diff --git a/docs/dev-tools/install-layout.md b/docs/dev-tools/install-layout.md index 87ebddfe841..88d41844197 100644 --- a/docs/dev-tools/install-layout.md +++ b/docs/dev-tools/install-layout.md @@ -159,7 +159,10 @@ mise still writes the tool directory's `.mise.backend.toml` sidecar and [Finding an installation](#finding-an-installation-mise-where-and-mise-which) for when mise uses the installation directory instead. - **Tool options, backends, and the registry work as before.** Only where files - land and how installations are found changes. + land and how installations are found changes. `mise backends switch` installs + the new backend's installation of each switched version (it is a different + installation from the old backend's, which stays until it is pruned) and + points the version link at it. ## How identity is decided @@ -450,12 +453,16 @@ tools that have more than one variant of a version. package-manager installs record the real installation path, which now contains the hash. They stay valid for that installation but are not portable to another one. Installing the same identity again lands in the same directory. -- **A version named on the command line carries no options.** An installation - made with install options a configuration file sets (`symlink_bins`, a - `matching` pattern) is a different installation from the plain version, so - `mise where tool@1.0` finds it only because it is the lone variant, and other - commands treat the unadorned request as not installed. Run them where the - configuration applies, or let `mise install` make the plain variant. +- **A version named on the command line carries only the options a + configuration gives it.** In a project, `mise where tool@1.0`, `mise x +tool@1.0` and the other commands use the install options the configuration + sets for that tool (`symlink_bins`, a `matching` pattern). Outside one, a + version named without options uses the installation of that version when + there is only one, whatever options it was made with, as before this layout. + With several, `mise where` lists them and other commands treat the version as + not installed; name the options to pick one, as in + `mise where 'tool[matching=server]@1.0'`, or install the plain version with + `mise install --force tool@1.0`. - **A version link is not authority.** Hardcoded `installs//` paths see only the most recently installed variant. - **Directory names are less descriptive.** A label such as `berry` for yarn or diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants new file mode 100644 index 00000000000..1c52e16b412 --- /dev/null +++ b/e2e/cli/test_install_layout_cli_variants @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# A version named on the command line carries no install options, so under the +# identity install layout it can mean an installation a configuration made with +# options. `mise where` uses one such installation when it is the only one, and +# lists them instead of guessing when there are several. +export MISE_EXPERIMENTAL=1 +export MISE_INSTALL_LAYOUT=identity +work="$PWD" + +mkdir env opt +cat >env/mise.toml <<'EOF2' +[tools] +dummy = { version = "2.0.0", install_env = { DUMMY_VARIANT = "musl" } } +EOF2 +cat >opt/mise.toml <<'EOF2' +[tools] +dummy = { version = "2.0.0", flavor = "full" } +EOF2 + +echo "=== the only installation of the version is found ===" +assert "mise -C $work/env install" +env_dir="$(mise -C "$work/env" where dummy)" +assert "mise where dummy@2.0.0" "$env_dir" + +echo "=== several are listed, not guessed between ===" +assert "mise -C $work/opt install" +opt_dir="$(mise -C "$work/opt" where dummy)" +assert_fail "mise where dummy@2.0.0" "installed with several sets of options" +assert_fail "mise where dummy@2.0.0" "$(basename "$opt_dir")" +assert_fail "mise where dummy@2.0.0" "$(basename "$env_dir")" + +echo "=== naming the options finds the variant ===" +assert "mise where 'dummy[flavor=full]@2.0.0'" "$opt_dir" +assert "mise -C $work/opt where dummy@2.0.0" "$opt_dir" + +echo "=== a lone variant stands for the bare version in every command ===" +mkdir solo +cat >solo/mise.toml <<'EOF2' +[tools] +dummy = { version = "1.0.0", flavor = "full" } +EOF2 +assert "mise -C $work/solo install" +solo_dir="$(mise -C "$work/solo" where dummy)" +assert "mise where dummy@1.0.0" "$solo_dir" +assert "mise bin-paths dummy@1.0.0 | xargs realpath" "$(realpath "$solo_dir/bin")" +assert "mise which dummy --tool dummy@1.0.0 | xargs realpath" "$(realpath "$solo_dir/bin/dummy")" +assert_contains "mise x dummy@1.0.0 -- dummy" "This is Dummy 1.0.0!" +assert_contains "mise install dummy@1.0.0 2>&1" "already installed" +assert "test \"\$(mise installs ls dummy --json | jq '[.[] | select(.version == \"1.0.0\")] | length')\" -eq 1" +# a forced install of the bare version makes its own installation, which it then uses +assert "mise install --force dummy@1.0.0" +assert "test \"\$(mise installs ls dummy --json | jq '[.[] | select(.version == \"1.0.0\")] | length')\" -eq 2" +plain_dir="$(mise where dummy@1.0.0)" +[[ $plain_dir != "$solo_dir" ]] || fail "the bare version still resolves to the variant" +assert "mise -C $work/solo where dummy" "$solo_dir" + +echo "=== a lone variant that has to be installed again is not installed over for the bare version ===" +mkdir solo2 +cat >solo2/mise.toml <<'EOF2' +[tools] +tiny = { version = "3.1.0", flavor = "full" } +EOF2 +assert "mise -C $work/solo2 install" +solo2_dir="$(mise -C "$work/solo2" where tiny)" +assert "mise where tiny@3.1.0" "$solo2_dir" +# as if a refresh of the variant had been interrupted +mkdir -p "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")" +touch "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")/incomplete" +assert "mise install tiny@3.1.0" +bare_dir="$(mise where tiny@3.1.0)" +[[ $bare_dir != "$solo2_dir" ]] || fail "the bare version was installed into the variant" +# the variant is still the project's, once its own refresh is done +rm "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")/incomplete" +assert "mise -C $work/solo2 where tiny" "$solo2_dir" +assert "mise installs ls tiny --json | jq -r '.[] | select(.selected and .version == \"3.1.0\") | .dir' | sort | tr '\n' ' '" "$(printf '%s\n' "$bare_dir" "$solo2_dir" | sort | tr '\n' ' ')" + +echo "=== a postinstall hook that always runs never runs in a variant standing in ===" +mkdir variant hooked +cat >variant/mise.toml <<'EOF2' +[tools] +tiny = { version = "3.0.0", flavor = "full" } +EOF2 +assert "mise -C $work/variant install" +variant_dir="$(mise -C "$work/variant" where tiny)" +cat >hooked/mise.toml <<'EOF2' +[tools] +tiny = { version = "3.0.0", postinstall = { run = "touch \"$MISE_TOOL_INSTALL_PATH/hooked\"", when = "always" } } +EOF2 +assert "mise -C $work/hooked install tiny@3.0.0" +assert "test ! -e '$variant_dir/hooked'" +assert "test -e \"\$(mise -C '$work/hooked' where tiny@3.0.0)/hooked\"" + +echo "=== another tool aliasing the same backend never stands in ===" +mkdir aliased +cat >aliased/mise.toml <env2/mise.toml <<'EOF2' +[tools] +tiny = { version = "1.0.0", install_env = { TINY_BUILD = "1" } } +EOF2 +cat >more/mise.toml <<'EOF2' +[tools] +tiny = { version = "1.0.0", install_env = { TINY_BUILD = "1" }, flavor = "full" } +EOF2 +assert "mise -C $work/more install" +assert_fail "mise -C $work/env2 where tiny@1.0.0" +# while the version named alone does find it +assert "mise where tiny@1.0.0" "$(mise -C "$work/more" where tiny)" +# options written on the command line are the version's own too +assert_fail "mise where 'tiny[flavor=full]@1.0.0'" + +echo "=== a lone variant in a shared root whose catalog is gone still stands in ===" +mkdir sharedvar +cat >sharedvar/mise.toml <<'EOF2' +[tools] +tiny = { version = "1.0.1", flavor = "full" } +EOF2 +assert "MISE_INSTALLS_DIR='$HOME/shared-variants' mise -C $work/sharedvar install" +mv "$HOME/shared-variants/.mise" "$HOME/shared-variants/.mise.lost" +assert_matches "MISE_SHARED_INSTALL_DIRS='$HOME/shared-variants' mise where tiny@1.0.1" "^$HOME/shared-variants/tiny-" +# a second copy made with the same options is the same variant: its selection says which +export MISE_SHARED_INSTALL_DIRS="$HOME/shared-variants" +assert "mise -C $work/sharedvar install --force" +own_copy="$(mise -C "$work/sharedvar" where tiny)" +[[ $own_copy == "$MISE_DATA_DIR/installs/"tiny-* ]] || fail "$own_copy is not in the user's root" +assert "mise where tiny@1.0.1" "$own_copy" +# with the selected copy gone, the other copy is not taken in its place +rm -rf "$own_copy" +assert_fail "mise where tiny@1.0.1" +unset MISE_SHARED_INSTALL_DIRS + +echo "=== a version named with options a configuration sets never stands in for another variant ===" +mkdir minimal +cat >minimal/mise.toml <<'EOF2' +[tools] +dummy = { version = "1.1.0", flavor = "minimal" } +EOF2 +assert "mise -C $work/minimal install" +mkdir full +cat >full/mise.toml <<'EOF2' +[tools] +dummy = { version = "1.1.0", flavor = "full" } +EOF2 +assert_fail "mise -C $work/full where dummy@1.1.0" +assert_fail "mise -C $work/full where dummy 1.1.0" diff --git a/e2e/cli/test_install_layout_switch b/e2e/cli/test_install_layout_switch new file mode 100644 index 00000000000..f2a10bd3272 --- /dev/null +++ b/e2e/cli/test_install_layout_switch @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# `mise backends switch` under the identity install layout: the new backend's +# installation is a different one from the old backend's, so the switch installs +# it (instead of finding the version "already installed"), and the version link +# moves to it. hk moved from Aqua to Packslip at 1.58.1. +export MISE_EXPERIMENTAL=1 +export MISE_INSTALL_LAYOUT=identity +export MISE_LOCKFILE=1 +export MISE_MINIMUM_RELEASE_AGE=0 +detect_platform +installs="$MISE_DATA_DIR/installs" + +cat <<'EOF2' >mise.toml +[tools] +hk = "1" +EOF2 +cat <<'EOF2' >mise.lock +lockfile_version = 2 + +[[tools.hk]] +version = "1.58.1" +backend = "aqua:jdx/hk" +specifiers = ["1"] +EOF2 + +assert_contains "mise install 2>&1" "mise backends switch hk" +old_dir="$(mise where hk)" +assert_contains "cat '$old_dir/.mise-install.toml'" 'backend = "aqua:jdx/hk"' + +mise backends switch hk +assert_contains "cat mise.lock" 'backend = "packslip:github.com/jdx/hk"' +new_dir="$(mise where hk)" +[[ $new_dir != "$old_dir" ]] || fail "the switch kept the aqua installation $old_dir" +assert_contains "cat '$new_dir/.mise-install.toml'" 'backend = "packslip:github.com/jdx/hk"' +assert "readlink '$installs/hk/1.58.1'" "../$(basename "$new_dir")" +assert_contains "mise exec -- hk --version" "hk 1.58.1" +# the old backend's installation is left for prune, not deleted +assert "test -f '$old_dir/.mise-install.toml'" diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 87533448e4c..7d9f7a86779 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -3974,13 +3974,19 @@ pub trait Backend: Debug + Send + Sync { .await && !rolling_reinstall; - // An identity-layout installation found in a read-only shared root is used as - // it is; anything that would write (a forced or incompatible install) is - // allocated afresh in the primary root first, before it can touch the shared - // directory. + // An identity-layout installation found in a read-only shared root, or a + // variant standing in for a bare version, is used as it is; anything that + // would write (a forced or incompatible install, or a postinstall hook that + // runs even when nothing is installed) is allocated afresh in the primary + // root first, before it can touch that directory. let mut allocated = allocated; + let always_postinstall = tv + .request + .options() + .postinstall() + .is_some_and(|(_, always)| always); if allocated.as_ref().is_some_and(|a| a.read_only) - && (ctx.force || rolling_reinstall || !install_satisfied) + && (ctx.force || rolling_reinstall || !install_satisfied || always_postinstall) { let mut bare = tv.clone(); bare.install_path = None; diff --git a/src/cli/backends/switch.rs b/src/cli/backends/switch.rs index 31f667bc12c..db22fb2aa1c 100644 --- a/src/cli/backends/switch.rs +++ b/src/cli/backends/switch.rs @@ -455,9 +455,11 @@ impl BackendsSwitch { } /// Reinstall the switched versions that are installed, from the new - /// backend. Installs are keyed by tool and version, not backend, so an - /// install from the old backend would otherwise keep satisfying the new - /// lock entry. + /// backend. In the legacy layout installs are keyed by tool and version, not + /// backend, so an install from the old backend would otherwise keep + /// satisfying the new lock entry. In the identity layout the new backend's + /// installation is a different one, so a version is reinstalled when its + /// version path (the old backend's install, or its version link) is there. async fn reinstall( &self, switched: &BTreeSet<(String, String)>, @@ -465,11 +467,35 @@ impl BackendsSwitch { ) -> Result<()> { let mut config = Config::reset().await?; let mut requests = vec![]; + // Read once, and only if some switched version has no version path. + let mut installations = None; for (lockfile, tv) in self.scoped_versions(&config).await? { - if (switched.contains(&(tv.short().to_string(), tv.version.clone())) - || relocked_tools.contains(&(lockfile, tv.short().to_string()))) - && tv.backend()?.is_version_installed(&config, &tv, false) + if !switched.contains(&(tv.short().to_string(), tv.version.clone())) + && !relocked_tools.contains(&(lockfile, tv.short().to_string())) { + continue; + } + // The old backend's install may be in the user's root or in a shared + // or system one, and may have no version link (Windows without + // junctions), so its receipt is looked for too. + let installed = tv.backend()?.is_version_installed(&config, &tv, false) + || (crate::install_layout::resolver::enabled() + && (std::iter::once(crate::dirs::INSTALLS.to_path_buf()) + .chain(crate::env::shared_install_dirs()) + .any(|root| { + tv.ba() + .installs_path() + .file_name() + .is_some_and(|tool_dir| has_version_path(&root.join(tool_dir), &tv)) + }) + || installations + .get_or_insert_with(crate::install_layout::resolver::installations) + .iter() + .any(|i| { + i.version == layout_version(&tv) + && i.requested_as.as_deref() == Some(tv.short()) + }))); + if installed { requests.push(tv.request); } } @@ -556,3 +582,32 @@ impl Snapshot { } } } + +/// `tv`'s version as installations and version paths name it (`ref-main` for +/// `ref:main`). +fn layout_version(tv: &ToolVersion) -> String { + crate::install_layout::resolver::identity_scope(tv) + .map(|(_, version)| version) + .unwrap_or_else(|| tv.version.replace([':', '/'], "-")) +} + +/// Whether the tool directory `tool_dir` has a version path for `tv`'s version, +/// as the backend before the switch named it. That is `tv`'s own path name, or +/// the version with the private dependency-graph suffix an aube or uv install +/// adds (`1.0.0~aube~…`), which only one of the two backends may carry. +fn has_version_path(tool_dir: &Path, tv: &ToolVersion) -> bool { + let version = layout_version(tv); + if tool_dir.join(tv.tv_pathname()).exists() || tool_dir.join(&version).exists() { + return true; + } + let suffixed = format!("{version}~"); + std::fs::read_dir(tool_dir).is_ok_and(|entries| { + entries.flatten().any(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(&suffixed)) + && entry.path().exists() + }) + }) +} diff --git a/src/cli/where.rs b/src/cli/where.rs index f9d4be2fc89..dfb930f4ba1 100644 --- a/src/cli/where.rs +++ b/src/cli/where.rs @@ -42,22 +42,28 @@ impl Where { let config = Config::get().await?; // A version named on the command line carries no install options. let named_here = self.tool.tvr.is_some() || self.asdf_version.is_some(); - let tvr = match self.tool.tvr { - Some(tvr) => tvr, - None => match self.asdf_version { - Some(version) => self.tool.with_version(&version).tvr.unwrap(), - None => { - let ts = ToolsetBuilder::new().build(&config).await?; - match ts.versions.get(self.tool.ba.as_ref()) { - Some(tvl) => { - tvl.os_supported_requests().next().cloned().ok_or_else(|| { - eyre::eyre!("{} does not have an active version", self.tool.ba) - })? - } - None => self.tool.with_version("latest").tvr.unwrap(), - } + // The options a configuration sets for the tool apply to a version named + // here too (as `tool@version` or `tool version`), as they do for + // `mise x tool@version`. + let named = match (self.tool.tvr.clone(), &self.asdf_version) { + (Some(tvr), _) => Some(tvr), + (None, Some(version)) => self.tool.clone().with_version(version).tvr, + (None, None) => None, + }; + let tvr = match named { + Some(tvr) => crate::toolset::apply_config_options_to_runtime_arg( + config.get_tool_request_set().await?, + tvr, + ), + None => { + let ts = ToolsetBuilder::new().build(&config).await?; + match ts.versions.get(self.tool.ba.as_ref()) { + Some(tvl) => tvl.os_supported_requests().next().cloned().ok_or_else(|| { + eyre::eyre!("{} does not have an active version", self.tool.ba) + })?, + None => self.tool.with_version("latest").tvr.unwrap(), } - }, + } }; let tv = tvr.resolve(&config, &Default::default()).await?; @@ -65,10 +71,10 @@ impl Where { if tv.backend()?.is_version_installed(&config, &tv, true) { miseprintln!("{}", tv.install_path().to_string_lossy()); Ok(()) - } else if let Some(dir) = named_here + } else if let Some(dir) = (named_here && !tv.resolved_from_lockfile()) .then(|| installed_variant(&tv)) + .transpose()? .flatten() - .filter(|_| !tv.resolved_from_lockfile()) { // The version was installed with options a configuration sets; the one // named here carries none. @@ -83,13 +89,27 @@ impl Where { } } -/// An installation of `tv`'s tool and version made with install options, when the -/// version as named installs nothing of its own. With several, the one the version -/// link names, else the first. -fn installed_variant(tv: &crate::toolset::ToolVersion) -> Option { - let variants = crate::install_layout::resolver::variants_of(tv); - let linked = crate::install_layout::resolver::physical_dir(tv.ba(), &tv.tv_pathname()); - linked - .filter(|dir| variants.contains(dir)) - .or_else(|| variants.into_iter().next()) +/// The installation of `tv`'s tool and version made with install options, when the +/// version as named installs nothing of its own and there is exactly one. With +/// several, which one is meant depends on options the command line did not give, +/// so it is an error that lists them rather than a guess (the version link names +/// only the most recent one). +fn installed_variant(tv: &crate::toolset::ToolVersion) -> Result> { + let mut variants = crate::install_layout::resolver::variants_of(tv); + if variants.len() > 1 { + let list = variants + .iter() + .map(|dir| format!(" {}", crate::file::display_path(dir))) + .collect::>() + .join("\n"); + eyre::bail!( + "{} is installed with several sets of options:\n{list}\n\ + Run this where the configuration that sets them applies, or name them, \ + for example `{}[option=value]@{}`", + tv.style(), + tv.ba().short, + tv.version + ); + } + Ok(variants.pop()) } diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index b842b107000..a46efc50ab9 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -353,6 +353,7 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { let key = identity.request_key(); let unlocked = pin_of(&identity).is_none(); let mut ambiguous = false; + let mut try_variant = false; if unlocked { let cache_key = (dirs::INSTALLS.to_path_buf(), digest.clone()); if let Some(hit) = locate_cache().get(&cache_key) { @@ -386,7 +387,12 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { return Some(located); } Unlocked::Ambiguous(_) => ambiguous = true, - Unlocked::Nothing => {} + // Nothing of the request's own: a bare version named on the command + // line may still stand for a variant, after a legacy install (below). + Unlocked::Nothing => { + try_variant = matches!(tv.request.source(), crate::toolset::ToolSource::Argument) + && is_bare(tv, &identity); + } } } else { for root in roots() { @@ -414,6 +420,15 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { if !ambiguous && let Some(legacy) = legacy_dir(tv, &identity) { return Some(legacy_located(legacy)); } + // A version named on the command line means whatever is installed of it, as + // it did before this layout: a lone installation made with the request's + // options and more that a configuration set (`filter_bins`, `matching`) stands + // for it (see `extends`). With several, which is meant is not known. It is + // not cached: the cache is keyed by the request, and the same request from a + // configuration file does not stand for a variant. + if try_variant && let [only] = variant_choices(tv.ba(), &identity).as_slice() { + return Some(only.clone()); + } let primary = Catalog::new(dirs::INSTALLS.to_path_buf()); // Several installations answer and none is chosen: report a path that is // never an installation, so nothing (`where`, `exec`, PATH) quietly runs one @@ -499,12 +514,7 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { let catalog = Catalog::new(&root); // A shared root's catalog cannot be rebuilt here; if it is gone, the // installations it listed still count, by their receipts. - let records = if is_primary_root(&root) { - catalog.records_for_backend(&key.backend) - } else { - catalog.records_or_receipts_for_backend(&key.backend) - }; - for record in records { + for record in records_of(&catalog, &root, &key.backend) { let dir = catalog.install_dir(&record); if same_request(&record.identity, key) && is_complete(&dir) { complete.push(Located { @@ -523,6 +533,115 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { } } +/// Whether the request `identity` came from sets no install options of its own: +/// its options are those the same version named with nothing else gets (from the +/// tool's registry entry or backend alias, and from settings). +fn is_bare(tv: &ToolVersion, identity: &InstallIdentity) -> bool { + // From the tool's name alone: options written inline (`tool[opt=value]`) are + // the request's own, and the plain request does not have them. + let Ok(plain) = ToolRequest::new( + std::sync::Arc::new(crate::args::BackendArg::from(tv.ba().short.as_str())), + &tv.version, + crate::toolset::ToolSource::Argument, + ) else { + return false; + }; + identity_of(&ToolVersion::new(plain, tv.version.clone())) + .is_some_and(|plain| plain.options == identity.options) +} + +/// Whether an installation made for `a` can stand for a request for `b`: it was +/// made with every install option `b` has (the request's own, its registry's +/// defaults, the settings that take part), and more besides. A version named +/// without options thus finds an installation a configuration made with some, +/// and never one made with different values for the options it has. +fn extends(a: &InstallIdentity, b: &InstallIdentity) -> bool { + b.options + .iter() + .all(|(key, value)| a.options.get(key) == Some(value)) +} + +/// Whether `a` was built from the dependency graph (aube, uv) `b` names, or +/// neither has one. A graph is not an option a configuration sets: an +/// installation built from another one is not a variant of the request. +fn same_graph(a: &InstallIdentity, b: Option<&InstallIdentity>) -> bool { + ["aube", "uv"] + .iter() + .all(|k| a.inputs.get(*k) == b.and_then(|b| b.inputs.get(*k))) +} + +/// The installations a bare version could stand for, one for each set of options +/// they were made with: copies of one option set (another root, an older refresh +/// generation) are that set's own choice, its selection or its only copy. A set +/// whose selected copy is gone stands for nothing; one whose copies are not +/// settled lists them all, for the user to select. +fn variant_choices(ba: &crate::args::BackendArg, identity: &InstallIdentity) -> Vec { + let mut sets: Vec<(InstallIdentity, Vec)> = vec![]; + for located in variant_installations(ba, identity) { + let Some(record) = &located.record else { + continue; + }; + let key = request_of(&record.identity); + match sets.iter_mut().find(|(k, _)| *k == key) { + Some((_, copies)) => copies.push(located), + None => sets.push((key, vec![located])), + } + } + sets.into_iter() + .flat_map(|(key, copies)| match unlocked_choice(&key) { + // The set's own choice, if it is one of this tool's copies. + Unlocked::Selected(located) | Unlocked::Found(located) + if located.installed && copies.iter().any(|c| c.dir == located.dir) => + { + vec![located] + } + // Its selected copy is gone (pruned): no other copy is taken for it. + Unlocked::Selected(located) if !located.installed => vec![], + _ => copies, + }) + .collect() +} + +/// The records of `backend` in the catalog of `root`; for a shared root whose +/// catalog is gone, its installations' receipts. +fn records_of(catalog: &Catalog, root: &Path, backend: &str) -> Vec { + if is_primary_root(root) { + catalog.records_for_backend(backend) + } else { + catalog.records_or_receipts_for_backend(backend) + } +} + +/// The complete installations, in every root, of `identity`'s backend, version and +/// platform made for another request (other install options) of the tool `ba`; +/// another tool aliasing the same backend (`oxfmt` and `oxlint`) has its own. +fn variant_installations(ba: &crate::args::BackendArg, identity: &InstallIdentity) -> Vec { + let own = request_of(identity); + let mut out = vec![]; + for root in roots() { + let catalog = Catalog::new(&root); + for record in records_of(&catalog, &root, &identity.backend) { + let dir = catalog.install_dir(&record); + if record.identity.version == identity.version + && record.identity.platform == identity.platform + && same_graph(&record.identity, Some(identity)) + && extends(&record.identity, identity) + && request_of(&record.identity) != own + && is_complete(&dir) + && belongs_to(ba, &record, &dir) + { + out.push(Located { + dir, + root: root.clone(), + record: Some(record), + installed: true, + }); + } + } + } + out +} + /// The catalog records that could satisfy a request pinning an artifact, best /// first: the installation allocated for exactly that pin, one the pin already /// adopted, or an unlocked installation whose acquired artifact is the pinned @@ -896,21 +1015,28 @@ pub(crate) fn installs_matching(backend: &str, version: &str) -> Vec<(String, Op out } -/// The installations of `tv`'s tool and version on this platform made with other -/// install options than `tv` carries. A version named on the command line carries -/// no options, so `mise where tool@1.0` has to find a copy made with the options a -/// configuration file sets. Installations of `tv`'s own request are not variants: -/// which of those it uses is its selection's business, never a guess here. +/// The installations of `tv`'s tool and version on this platform made with the +/// install options `tv` carries and more (see `extends`). A version named on the +/// command line carries none of its own, so `mise where tool@1.0` has to find a +/// copy made with the options a configuration file sets. Installations of `tv`'s +/// own request are not variants: which of those it uses is its selection's +/// business, never a guess here. pub fn variants_of(tv: &ToolVersion) -> Vec { if !enabled() { return vec![]; } - let Ok(b) = tv.backend() else { - return vec![]; - }; let mut bare = tv.clone(); bare.install_path = None; - let own_request = identity_of(&bare).map(|identity| request_of(&identity)); + let own_identity = identity_of(&bare); + // A request with options of its own (named, or set by a configuration) means + // exactly those; only a bare version can stand for a variant. + if own_identity + .as_ref() + .is_some_and(|identity| !is_bare(tv, identity)) + { + return vec![]; + } + let own_request = own_identity.as_ref().map(request_of); // The request has installations of its own, or a selection: which one it uses // is for the selection to say (or the user, when that is ambiguous), so a // variant made with other options is no stand-in for it. @@ -919,25 +1045,13 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { { return vec![]; } - let backend = canonical_backend(&tv.ba().full()); - let (version, platform) = (tv.logical_pathname(), b.get_platform_key()); - let mut out = vec![]; - for root in roots() { - let catalog = Catalog::new(&root); - for record in catalog.records_for_backend(&backend) { - let dir = catalog.install_dir(&record); - if record.identity.version == version - && record.identity.platform == platform - && own_request - .as_ref() - .is_none_or(|own| request_of(&record.identity) != *own) - && is_complete(&dir) - { - out.push(dir); - } - } - } - out + let Some(own_identity) = own_identity else { + return vec![]; + }; + variant_choices(tv.ba(), &own_identity) + .into_iter() + .map(|located| located.dir) + .collect() } /// The path to put on PATH for an unlocked `tv`: the link users know @@ -1022,8 +1136,9 @@ pub(crate) struct Allocated { /// An existing installation that already satisfies the request: nothing /// needs to be installed, only recorded. pub(crate) reused: bool, - /// The installation is in a read-only shared root: it may be used, never - /// written to. Installing means allocating again, in the primary root. + /// The installation is in a read-only shared root, or is a variant standing + /// in for a bare version: it may be used, never written to. Installing means + /// allocating again, for the request itself in the primary root. pub(crate) read_only: bool, /// The unlocked request this allocation answers; set when its selection /// should be (re)recorded on success. `None` for locked requests, which @@ -1108,6 +1223,20 @@ pub(crate) fn allocate(tv: &ToolVersion, refresh: bool) -> Result return Ok(None), + // A variant standing in for a bare version (see `locate`) is used where + // it is, never installed into or selected for that version: installing + // makes the bare request an installation of its own. + Some(record) if request_of(&record.identity) != request_of(&identity) => { + if !refresh { + return Ok(Some(Allocated { + read_only: true, + dir: located.dir, + reused: true, + selects: None, + record, + })); + } + } // An installation that already satisfies the request, possibly in a // read-only shared root, is reused as it is. Some(record) if !refresh => { @@ -1324,7 +1453,9 @@ pub(crate) fn note_reuse(tv: &ToolVersion) -> Result<()> { // a lockfile, another spelling or a shared root made) remembers it, so the // choice stays put when more installations of the request appear. let key = identity.request_key(); - if pin_of(&identity).is_none() { + // A variant standing in for a bare request named on the command line is not + // its selection. + if pin_of(&identity).is_none() && same_request(&record.identity, &key) { let shared = (!is_primary_root(&located.root)).then_some(located.root.as_path()); if catalog.select_if_unset(&key, &record, shared)? { reset_cache(); diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index 08115852fc8..d3abdfa9027 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -36,7 +36,8 @@ pub use install_options::InstallOptions; pub use tool_deps::ensure_compatible_install_requests; pub use tool_request::ToolRequest; pub use tool_request_set::{ - ToolRequestSet, ToolRequestSetBuilder, tool_env_var_name, tool_env_vars, tool_from_env_var_name, + ToolRequestSet, ToolRequestSetBuilder, apply_config_options_to_runtime_arg, tool_env_var_name, + tool_env_vars, tool_from_env_var_name, }; pub use tool_source::ToolSource; pub use tool_version::resolve_sub_base; diff --git a/src/toolset/tool_request_set.rs b/src/toolset/tool_request_set.rs index 4815791503c..19e129b735d 100644 --- a/src/toolset/tool_request_set.rs +++ b/src/toolset/tool_request_set.rs @@ -304,7 +304,10 @@ impl ToolRequestSetBuilder { /// defaults, so option emptiness cannot indicate whether config should apply. /// The canonical backend resolver preserves the normal precedence and reapplies /// explicit inline backend options last. -fn apply_config_options_to_runtime_arg(trs: &ToolRequestSet, mut tvr: ToolRequest) -> ToolRequest { +pub fn apply_config_options_to_runtime_arg( + trs: &ToolRequestSet, + mut tvr: ToolRequest, +) -> ToolRequest { if let Some(config_options) = trs .tools .get(tvr.ba())