diff --git a/Cargo.lock b/Cargo.lock index f286b64c33..70ba0b09b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,12 +50,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aliasable" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" - [[package]] name = "allocator-api2" version = "0.2.21" @@ -1008,21 +1002,20 @@ dependencies = [ [[package]] name = "bzip2" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b89e7c29231c673a61a46e722602bcd138298f6b9e81e71119693534585f5c" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" dependencies = [ "bzip2-sys", ] [[package]] name = "bzip2-sys" -version = "0.1.12+1.0.8" +version = "0.1.13+1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ebc2f1a417f01e1da30ef264ee86ae31d2dcd2d603ea283d3c244a883ca2a9" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" dependencies = [ "cc", - "libc", "pkg-config", ] @@ -1213,7 +1206,7 @@ version = "4.5.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn", @@ -1780,7 +1773,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn", @@ -2451,12 +2444,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -4021,30 +4008,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "ouroboros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" -dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - [[package]] name = "outref" version = "0.5.2" @@ -4373,6 +4336,7 @@ dependencies = [ "rattler_conda_types", "rattler_digest", "rattler_lock", + "rattler_menuinst", "rattler_networking", "rattler_repodata_gateway", "rattler_shell", @@ -4890,19 +4854,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - [[package]] name = "procfs" version = "0.17.0" @@ -5192,9 +5143,9 @@ dependencies = [ [[package]] name = "rattler" -version = "0.32.3" +version = "0.32.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da33201346a984d69ec4eae6ff21f599a8166e15876b129e6e48cbfcf0d7f51" +checksum = "78f7a0f01c00a744e5b1d14cb30556a59de6092eedb0dc384d28e41a9cadfe37" dependencies = [ "anyhow", "clap", @@ -5236,9 +5187,9 @@ dependencies = [ [[package]] name = "rattler_cache" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704d5159f1ab6ca2500345536b3e45a1d4ce18aa9995c7a8d44bfeaa8db61b0c" +checksum = "75de91ad84b88ff2a96240ba5a122cc0276f266bd0db9c355780a8b25ca760f3" dependencies = [ "anyhow", "dashmap", @@ -5268,9 +5219,9 @@ dependencies = [ [[package]] name = "rattler_conda_types" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40d6c3379b4473aab0278a57d2fc8a7755dae122e4ad2e0a6165573aebad698c" +checksum = "a38f19d8845726afad1fd507ef76fae9e95914dfb16d300beaf0bc446e4cdbb3" dependencies = [ "chrono", "dirs 6.0.0", @@ -5359,9 +5310,9 @@ dependencies = [ [[package]] name = "rattler_menuinst" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8d4f0d757e8df30a46f1fcd8a53c33026f3c9611271da52a5e0b9f17f696eb" +checksum = "0bfe7aa3764ef7b38f9b2b07732e820d298f1134fecebe7e1720a86a4b71f5e2" dependencies = [ "chrono", "configparser", @@ -5389,9 +5340,9 @@ dependencies = [ [[package]] name = "rattler_networking" -version = "0.22.7" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c12eeb2048ecd992825be6f1eb7718ae1c8e5772c347948175f00bdcab27371a" +checksum = "142d9e08b909b85caa724323a32e305c571af9b7ea825d481972fc37696384c8" dependencies = [ "anyhow", "async-trait", @@ -5420,9 +5371,9 @@ dependencies = [ [[package]] name = "rattler_package_streaming" -version = "0.22.31" +version = "0.22.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5b15b323e873dd123f1e9cabe1718a6dfcbacbca81538f8e76b4d8e6202174" +checksum = "501024c475b83ff675c6e27512539855fd1907e365152462aa883296daee386a" dependencies = [ "bzip2", "chrono", @@ -5450,9 +5401,9 @@ dependencies = [ [[package]] name = "rattler_redaction" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5696e27b6dd0d23d49e93ad89677f27cb2811e85b1113ec3fe2a7b718d32bc90" +checksum = "2fb481bc7af5d6564f120307bc4481efb4e55c8502424acec3409a4e5d61ef12" dependencies = [ "reqwest", "reqwest-middleware", @@ -5461,9 +5412,9 @@ dependencies = [ [[package]] name = "rattler_repodata_gateway" -version = "0.21.40" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1556d20ba3bf458e6236213c5718439dcc43fa591cafadaab197d2fa1921e06f" +checksum = "537dec187efd7603e463d077d85361fba657b6abd2e86ce6a9275c6176635364" dependencies = [ "anyhow", "async-compression", @@ -5472,6 +5423,7 @@ dependencies = [ "blake2", "bytes", "cache_control", + "cfg-if", "chrono", "dashmap", "dirs 6.0.0", @@ -5486,9 +5438,7 @@ dependencies = [ "itertools 0.14.0", "json-patch", "libc", - "md-5", "memmap2 0.9.5", - "ouroboros", "parking_lot 0.12.3", "pin-project-lite", "rattler_cache", @@ -5500,6 +5450,7 @@ dependencies = [ "reqwest-middleware", "retry-policies", "rmp-serde", + "self_cell", "serde", "serde_json", "serde_with", @@ -5511,6 +5462,7 @@ dependencies = [ "tokio-util", "tracing", "url", + "wasmtimer", "windows-sys 0.59.0", "zstd", ] @@ -5536,9 +5488,9 @@ dependencies = [ [[package]] name = "rattler_solve" -version = "1.3.11" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cb071d8b0f6bd6ef92ec7f72bd1a036019e5893dd3a666af228adaf81d80d9" +checksum = "32604285d4e970dd4716379013bb18ae9ffc49dc449862e7d54d120079a66ada" dependencies = [ "chrono", "futures", @@ -6333,6 +6285,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "self_cell" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" + [[package]] name = "semver" version = "1.0.25" @@ -6793,7 +6751,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", @@ -8673,6 +8631,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmtimer" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0048ad49a55b9deb3953841fa1fc5858f0efbcb7a18868c899a360269fac1b23" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.12.3", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "wax" version = "0.6.0" @@ -9354,12 +9326,6 @@ dependencies = [ "lzma-sys", ] -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.7.5" diff --git a/Cargo.toml b/Cargo.toml index ee16e7a542..b1c89aa958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,19 +116,20 @@ which = "7.0.2" # Rattler crates file_url = "0.2.3" -rattler = { version = "0.32.3", default-features = false } -rattler_cache = { version = "0.3.12", default-features = false } -rattler_conda_types = { version = "0.31.2", default-features = false, features = [ +rattler = { version = "0.32.4", default-features = false } +rattler_cache = { version = "0.3.13", default-features = false } +rattler_conda_types = { version = "0.31.3", default-features = false, features = [ "rayon", ] } rattler_digest = { version = "1.0.7", default-features = false } rattler_lock = { version = "0.22.46", default-features = false } -rattler_networking = { version = "0.22.7", default-features = false, features = [ +rattler_menuinst = { version = "0.2.2", default-features = false } +rattler_networking = { version = "0.22.8", default-features = false, features = [ "google-cloud-auth", ] } -rattler_repodata_gateway = { version = "0.21.40", default-features = false } +rattler_repodata_gateway = { version = "0.22.0", default-features = false } rattler_shell = { version = "0.22.22", default-features = false } -rattler_solve = { version = "1.3.11", default-features = false } +rattler_solve = { version = "1.4.0", default-features = false } rattler_virtual_packages = { version = "2.0.6", default-features = false } @@ -264,6 +265,7 @@ rattler = { workspace = true, features = ["cli-tools", "indicatif"] } rattler_conda_types = { workspace = true } rattler_digest = { workspace = true } rattler_lock = { workspace = true } +rattler_menuinst = { workspace = true } rattler_networking = { workspace = true } rattler_repodata_gateway = { workspace = true, features = [ "sparse", diff --git a/crates/pixi_consts/src/consts.rs b/crates/pixi_consts/src/consts.rs index 52bde05c60..fb1385e30c 100644 --- a/crates/pixi_consts/src/consts.rs +++ b/crates/pixi_consts/src/consts.rs @@ -31,6 +31,7 @@ pub const CONDA_PACKAGE_CACHE_DIR: &str = rattler_cache::PACKAGE_CACHE_DIR; pub const CONDA_REPODATA_CACHE_DIR: &str = rattler_cache::REPODATA_CACHE_DIR; // TODO: move to rattler pub const CONDA_META_DIR: &str = "conda-meta"; +pub const CONDA_MENU_SCHEMA_DIR: &str = "Menu"; pub const PYPI_CACHE_DIR: &str = "uv-cache"; pub const CONDA_PYPI_MAPPING_CACHE_DIR: &str = "conda-pypi-mapping"; pub const CACHED_ENVS_DIR: &str = "cached-envs-v0"; diff --git a/crates/pixi_toml/src/from_str.rs b/crates/pixi_toml/src/from_str.rs index 3dcf9cbb48..7f11925851 100644 --- a/crates/pixi_toml/src/from_str.rs +++ b/crates/pixi_toml/src/from_str.rs @@ -11,6 +11,7 @@ use crate::DeserializeAs; /// additional parsing beyond checking if the value is a string. If a type /// provides a [`FromStr`] implementation, this type can be used to deserialize /// the value and return the parsed value. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct TomlFromStr(T); impl TomlFromStr { diff --git a/docs/features/global_tools.md b/docs/features/global_tools.md index da6f06e772..0d94fa8014 100644 --- a/docs/features/global_tools.md +++ b/docs/features/global_tools.md @@ -219,6 +219,27 @@ When you execute a global install binary, a trampoline performs the following se * Once the configuration is loaded and the environment is set, the trampoline executes the original binary with the correct environment settings. * When installing a new binary, a new trampoline is placed in the `.pixi/bin` directory and is hard-linked to the `.pixi/bin/trampoline_configuration/trampoline_bin`. This optimizes storage space and avoids duplication of the same trampoline. +### Shortcuts + +Especially for graphical user interfaces it is useful to add shortcuts so that the operating system knows that about the application. +This way the application can show up in the start menu or be suggested when you want to open a file type the application supports. +If the package supports shortcuts, nothing has do be done from your side. +Simply executing `pixi global install` will do the trick. +For example, `pixi global install mss` will lead to the following manifest: + +```toml +[envs.mss] +channels = ["https://prefix.dev/conda-forge"] +dependencies = { mss = "*" } +exposed = { ... } +shortcuts = ["mss"] +``` + +Note the `shortcuts` entry. +If it's present, `pixi` will install the shortcut for the `mss` package. +If you want to package an application yourself that would benefit from this, you can check out the corresponding [documentation](https://conda.github.io/menuinst/). + + ### Example: Adding a series of tools at once Without specifying an environment, you can add multiple tools at once: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1ac817a343..f3bc65a32b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1119,6 +1119,8 @@ Allowing you to access it anywhere on your system without activating the environ - `--environment (-e)`: The environment to install the package into. (default: name of the tool) - `--expose `: A mapping from name to the binary to expose to the system. (default: name of the tool) - `--with `: Add additional dependencies to the environment. Their executables will not be exposed. +- `--force-reinstall`: Specifies that the packages should be reinstalled even if they are already installed +- `--no-shortcut`: Specifies that no shortcuts should be created for the installed packages ```shell pixi global install ruff diff --git a/src/cli/global/add.rs b/src/cli/global/add.rs index b6b65f941f..96825efe52 100644 --- a/src/cli/global/add.rs +++ b/src/cli/global/add.rs @@ -50,14 +50,14 @@ pub async fn execute(args: Args) -> miette::Result<()> { async fn apply_changes( env_name: &EnvironmentName, - specs: &[MatchSpec], + specs: Vec, expose: &[Mapping], project: &mut Project, ) -> miette::Result { let mut state_changes = StateChanges::new_with_env(env_name.clone()); // Add specs to the manifest - for spec in specs { + for spec in &specs { project.manifest.add_dependency( env_name, spec, @@ -82,6 +82,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { } let mut project_modified = project_original.clone(); + let specs = args .specs()? .into_iter() @@ -90,7 +91,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { match apply_changes( &args.environment, - specs.as_slice(), + specs, args.expose.as_slice(), &mut project_modified, ) diff --git a/src/cli/global/install.rs b/src/cli/global/install.rs index 384a241c4c..c2885d42ae 100644 --- a/src/cli/global/install.rs +++ b/src/cli/global/install.rs @@ -1,13 +1,19 @@ +use std::ops::Not; + use clap::Parser; use fancy_display::FancyDisplay; +use indexmap::IndexMap; use itertools::Itertools; use miette::{Context, IntoDiagnostic}; -use rattler_conda_types::{MatchSpec, NamedChannelOrUrl, Platform}; +use rattler_conda_types::{MatchSpec, NamedChannelOrUrl, PackageName, Platform}; use crate::{ cli::{global::revert_environment_after_error, has_specs::HasSpecs}, global::{ - self, common::NotChangedReason, list::list_global_environments, project::ExposedType, + self, + common::{contains_menuinst_document, NotChangedReason}, + list::list_global_environments, + project::ExposedType, EnvChanges, EnvState, EnvironmentName, Mapping, Project, StateChange, StateChanges, }, }; @@ -61,6 +67,10 @@ pub struct Args { /// Specifies that the packages should be reinstalled even if they are already installed. #[arg(action, long)] force_reinstall: bool, + + /// Specifies that no shortcuts should be created for the installed packages. + #[arg(action, long)] + no_shortcut: bool, } impl HasSpecs for Args { @@ -98,22 +108,17 @@ pub async fn execute(args: Args) -> miette::Result<()> { let mut last_updated_project = project_original; let specs = args.specs()?; for env_name in &env_names { + let specs = specs.clone(); let specs = if multiple_envs { specs - .clone() .into_iter() .filter(|(package_name, _)| env_name.as_str() == package_name.as_source()) - .map(|(_, spec)| spec) - .collect_vec() + .collect() } else { specs - .clone() - .into_iter() - .map(|(_, spec)| spec) - .collect_vec() }; let mut project = last_updated_project.clone(); - match setup_environment(env_name, &args, &specs, &mut project) + match setup_environment(env_name, &args, specs, &mut project) .await .wrap_err_with(|| format!("Couldn't install {}", env_name.fancy_display())) { @@ -157,7 +162,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { async fn setup_environment( env_name: &EnvironmentName, args: &Args, - specs: &[MatchSpec], + specs: IndexMap, project: &mut Project, ) -> miette::Result { let mut state_changes = StateChanges::new_with_env(env_name.clone()); @@ -179,7 +184,13 @@ async fn setup_environment( } // Add the dependencies to the environment - for spec in specs.iter().chain(&args.with) { + let packages_to_add = specs + .clone() + .into_iter() + .map(|(_, spec)| spec) + .chain(args.with.clone()) + .collect_vec(); + for spec in &packages_to_add { project.manifest.add_dependency( env_name, spec, @@ -202,6 +213,38 @@ async fn setup_environment( // Installing the environment to be able to find the bin paths later let _ = project.install_environment(env_name).await?; + // Sync exposed name + sync_exposed_names(env_name, project, args).await?; + + // Add shortcuts + if !args.no_shortcut { + let prefix = project.environment_prefix(env_name).await?; + for (package_name, _) in specs.iter() { + let prefix_record = prefix.find_designated_package(package_name).await?; + if contains_menuinst_document(&prefix_record) { + project.manifest.add_shortcut(env_name, package_name)?; + } + } + state_changes |= project.sync_shortcuts(env_name).await?; + } + + // Figure out added packages and their corresponding versions + state_changes |= project.added_packages(packages_to_add, env_name).await?; + + // Expose executables of the new environment + state_changes |= project + .expose_executables_from_environment(env_name) + .await?; + + project.manifest.save().await?; + Ok(state_changes) +} + +async fn sync_exposed_names( + env_name: &EnvironmentName, + project: &mut Project, + args: &Args, +) -> Result<(), miette::Error> { let with_package_names = args .with .iter() @@ -211,26 +254,13 @@ async fn setup_environment( .ok_or_else(|| miette::miette!("could not find package name in MatchSpec {}", spec)) }) .collect::>>()?; - - // Sync exposed binaries - let expose_type = if !args.expose.is_empty() { + let expose_type = if args.expose.is_empty().not() { ExposedType::Mappings(args.expose.clone()) } else if with_package_names.is_empty() { ExposedType::All } else { ExposedType::Ignore(with_package_names) }; - project.sync_exposed_names(env_name, expose_type).await?; - - // Figure out added packages and their corresponding versions - state_changes |= project.added_packages(specs, env_name).await?; - - // Expose executables of the new environment - state_changes |= project - .expose_executables_from_environment(env_name) - .await?; - - project.manifest.save().await?; - Ok(state_changes) + Ok(()) } diff --git a/src/cli/global/update.rs b/src/cli/global/update.rs index f6b57039ec..e355c5c486 100644 --- a/src/cli/global/update.rs +++ b/src/cli/global/update.rs @@ -31,10 +31,10 @@ pub async fn execute(args: Args) -> miette::Result<()> { let env_binaries = project.executables_of_direct_dependencies(env_name).await?; // Get the exposed binaries from mapping - let exposed_mapping_binaries = project + let exposed_mapping_binaries = &project .environment(env_name) .ok_or_else(|| miette::miette!("Environment {} not found", env_name.fancy_display()))? - .exposed(); + .exposed; // Check if they were all auto-exposed, or if the user manually exposed a subset of them let expose_type = if check_all_exposed(&env_binaries, exposed_mapping_binaries) { diff --git a/src/global/common.rs b/src/global/common.rs index 869ecf67e4..29971fa96e 100644 --- a/src/global/common.rs +++ b/src/global/common.rs @@ -352,6 +352,8 @@ pub(crate) enum StateChange { AddedEnvironment, RemovedEnvironment, UpdatedEnvironment(EnvironmentUpdate), + InstalledShortcut(String), + UninstalledShortcut(String), } #[must_use] @@ -587,6 +589,66 @@ impl StateChanges { StateChange::UpdatedEnvironment(update_change) => { StateChanges::report_update_changes(&env_name, update_change); } + StateChange::InstalledShortcut(name) => { + let mut installed_items = StateChanges::accumulate_changes( + &mut iter, + |next| match next { + Some(StateChange::InstalledShortcut(name)) => Some(name.clone()), + _ => None, + }, + Some(name.clone()), + ); + + installed_items.sort(); + + if installed_items.len() == 1 { + eprintln!( + "{}Installed shortcut {} in environment {}.", + console::style(console::Emoji("✔ ", "")).green(), + installed_items[0], + env_name.fancy_display() + ); + } else { + eprintln!( + "{}Installed shortcuts in environment {}:", + console::style(console::Emoji("✔ ", "")).green(), + env_name.fancy_display() + ); + for installed_item in installed_items { + eprintln!(" - {}", installed_item); + } + } + } + StateChange::UninstalledShortcut(name) => { + let mut uninstalled_items = StateChanges::accumulate_changes( + &mut iter, + |next| match next { + Some(StateChange::UninstalledShortcut(name)) => Some(name.clone()), + _ => None, + }, + Some(name.clone()), + ); + + uninstalled_items.sort(); + + if uninstalled_items.len() == 1 { + eprintln!( + "{}Uninstalled shortcut {} in environment {}.", + console::style(console::Emoji("✔ ", "")).green(), + uninstalled_items[0], + env_name.fancy_display() + ); + } else { + eprintln!( + "{}Uninstalled shortcuts in environment {}:", + console::style(console::Emoji("✔ ", "")).green(), + env_name.fancy_display() + ); + for uninstalled_item in uninstalled_items { + eprintln!(" - {}", uninstalled_item); + } + } + } } } } @@ -697,10 +759,65 @@ pub(crate) fn channel_url_to_prioritized_channel( .into()) } +/// Determines which shortcuts need to be installed or removed by comparing the requested shortcuts +/// with the installed package records. +/// +/// This function filters the provided `prefix_records` to find those that contain menuinst JSON files. +/// It then compares these records with the requested `shortcuts` to +/// determine which records need to be installed and which need to be uninstalled. +pub(crate) fn shortcut_sync_status( + shortcuts: IndexSet, + prefix_records: Vec, +) -> miette::Result<(Vec, Vec)> { + let mut remaining_shortcuts = shortcuts; + let mut records_to_install = Vec::new(); + let mut records_to_uninstall = Vec::new(); + + let records_with_menuinst = prefix_records + .into_iter() + .filter(contains_menuinst_document); + + for record in records_with_menuinst { + let has_installed_system_menus = record.installed_system_menus.is_empty().not(); + if remaining_shortcuts + .swap_take(&record.repodata_record.package_record.name) + .is_some() + { + if !has_installed_system_menus { + // The package record isn't installed, but it is requested + records_to_install.push(record); + } + } else if has_installed_system_menus { + // The package record is installed, but not requested + records_to_uninstall.push(record); + } + } + + if remaining_shortcuts.is_empty().not() { + miette::bail!( + "the following shortcuts are requested but not available: {}", + remaining_shortcuts + .iter() + .map(|n| n.as_normalized()) + .join(", ") + ); + } + Ok((records_to_install, records_to_uninstall)) +} + +pub(crate) fn contains_menuinst_document(prefix_record: &PrefixRecord) -> bool { + prefix_record.files.iter().any(|file| { + file.extension().is_some_and(|ext| ext == "json") + && file + .parent() + .is_some_and(|parent| parent.file_name().is_some_and(|f| f == "Menu")) + }) +} + /// Figures out what the status is of the exposed binaries of the environment. /// /// Returns a tuple of the exposed binaries to remove and the exposed binaries to add. -pub(crate) async fn get_expose_scripts_sync_status( +pub(crate) async fn expose_scripts_sync_status( bin_dir: &BinDir, env_dir: &EnvDir, mappings: &IndexSet, @@ -959,7 +1076,7 @@ mod tests { // Test empty let exposed = IndexSet::new(); - let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + let (to_remove, to_add) = expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) .await .unwrap(); assert!(to_remove.is_empty()); @@ -979,7 +1096,7 @@ mod tests { .unwrap() .to_string(), )); - let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + let (to_remove, to_add) = expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) .await .unwrap(); assert!(to_remove.is_empty()); @@ -1036,7 +1153,7 @@ mod tests { }; // Test to_remove and to_add to see if the legacy scripts are removed and trampolines are added - let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + let (to_remove, to_add) = expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) .await .unwrap(); assert!(to_remove.iter().all(|bin| !bin.is_trampoline())); @@ -1045,10 +1162,9 @@ mod tests { // Test to_remove when nothing should be exposed // it should remove all the legacy scripts and add nothing - let (to_remove, to_add) = - get_expose_scripts_sync_status(&bin_dir, &env_dir, &IndexSet::new()) - .await - .unwrap(); + let (to_remove, to_add) = expose_scripts_sync_status(&bin_dir, &env_dir, &IndexSet::new()) + .await + .unwrap(); assert!(to_remove.iter().all(|bin| !bin.is_trampoline())); assert_eq!(to_remove.len(), 2); @@ -1066,7 +1182,7 @@ mod tests { // Test empty let exposed = IndexSet::new(); - let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + let (to_remove, to_add) = expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) .await .unwrap(); assert!(to_remove.is_empty()); @@ -1079,7 +1195,7 @@ mod tests { "test".to_string(), )); - let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + let (to_remove, to_add) = expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) .await .unwrap(); assert!(to_remove.is_empty()); @@ -1101,7 +1217,7 @@ mod tests { trampoline.save().await.unwrap(); - let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + let (to_remove, to_add) = expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) .await .unwrap(); @@ -1110,7 +1226,7 @@ mod tests { // Test to_remove when nothing should be exposed let (mut to_remove, to_add) = - get_expose_scripts_sync_status(&bin_dir, &env_dir, &IndexSet::new()) + expose_scripts_sync_status(&bin_dir, &env_dir, &IndexSet::new()) .await .unwrap(); assert_eq!(to_remove.len(), 1); diff --git a/src/global/list.rs b/src/global/list.rs index 5ea2ae5a88..89579225b7 100644 --- a/src/global/list.rs +++ b/src/global/list.rs @@ -86,7 +86,7 @@ fn print_meta_info(environment: &ParsedEnvironment) { } // Print platform - if let Some(platform) = environment.platform() { + if let Some(platform) = environment.platform { println!("{} {}", console::style("Platform:").bold().cyan(), platform); } } @@ -162,7 +162,8 @@ pub async fn list_environment( .map(|record| { PackageToOutput::new( &record.repodata_record.package_record, - env.dependencies() + env.dependencies + .specs .contains_key(&record.repodata_record.package_record.name), ) }) @@ -269,7 +270,8 @@ pub async fn list_global_environments( .unwrap_or("".to_string()); if !env - .dependencies() + .dependencies + .specs .iter() .any(|(pkg_name, _spec)| pkg_name.as_normalized() != env_name.as_str()) { @@ -303,7 +305,7 @@ pub async fn list_global_environments( } // Write exposed binaries - if let Some(exp_message) = format_exposed(env.exposed(), last) { + if let Some(exp_message) = format_exposed(&env.exposed, last) { message.push_str(&exp_message); } diff --git a/src/global/project/environment.rs b/src/global/project/environment.rs index f549bc78d9..e74741b9ae 100644 --- a/src/global/project/environment.rs +++ b/src/global/project/environment.rs @@ -1,13 +1,11 @@ use crate::global::install::local_environment_matches_spec; -use crate::global::EnvDir; -use crate::prefix::Prefix; use console::StyledObject; use fancy_display::FancyDisplay; use indexmap::IndexSet; use itertools::Itertools; use miette::Diagnostic; use pixi_consts::consts; -use rattler_conda_types::{MatchSpec, Platform}; +use rattler_conda_types::{MatchSpec, Platform, PrefixRecord}; use regex::Regex; use serde::{self, Deserialize, Deserializer, Serialize}; use std::{fmt, str::FromStr}; @@ -82,16 +80,13 @@ pub struct ParseEnvironmentNameError { /// Checks if the manifest is in sync with the locally installed environment and binaries. /// Returns `true` if the environment is in sync, `false` otherwise. pub(crate) async fn environment_specs_in_sync( - env_dir: &EnvDir, + prefix_records: &[PrefixRecord], specs: &IndexSet, platform: Option, ) -> miette::Result { - let prefix = Prefix::new(env_dir.path()); - - let repodata_records = prefix - .find_installed_packages()? - .into_iter() - .map(|r| r.repodata_record) + let repodata_records = prefix_records + .iter() + .map(|r| r.repodata_record.clone()) .collect_vec(); if !local_environment_matches_spec(repodata_records, specs, platform) { @@ -104,7 +99,10 @@ pub(crate) async fn environment_specs_in_sync( mod tests { use super::*; - use crate::global::EnvRoot; + use crate::{ + global::{EnvDir, EnvRoot}, + prefix::Prefix, + }; use fs_err::tokio as tokio_fs; use rattler_conda_types::ParseStrictness; use std::path::PathBuf; @@ -118,7 +116,9 @@ mod tests { // Test empty let specs = IndexSet::new(); - let result = environment_specs_in_sync(&env_dir, &specs, None) + let prefix = Prefix::new(env_dir.path()); + let prefix_records = prefix.find_installed_packages().unwrap(); + let result = environment_specs_in_sync(&prefix_records, &specs, None) .await .unwrap(); assert!(result); @@ -138,7 +138,8 @@ mod tests { .await .unwrap(); - let result = environment_specs_in_sync(&env_dir, &specs, None) + let prefix_records = prefix.find_installed_packages().unwrap(); + let result = environment_specs_in_sync(&prefix_records, &specs, None) .await .unwrap(); assert!(result); diff --git a/src/global/project/manifest.rs b/src/global/project/manifest.rs index d625f83619..3130ee45ab 100644 --- a/src/global/project/manifest.rs +++ b/src/global/project/manifest.rs @@ -318,8 +318,8 @@ impl Manifest { /// Checks if an exposed name already exists in other environments pub fn exposed_name_already_exists_in_other_envs( &self, - exposed_name: &ExposedName, env_name: &EnvironmentName, + exposed_name: &ExposedName, ) -> bool { self.parsed .envs @@ -341,7 +341,7 @@ impl Manifest { } // Ensure exposed name is unique - if self.exposed_name_already_exists_in_other_envs(&mapping.exposed_name, env_name) { + if self.exposed_name_already_exists_in_other_envs(env_name, &mapping.exposed_name) { miette::bail!( "Exposed name {} already exists", mapping.exposed_name.fancy_display() @@ -423,6 +423,77 @@ impl Manifest { Ok(()) } + /// Checks if an exposed name already exists in other environments + pub fn shortcut_already_exists_in_other_envs( + &self, + env_name: &EnvironmentName, + shortcut: &PackageName, + ) -> bool { + self.parsed + .envs + .iter() + .filter_map(|(name, env)| if name != env_name { Some(env) } else { None }) + .flat_map(|env| env.shortcuts.iter().flat_map(|s| s.iter())) + .any(|s| s == shortcut) + } + + /// Adds shortcut to the manifest + pub fn add_shortcut( + &mut self, + env_name: &EnvironmentName, + shortcut: &PackageName, + ) -> miette::Result<()> { + // Ensure the environment exists + if !self.parsed.envs.contains_key(env_name) { + miette::bail!("Environment {} doesn't exist", env_name.fancy_display()); + } + + // Ensure shortcut is unique + if self.shortcut_already_exists_in_other_envs(env_name, shortcut) { + miette::bail!( + "Shortcut {} already exists", + console::style(shortcut.as_normalized()).green() + ); + } + + // Update self.parsed + let env = self + .parsed + .envs + .get_mut(env_name) + .ok_or_else(|| miette::miette!("This should be impossible"))?; + env.shortcuts + .get_or_insert_default() + .insert(shortcut.clone()); + + // Update self.document + let shortcuts_array = self + .document + .get_or_insert_nested_table(&format!("envs.{env_name}"))? + .entry("shortcuts") + .or_insert_with(|| toml_edit::Item::Value(toml_edit::Value::Array(Default::default()))) + .as_array_mut() + .ok_or_else(|| miette::miette!("Expected an array for shortcuts"))?; + + // Convert existing TOML array to a IndexSet to ensure uniqueness + let mut existing_shortcuts: IndexSet = shortcuts_array + .iter() + .filter_map(|item| item.as_str().map(|s| s.to_string())) + .collect(); + + // Add the new shortcut to the HashSet + existing_shortcuts.insert(shortcut.as_normalized().to_string()); + + // Reinsert unique shortcuts + *shortcuts_array = existing_shortcuts.iter().collect(); + + tracing::debug!( + "Added channel {} for environment {env_name} in toml document", + console::style(shortcut.as_normalized()).green() + ); + Ok(()) + } + /// Saves the manifest to the file system pub async fn save(&self) -> miette::Result<()> { let contents = { diff --git a/src/global/project/mod.rs b/src/global/project/mod.rs index 365ea16eb8..d81cd651e8 100644 --- a/src/global/project/mod.rs +++ b/src/global/project/mod.rs @@ -1,6 +1,6 @@ use self::trampoline::{Configuration, ConfigurationParseError, Trampoline}; use super::{ - common::{get_install_changes, EnvironmentUpdate}, + common::{get_install_changes, shortcut_sync_status, EnvironmentUpdate}, install::find_binary_by_name, trampoline::{self, GlobalExecutable}, BinDir, EnvRoot, StateChange, StateChanges, @@ -8,8 +8,7 @@ use super::{ use crate::{ global::{ common::{ - channel_url_to_prioritized_channel, find_package_records, - get_expose_scripts_sync_status, + channel_url_to_prioritized_channel, expose_scripts_sync_status, find_package_records, }, find_executables, find_executables_for_many_records, install::{create_executable_trampolines, script_exec_mapping}, @@ -44,7 +43,8 @@ use rattler::{ package_cache::PackageCache, }; use rattler_conda_types::{ - ChannelConfig, GenericVirtualPackage, MatchSpec, PackageName, Platform, PrefixRecord, + menuinst::MenuMode, ChannelConfig, GenericVirtualPackage, MatchSpec, PackageName, Platform, + PrefixRecord, }; use rattler_lock::Matches; use rattler_repodata_gateway::Gateway; @@ -615,6 +615,9 @@ impl Project { let env_dir = EnvDir::from_env_root(self.env_root.clone(), env_name).await?; let mut state_changes = StateChanges::new_with_env(env_name.clone()); + // Remove all shortcuts, using the information still available in the environment + state_changes |= self.remove_shortcuts(env_name).await?; + // Remove the environment from the manifest, if it exists, otherwise ignore // error. self.manifest.remove_environment(env_name)?; @@ -626,7 +629,7 @@ impl Project { // Get all removable binaries related to the environment let (to_remove, _to_add) = - get_expose_scripts_sync_status(&self.bin_dir, &env_dir, &IndexSet::new()).await?; + expose_scripts_sync_status(&self.bin_dir, &env_dir, &IndexSet::new()).await?; // Remove all removable binaries for binary_path in to_remove { @@ -653,7 +656,7 @@ impl Project { // Get all removable binaries related to the environment let (to_remove, _to_add) = - get_expose_scripts_sync_status(&self.bin_dir, &env_dir, &environment.exposed).await?; + expose_scripts_sync_status(&self.bin_dir, &env_dir, &environment.exposed).await?; // Remove all removable binaries for exposed_path in to_remove { @@ -691,7 +694,7 @@ impl Project { .environment(env_name) .ok_or_else(|| miette::miette!("Environment {} not found", env_name.fancy_display()))?; - let package_names: Vec<_> = parsed_env.dependencies().keys().cloned().collect(); + let package_names: Vec<_> = parsed_env.dependencies.specs.keys().cloned().collect(); let mut executables_for_package = IndexMap::new(); @@ -841,21 +844,45 @@ impl Project { let env_dir = EnvDir::from_path(self.env_root.clone().path().join(env_name.clone().as_str())); + let prefix_records = self + .environment_prefix(env_name) + .await? + .find_installed_packages()?; let specs_in_sync = - environment_specs_in_sync(&env_dir, &specs, environment.platform).await?; + environment_specs_in_sync(&prefix_records, &specs, environment.platform).await?; if !specs_in_sync { return Ok(false); } tracing::debug!("Verify that the binaries are in sync with the environment"); - let (to_remove, to_add) = - get_expose_scripts_sync_status(&self.bin_dir, &env_dir, &environment.exposed).await?; - if !to_remove.is_empty() || !to_add.is_empty() { + let (exec_to_remove, exec_to_add) = + expose_scripts_sync_status(&self.bin_dir, &env_dir, &environment.exposed).await?; + if !exec_to_remove.is_empty() || !exec_to_add.is_empty() { tracing::debug!( - "Environment {} binaries not in sync: to_remove: {:?}, to_add: {:?}", + "Environment {} binaries are not in sync: to_remove: {:?}, to_add: {:?}", env_name.fancy_display(), - to_remove, - to_add + exec_to_remove, + exec_to_add + ); + return Ok(false); + } + + tracing::debug!("Verify that the shortcuts are in sync with the environment"); + let shortcuts = environment.shortcuts.clone().unwrap_or_default(); + let (shortcuts_to_remove, shortcuts_to_add) = + shortcut_sync_status(shortcuts, prefix_records)?; + if !shortcuts_to_remove.is_empty() || !shortcuts_to_add.is_empty() { + tracing::debug!( + "Environment {} shortcuts are not in sync: to_remove: {}, to_add: {}", + env_name.fancy_display(), + shortcuts_to_remove + .iter() + .map(|s| s.repodata_record.package_record.name.as_normalized()) + .join(", "), + shortcuts_to_add + .iter() + .map(|s| s.repodata_record.package_record.name.as_normalized()) + .join(", ") ); return Ok(false); } @@ -974,6 +1001,9 @@ impl Project { // Expose executables state_changes |= self.expose_executables_from_environment(env_name).await?; + // Install shortcuts + state_changes |= self.sync_shortcuts(env_name).await?; + Ok(state_changes) } @@ -1028,12 +1058,15 @@ impl Project { if !env_set.contains(&env_name) { // Test if the environment directory is a conda environment if let Ok(true) = env_path.join(consts::CONDA_META_DIR).try_exists() { + // Remove all shortcuts, using the information still available in the environment + state_changes |= self.remove_shortcuts(&env_name).await?; + // Remove the conda environment tokio_fs::remove_dir_all(&env_path) .await .into_diagnostic()?; // Get all removable binaries related to the environment - let (to_remove, _to_add) = get_expose_scripts_sync_status( + let (to_remove, _to_add) = expose_scripts_sync_status( &self.bin_dir, &EnvDir::from_path(env_path.clone()), &IndexSet::new(), @@ -1054,7 +1087,7 @@ impl Project { // Figure which packages have been added pub async fn added_packages( &self, - specs: &[MatchSpec], + specs: Vec, env_name: &EnvironmentName, ) -> miette::Result { let mut state_changes = StateChanges::default(); @@ -1070,6 +1103,86 @@ impl Project { ); Ok(state_changes) } + + /// Install shortcuts of a specific environment + pub async fn sync_shortcuts(&self, env_name: &EnvironmentName) -> miette::Result { + let mut state_changes = StateChanges::default(); + let environment = self + .environment(env_name) + .ok_or_else(|| miette::miette!("Environment {} not found", env_name.fancy_display()))?; + + let prefix = self.environment_prefix(env_name).await?; + let prefix_records = prefix.find_installed_packages()?; + + let shortcuts = environment.shortcuts.clone().unwrap_or_default(); + let (records_to_install, records_to_uninstall) = + shortcut_sync_status(shortcuts, prefix_records)?; + + for record in records_to_install { + rattler_menuinst::install_menuitems_for_record( + prefix.root(), + &record, + environment.platform.unwrap_or(Platform::current()), + MenuMode::User, + ) + .into_diagnostic()?; + + state_changes.insert_change( + env_name, + StateChange::InstalledShortcut( + record + .repodata_record + .package_record + .name + .as_normalized() + .to_owned(), + ), + ); + } + + for record in records_to_uninstall { + rattler_menuinst::remove_menu_items(&record.installed_system_menus) + .into_diagnostic()?; + + state_changes.insert_change( + env_name, + StateChange::UninstalledShortcut( + record + .repodata_record + .package_record + .name + .as_normalized() + .to_owned(), + ), + ); + } + + Ok(state_changes) + } + + /// Remove the shortcuts from the system coming from a specific environment + pub async fn remove_shortcuts( + &self, + env_name: &EnvironmentName, + ) -> miette::Result { + let mut state_changes = StateChanges::default(); + + // Find menu items in the prefix + let prefix = self.environment_prefix(env_name).await?; + let prefix_records = prefix.find_installed_packages()?; + + // Remove menu items + for record in prefix_records { + rattler_menuinst::remove_menu_items(&record.installed_system_menus) + .into_diagnostic()?; + tracing::info!("Uninstalled menu items for: '{}'", record.file_name()); + state_changes.insert_change( + env_name, + StateChange::UninstalledShortcut(record.file_name().to_string()), + ); + } + Ok(state_changes) + } } impl Repodata for Project { diff --git a/src/global/project/parsed_manifest.rs b/src/global/project/parsed_manifest.rs index 62af09155e..a9ada65f40 100644 --- a/src/global/project/parsed_manifest.rs +++ b/src/global/project/parsed_manifest.rs @@ -8,7 +8,7 @@ use miette::{Context, Diagnostic, IntoDiagnostic, LabeledSpan, NamedSource, Repo use pixi_consts::consts; use pixi_manifest::{toml::TomlPlatform, utils::package_map::UniquePackageMap, PrioritizedChannel}; use pixi_spec::PixiSpec; -use pixi_toml::{TomlIndexMap, TomlIndexSet}; +use pixi_toml::{TomlFromStr, TomlIndexMap, TomlIndexSet, TomlWith}; use rattler_conda_types::{NamedChannelOrUrl, PackageName, Platform}; use serde::{ser::SerializeMap, Serialize, Serializer}; use serde_with::serde_derive::Deserialize; @@ -129,34 +129,8 @@ impl<'de> toml_span::Deserialize<'de> for ParsedManifest { .map(TomlIndexMap::into_inner) .unwrap_or_default(); - // Check for duplicate keys in the exposed fields - let mut exposed_names = IndexSet::new(); - let mut duplicates = IndexMap::new(); - for key in envs - .values() - .flat_map(|env| env.exposed.iter().map(|m| m.exposed_name())) - { - if !exposed_names.insert(key) { - duplicates.entry(key).or_insert_with(Vec::new).push(key); - } - } - if !duplicates.is_empty() { - return Err(DeserError::from(toml_span::Error { - kind: toml_span::ErrorKind::Custom( - format!( - "Duplicated exposed names found: {}", - duplicates - .keys() - .sorted() - .map(|exposed_name| exposed_name.fancy_display()) - .join(", ") - ) - .into(), - ), - span: value.span, - line_info: None, - })); - } + ensure_unique_exposed_names(value, &envs)?; + ensure_unique_shortcut_names(value, &envs)?; th.finalize(None)?; @@ -164,6 +138,71 @@ impl<'de> toml_span::Deserialize<'de> for ParsedManifest { } } +fn ensure_unique_exposed_names( + value: &mut Value<'_>, + envs: &IndexMap, +) -> Result<(), DeserError> { + let mut exposed_names = IndexSet::new(); + let mut duplicates = IndexMap::new(); + for key in envs + .values() + .flat_map(|env| env.exposed.iter().map(|m| m.exposed_name())) + { + if !exposed_names.insert(key) { + duplicates.entry(key).or_insert_with(Vec::new).push(key); + } + } + if !duplicates.is_empty() { + return Err(DeserError::from(toml_span::Error { + kind: toml_span::ErrorKind::Custom( + format!( + "Duplicated exposed names found: {}", + duplicates + .keys() + .sorted() + .map(|exposed_name| exposed_name.fancy_display()) + .join(", ") + ) + .into(), + ), + span: value.span, + line_info: None, + })); + } + Ok(()) +} + +fn ensure_unique_shortcut_names( + value: &mut Value<'_>, + envs: &IndexMap, +) -> Result<(), DeserError> { + let mut shortcut_names = IndexSet::new(); + let mut duplicates = IndexMap::new(); + for key in envs.values().flat_map(|env| env.shortcuts.iter().flatten()) { + if !shortcut_names.insert(key) { + duplicates.entry(key).or_insert_with(Vec::new).push(key); + } + } + if !duplicates.is_empty() { + return Err(DeserError::from(toml_span::Error { + kind: toml_span::ErrorKind::Custom( + format!( + "Duplicated shortcut names found: {}", + duplicates + .keys() + .sorted() + .map(|shortcut_name| console::style(shortcut_name.as_normalized()).green()) + .join(", ") + ) + .into(), + ), + span: value.span, + line_info: None, + })); + } + Ok(()) +} + impl ParsedManifest { /// Parses a toml string into a project manifest. pub(crate) fn from_toml_str(source: &str) -> Result { @@ -252,12 +291,13 @@ where #[derive(Serialize, Debug, Clone, Default)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub(crate) struct ParsedEnvironment { - pub channels: IndexSet, + pub(crate) channels: IndexSet, /// Platform used by the environment. - pub platform: Option, + pub(crate) platform: Option, pub(crate) dependencies: UniquePackageMap, #[serde(default, serialize_with = "serialize_expose_mappings")] pub(crate) exposed: IndexSet, + pub(crate) shortcuts: Option>, } impl<'de> toml_span::Deserialize<'de> for ParsedEnvironment { @@ -274,6 +314,9 @@ impl<'de> toml_span::Deserialize<'de> for ParsedEnvironment { .optional::("exposed") .map(TomlMapping::into_inner) .unwrap_or_default(); + let shortcuts = th + .optional_s::>>>("shortcuts") + .map(|s| s.value.into_inner()); th.finalize(None)?; @@ -282,6 +325,7 @@ impl<'de> toml_span::Deserialize<'de> for ParsedEnvironment { platform, dependencies, exposed, + shortcuts, }) } } @@ -294,26 +338,11 @@ impl ParsedEnvironment { ..Default::default() } } - /// Returns the platform associated with this platform, `None` means current - /// platform - pub(crate) fn platform(&self) -> Option { - self.platform - } /// Returns the channels associated with this environment. pub(crate) fn channels(&self) -> IndexSet<&NamedChannelOrUrl> { PrioritizedChannel::sort_channels_by_priority(&self.channels).collect() } - - /// Returns the dependencies associated with this environment. - pub(crate) fn dependencies(&self) -> &IndexMap { - &self.dependencies.specs - } - - /// Returns the exposed name mappings associated with this environment. - pub(crate) fn exposed(&self) -> &IndexSet { - &self.exposed - } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, PartialOrd, Ord)] diff --git a/src/global/project/snapshots/pixi__global__project__parsed_manifest__tests__invalid_key.snap b/src/global/project/snapshots/pixi__global__project__parsed_manifest__tests__invalid_key.snap index e04ac695e1..64142dfa2b 100644 --- a/src/global/project/snapshots/pixi__global__project__parsed_manifest__tests__invalid_key.snap +++ b/src/global/project/snapshots/pixi__global__project__parsed_manifest__tests__invalid_key.snap @@ -5,5 +5,5 @@ expression: "examples.into_iter().map(|example|\nParsedManifest::from_toml_str(e unexpected keys in table: `[("invalid", Span { start: 1, end: 8 })]` expected: ["version", "envs"] unexpected keys in table: `[("invalid", Span { start: 14, end: 21 })]` -expected: ["channels", "platform", "dependencies", "exposed"] +expected: ["channels", "platform", "dependencies", "exposed", "shortcuts"] Failed to parse environment name 'python;3', please use only lowercase letters, numbers, dashes and underscores diff --git a/src/prefix.rs b/src/prefix.rs index 48eefee216..e7a9011866 100644 --- a/src/prefix.rs +++ b/src/prefix.rs @@ -71,6 +71,8 @@ impl Prefix { .map_err(|err| PrefixError::PrefixRecordCollectionError(err, self.root.clone())) } + /// Processes prefix records (that you can get by using `find_installed_packages`) + /// to filter and collect executable files. /// Processes prefix records (that you can get by using /// `find_installed_packages`) to filter and collect executable files. pub fn find_executables(&self, prefix_packages: &[PrefixRecord]) -> Vec { diff --git a/tests/data/channels/channels/shortcuts_channel_1/noarch/pixi-editor-0.1.3-h4616a5c_0.conda b/tests/data/channels/channels/shortcuts_channel_1/noarch/pixi-editor-0.1.3-h4616a5c_0.conda new file mode 100644 index 0000000000..7213c3f36f Binary files /dev/null and b/tests/data/channels/channels/shortcuts_channel_1/noarch/pixi-editor-0.1.3-h4616a5c_0.conda differ diff --git a/tests/data/channels/channels/shortcuts_channel_1/noarch/repodata.json b/tests/data/channels/channels/shortcuts_channel_1/noarch/repodata.json new file mode 100644 index 0000000000..2b325db167 --- /dev/null +++ b/tests/data/channels/channels/shortcuts_channel_1/noarch/repodata.json @@ -0,0 +1,22 @@ +{ + "info": { + "subdir": "noarch" + }, + "packages": {}, + "packages.conda": { + "pixi-editor-0.1.3-h4616a5c_0.conda": { + "build": "h4616a5c_0", + "build_number": 0, + "depends": [], + "md5": "710f47b333ecfeeaf1fd8e1b02542283", + "name": "pixi-editor", + "noarch": "generic", + "sha256": "aa4823f8f8c9d999a97f7f85e17f24495b45892a6619c7875a255462bcb5e7d4", + "size": 335330, + "subdir": "noarch", + "timestamp": 1740648548184, + "version": "0.1.3" + } + }, + "repodata_version": 2 +} diff --git a/tests/data/channels/mappings.toml b/tests/data/channels/mappings.toml index 67dad465b2..a0e17059a1 100644 --- a/tests/data/channels/mappings.toml +++ b/tests/data/channels/mappings.toml @@ -4,7 +4,7 @@ "multiple_versions_channel_1_020.yaml" = "multiple_versions_channel_1" "non_self_expose_channel_1.yaml" = "non_self_expose_channel_1" "non_self_expose_channel_2.yaml" = "non_self_expose_channel_2" +"pixi-editor/recipe.yaml" = "shortcuts_channel_1" "trampoline/trampoline_1.yaml" = "trampoline_1" "trampoline/trampoline_2.yaml" = "trampoline_2" "trampoline/trampoline_path.yaml" = "trampoline_path_channel" -"virtual_packages/virtual_packages.yaml" = "virtual_packages" diff --git a/tests/data/channels/recipes/pixi-editor/Menu/menu.json b/tests/data/channels/recipes/pixi-editor/Menu/menu.json new file mode 100644 index 0000000000..bed1da30c0 --- /dev/null +++ b/tests/data/channels/recipes/pixi-editor/Menu/menu.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "menu_name": "pixi-editor", + "menu_items": [ + { + "name": { + "target_environment_is_base": "pixi-editor", + "target_environment_is_not_base": "pixi-editor ({{ ENV_NAME }})" + }, + "description": "Scientific Python Development Environment", + "icon": "{{ MENU_DIR }}/pixi-icon.{{ ICON_EXT }}", + "activate": false, + "terminal": false, + "command": [""], + "platforms": { + "win": { + "desktop": true, + "app_user_model_id": "dev.prefix.pixi-editor", + "command": ["notepad.exe", "%*"], + "file_extensions": [ + ".pixi" + ] + }, + "linux": { + "Categories": [ + "Development", + "Science" + ], + "command": ["gedit", "%F"], + "MimeType": [ + "text/x-pixi" + ] + }, + "osx": { + "command": ["open", "-a", "TextEdit"], + "CFBundleName": "Pixi Editor", + "CFBundleIdentifier": "dev.prefix.pixi-editor", + "CFBundleVersion": "0.1.0", + "CFBundleDocumentTypes": [ + { + "CFBundleTypeName": "text document", + "CFBundleTypeRole": "Editor", + "LSHandlerRank": "Default", + "CFBundleTypeIconFile": "pixi-icon.icns", + "LSItemContentTypes": [ + "public.pixi" + ] + } + ] + } + } + } + ] +} diff --git a/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.icns b/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.icns new file mode 100644 index 0000000000..68f610243a Binary files /dev/null and b/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.icns differ diff --git a/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.ico b/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.ico new file mode 100644 index 0000000000..6d58d94fd3 Binary files /dev/null and b/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.ico differ diff --git a/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.png b/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.png new file mode 100644 index 0000000000..ef095240da Binary files /dev/null and b/tests/data/channels/recipes/pixi-editor/Menu/pixi-icon.png differ diff --git a/tests/data/channels/recipes/pixi-editor/recipe.yaml b/tests/data/channels/recipes/pixi-editor/recipe.yaml new file mode 100644 index 0000000000..2a13dfe444 --- /dev/null +++ b/tests/data/channels/recipes/pixi-editor/recipe.yaml @@ -0,0 +1,31 @@ +package: + name: pixi-editor + version: "0.1.3" + +requirements: + build: + - python + +build: + noarch: generic + script: + interpreter: python + content: | + import os + import shutil + from pathlib import Path + + prefix = os.environ.get("PREFIX", "") + recipe_dir = os.environ.get("RECIPE_DIR", "") + + menu_dir = Path(prefix) / "Menu" + menu_dir.mkdir(parents=True, exist_ok=True) + + # Copy menu.json + shutil.copy(Path(recipe_dir) / "Menu" / "menu.json", menu_dir / "pixi-editor.json") + + # Copy icon files + for icon_name in ["pixi-icon.ico", "pixi-icon.png", "pixi-icon.icns"]: + src = Path(recipe_dir) / "Menu" / icon_name + if src.exists(): + shutil.copy(src, menu_dir) diff --git a/tests/integration_python/conftest.py b/tests/integration_python/conftest.py index fca7879cdc..800cce3776 100644 --- a/tests/integration_python/conftest.py +++ b/tests/integration_python/conftest.py @@ -84,6 +84,11 @@ def virtual_packages_channel(channels: Path) -> str: return channels.joinpath("virtual_packages").as_uri() +@pytest.fixture +def shortcuts_channel_1(channels: Path) -> str: + return channels.joinpath("shortcuts_channel_1").as_uri() + + @pytest.fixture def doc_pixi_projects() -> Path: return Path(__file__).parents[2].joinpath("docs", "source_files", "pixi_projects") diff --git a/tests/integration_python/pixi_global/__snapshots__/test_shortcuts.ambr b/tests/integration_python/pixi_global/__snapshots__/test_shortcuts.ambr new file mode 100644 index 0000000000..d85b41cd74 --- /dev/null +++ b/tests/integration_python/pixi_global/__snapshots__/test_shortcuts.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_sync_shortcuts[Linux-desktop-file] + ''' + [Desktop Entry] + Type=Application + Encoding=UTF-8 + Name=pixi-editor + Exec=bash -c 'gedit %F' + Terminal=false + Icon=/var/home/julian/Projekte/github.com/prefix-dev/pixi-1/pytest-temp/test_sync_shortcuts0/pixi_home/envs/test/Menu/pixi-icon.png + Comment=Scientific Python Development Environment + Categories=Development;Science; + MimeType=text/x-pixi; + + ''' +# --- diff --git a/tests/integration_python/pixi_global/test_shortcuts.py b/tests/integration_python/pixi_global/test_shortcuts.py new file mode 100644 index 0000000000..dd8a6a94ca --- /dev/null +++ b/tests/integration_python/pixi_global/test_shortcuts.py @@ -0,0 +1,306 @@ +from pathlib import Path +import tomllib +from typing import List + +import tomli_w +from ..common import verify_cli_command, ExitCode, CURRENT_PLATFORM +from abc import ABC, abstractmethod +import pytest +from dataclasses import dataclass + + +@dataclass +class SetupData: + pixi_home: Path + data_home: Path + env: dict[str, str] + + +@pytest.fixture +def setup_data(tmp_path: Path) -> SetupData: + pixi_home = tmp_path / "pixi_home" + data_home = tmp_path / "data_home" + env = { + "PIXI_HOME": str(pixi_home), + "HOME": str(data_home), # Used for macOS and Linux + "MENUINST_FAKE_DIRECTORIES": str(data_home), # Used for Windows + } + return SetupData(pixi_home=pixi_home, data_home=data_home, env=env) + + +class PlatformConfig(ABC): + @abstractmethod + def _shortcut_paths(self, data_home: Path, name: str) -> List[Path]: + pass + + @abstractmethod + def shortcut_exists(self, data_home: Path, name: str) -> bool: + """Given the name of a shortcut, return whether it exists or not.""" + pass + + +class LinuxConfig(PlatformConfig): + def _shortcut_paths(self, data_home: Path, name: str) -> List[Path]: + return [data_home / ".local" / "share" / "applications" / f"{name}_{name}.desktop"] + + def shortcut_exists(self, data_home: Path, name: str) -> bool: + return self._shortcut_paths(data_home, name).pop().is_file() + + +class MacOSConfig(PlatformConfig): + def _shortcut_paths(self, data_home: Path, name: str) -> List[Path]: + return [data_home / "Applications" / f"{name}.app"] + + def shortcut_exists(self, data_home: Path, name: str) -> bool: + return self._shortcut_paths(data_home, name).pop().is_dir() + + +class WindowsConfig(PlatformConfig): + def _shortcut_paths(self, data_home: Path, name: str) -> List[Path]: + return [data_home / "Desktop" / f"{name}.lnk", data_home / "Quick Launch" / f"{name}.lnk"] + + def shortcut_exists(self, data_home: Path, name: str) -> bool: + for path in self._shortcut_paths(data_home, name): + print(path) + if not path.is_file(): + return False + return True + + +def get_platform_config(platform: str) -> PlatformConfig: + if platform == "linux-64": + return LinuxConfig() + elif platform in {"osx-arm64", "osx64"}: + return MacOSConfig() + elif platform == "win-64": + return WindowsConfig() + else: + raise ValueError(f"Unsupported platform: {platform}") + + +def verify_shortcuts_exist( + data_home: Path, + shortcut_names: list[str], + expected_exists: bool, +) -> None: + """Verify if the specified shortcuts exist or not on the given system.""" + # Using the key to get the platform-specific configuration, to force a KeyError if the key is not found + system = CURRENT_PLATFORM + config = get_platform_config(system) + for name in shortcut_names: + assert config.shortcut_exists(data_home, name) == expected_exists, ( + f"Shortcut '{name}' {'should' if expected_exists else 'should not'} exist on {system}" + ) + + +def test_sync_creation_and_removal( + pixi: Path, + setup_data: SetupData, + shortcuts_channel_1: str, +) -> None: + """Test shortcut creation and removal with sync.""" + + # Setup manifest with given shortcuts + manifests = setup_data.pixi_home.joinpath("manifests") + manifests.mkdir(parents=True) + manifest = manifests.joinpath("pixi-global.toml") + toml = f""" + [envs.test] + channels = ["{shortcuts_channel_1}"] + dependencies = {{ pixi-editor = "*" }} + """ + manifest.write_text(toml) + + # Verify no shortcuts exist after sync + verify_cli_command([pixi, "global", "sync"], ExitCode.SUCCESS, env=setup_data.env) + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=False) + + parsed_toml = tomllib.loads(toml) + parsed_toml["envs"]["test"]["shortcuts"] = ["pixi-editor"] + manifest.write_text(tomli_w.dumps(parsed_toml)) + + # # Run sync and verify + verify_cli_command([pixi, "global", "sync"], ExitCode.SUCCESS, env=setup_data.env) + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=True) + + # test removal of shortcuts + del parsed_toml["envs"]["test"]["shortcuts"] + manifest.write_text(tomli_w.dumps(parsed_toml)) + verify_cli_command([pixi, "global", "sync"], ExitCode.SUCCESS, env=setup_data.env) + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=False) + + +def test_sync_empty_shortcut_list( + pixi: Path, + setup_data: SetupData, + shortcuts_channel_1: str, +) -> None: + # Setup manifest with given shortcuts + manifests = setup_data.pixi_home.joinpath("manifests") + manifests.mkdir(parents=True) + manifest = manifests.joinpath("pixi-global.toml") + toml = f""" + [envs.test] + channels = ["{shortcuts_channel_1}"] + dependencies = {{ pixi-editor = "*" }} + shortcuts = ["pixi-editor"] + """ + manifest.write_text(toml) + + # Run sync and verify + verify_cli_command([pixi, "global", "sync"], ExitCode.SUCCESS, env=setup_data.env) + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=True) + + # Set shortcuts to empty list + parsed_toml = tomllib.loads(toml) + parsed_toml["envs"]["test"]["shortcuts"] = [] + manifest.write_text(tomli_w.dumps(parsed_toml)) + verify_cli_command([pixi, "global", "sync"], ExitCode.SUCCESS, env=setup_data.env) + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=False) + + +def test_sync_removing_environment( + pixi: Path, + setup_data: SetupData, + shortcuts_channel_1: str, +) -> None: + # Setup manifest with given shortcuts + manifests = setup_data.pixi_home.joinpath("manifests") + manifests.mkdir(parents=True) + manifest = manifests.joinpath("pixi-global.toml") + toml = f""" + [envs.test] + channels = ["{shortcuts_channel_1}"] + dependencies = {{ pixi-editor = "*" }} + shortcuts = ["pixi-editor"] + """ + manifest.write_text(toml) + + # Run sync and verify + verify_cli_command([pixi, "global", "sync"], ExitCode.SUCCESS, env=setup_data.env) + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=True) + + # Remove environment + parsed_toml = tomllib.loads(toml) + del parsed_toml["envs"]["test"] + manifest.write_text(tomli_w.dumps(parsed_toml)) + verify_cli_command([pixi, "global", "sync"], ExitCode.SUCCESS, env=setup_data.env) + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=False) + + +def test_sync_duplicate_shortcuts( + pixi: Path, + setup_data: SetupData, + shortcuts_channel_1: str, +) -> None: + """Test shortcut creation and removal with sync.""" + + # Setup manifest with given shortcuts + manifests = setup_data.pixi_home.joinpath("manifests") + manifests.mkdir(parents=True) + manifest = manifests.joinpath("pixi-global.toml") + toml = f""" + [envs.test1] + channels = ["{shortcuts_channel_1}"] + dependencies = {{ pixi-editor = "*" }} + shortcuts = ["pixi-editor"] + + [envs.test2] + channels = ["{shortcuts_channel_1}"] + dependencies = {{ pixi-editor = "*" }} + shortcuts = ["pixi-editor"] + """ + manifest.write_text(toml) + + # Verify no shortcuts exist after sync + verify_cli_command( + [pixi, "global", "sync"], + ExitCode.FAILURE, + env=setup_data.env, + stderr_contains="Duplicated shortcut names found: pixi-editor", + ) + + +def test_sync_unavailable_shortcuts( + pixi: Path, + setup_data: SetupData, + shortcuts_channel_1: str, +) -> None: + """Test shortcut creation and removal with sync.""" + + # Setup manifest with given shortcuts + manifests = setup_data.pixi_home.joinpath("manifests") + manifests.mkdir(parents=True) + manifest = manifests.joinpath("pixi-global.toml") + toml = f""" + [envs.test1] + channels = ["{shortcuts_channel_1}"] + dependencies = {{ pixi-editor = "*" }} + shortcuts = ["unavailable-shortcut"] + """ + manifest.write_text(toml) + + # Verify no shortcuts exist after sync + verify_cli_command( + [pixi, "global", "sync"], + ExitCode.FAILURE, + env=setup_data.env, + stderr_contains="the following shortcuts are requested but not available: unavailable-shortcut", + ) + + +def test_install_simple( + pixi: Path, + setup_data: SetupData, + shortcuts_channel_1: str, +) -> None: + """Test shortcut creation with install.""" + + # Verify no shortcuts exist after sync + verify_cli_command( + [pixi, "global", "install", "--channel", shortcuts_channel_1, "pixi-editor"], + ExitCode.SUCCESS, + env=setup_data.env, + ) + + # Verify manifest + manifest = setup_data.pixi_home.joinpath("manifests", "pixi-global.toml") + parsed_toml = tomllib.loads(manifest.read_text()) + assert parsed_toml["envs"]["pixi-editor"]["shortcuts"] == ["pixi-editor"] + + # Verify shortcut exists + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=True) + + +def test_install_no_shortcut( + pixi: Path, + setup_data: SetupData, + shortcuts_channel_1: str, +) -> None: + """Test shortcut creation with install where `--no-shortcut` was passed.""" + + # Verify no shortcuts exist after sync + verify_cli_command( + [ + pixi, + "global", + "install", + "--channel", + shortcuts_channel_1, + "--no-shortcut", + "pixi-editor", + ], + ExitCode.SUCCESS, + env=setup_data.env, + ) + + # Verify manifest + manifest = setup_data.pixi_home.joinpath("manifests", "pixi-global.toml") + parsed_toml = tomllib.loads(manifest.read_text()) + assert "shortcuts" not in parsed_toml["envs"]["pixi-editor"] + + # Verify shortcut does not exist + verify_shortcuts_exist(setup_data.data_home, ["pixi-editor"], expected_exists=False) + + +# TODO: test more files on macOS and Windows