From d4d4681621933697ef80cc2b0d393352d2629d31 Mon Sep 17 00:00:00 2001 From: mise-en-dev Date: Sun, 12 Apr 2026 05:20:37 -0500 Subject: [PATCH 1/4] chore: release 2026.4.10 (#9059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🐛 Bug Fixes - ignore MISE_TOOL_VERSION in nested postinstall runs by @risu729 in [#9050](https://github.com/jdx/mise/pull/9050) --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- completions/_mise | 2 +- completions/mise.bash | 2 +- completions/mise.fish | 2 +- completions/mise.ps1 | 2 +- default.nix | 2 +- packaging/rpm/mise.spec | 2 +- snapcraft.yaml | 2 +- 11 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2624027ca..3546e315ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [2026.4.10](https://github.com/jdx/mise/compare/v2026.4.9..v2026.4.10) - 2026-04-12 + +### 🐛 Bug Fixes + +- ignore MISE_TOOL_VERSION in nested postinstall runs by @risu729 in [#9050](https://github.com/jdx/mise/pull/9050) + ## [2026.4.9](https://github.com/jdx/mise/compare/v2026.4.8..v2026.4.9) - 2026-04-11 ### 🐛 Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index 8d1a99caf7..c790c6af79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5732,7 +5732,7 @@ dependencies = [ [[package]] name = "mise" -version = "2026.4.9" +version = "2026.4.10" dependencies = [ "age", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index a172c4708d..0c57bcadb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ [package] name = "mise" -version = "2026.4.9" +version = "2026.4.10" edition = "2024" description = "The front-end to your dev env" authors = ["Jeff Dickey (@jdx)"] diff --git a/README.md b/README.md index 43172bb36e..d0ca3d30d2 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ $ ~/.local/bin/mise --version / / / / / / (__ ) __/_____/ __/ / / /_____/ /_/ / / /_/ / /__/ __/ /_/ /_/ /_/_/____/\___/ \___/_/ /_/ / .___/_/\__,_/\___/\___/ /_/ by @jdx -2026.4.9 macos-arm64 (2026-04-11) +2026.4.10 macos-arm64 (2026-04-12) ``` Hook mise into your shell (pick the right one for your shell): diff --git a/completions/_mise b/completions/_mise index 043e672eab..cfd8c22973 100644 --- a/completions/_mise +++ b/completions/_mise @@ -23,7 +23,7 @@ _mise() { return 1 fi - local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mise_2026_4_9.spec" + local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mise_2026_4_10.spec" if [[ ! -f "$spec_file" ]]; then mise usage >| "$spec_file" fi diff --git a/completions/mise.bash b/completions/mise.bash index 9cfa476e36..d1ccb46fe6 100644 --- a/completions/mise.bash +++ b/completions/mise.bash @@ -9,7 +9,7 @@ _mise() { local cur prev words cword was_split comp_args _comp_initialize -n : -- "$@" || return - local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mise_2026_4_9.spec" + local spec_file="${TMPDIR:-/tmp}/usage__usage_spec_mise_2026_4_10.spec" if [[ ! -f "$spec_file" ]]; then mise usage >| "$spec_file" fi diff --git a/completions/mise.fish b/completions/mise.fish index 9939679d50..a4b51a6896 100644 --- a/completions/mise.fish +++ b/completions/mise.fish @@ -8,7 +8,7 @@ if ! type -p usage &> /dev/null return 1 end set -l tmpdir (if set -q TMPDIR; echo $TMPDIR; else; echo /tmp; end) -set -l spec_file "$tmpdir/usage__usage_spec_mise_2026_4_9.spec" +set -l spec_file "$tmpdir/usage__usage_spec_mise_2026_4_10.spec" if not test -f "$spec_file" mise usage | string collect > "$spec_file" end diff --git a/completions/mise.ps1 b/completions/mise.ps1 index 9f1edd07bb..7dcb37f1cb 100644 --- a/completions/mise.ps1 +++ b/completions/mise.ps1 @@ -10,7 +10,7 @@ Register-ArgumentCompleter -Native -CommandName 'mise' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) $tmpDir = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() } - $specFile = Join-Path $tmpDir "usage__usage_spec_mise_2026_4_9.kdl" + $specFile = Join-Path $tmpDir "usage__usage_spec_mise_2026_4_10.kdl" if (-not (Test-Path $specFile)) { mise usage | Out-File -FilePath $specFile -Encoding utf8 diff --git a/default.nix b/default.nix index baa1049a53..156040c5e7 100644 --- a/default.nix +++ b/default.nix @@ -2,7 +2,7 @@ rustPlatform.buildRustPackage { pname = "mise"; - version = "2026.4.9"; + version = "2026.4.10"; src = lib.cleanSource ./.; diff --git a/packaging/rpm/mise.spec b/packaging/rpm/mise.spec index 1c83097935..446be9376a 100644 --- a/packaging/rpm/mise.spec +++ b/packaging/rpm/mise.spec @@ -1,6 +1,6 @@ Summary: The front-end to your dev env Name: mise -Version: 2026.4.9 +Version: 2026.4.10 Release: 1 URL: https://github.com/jdx/mise/ Group: System diff --git a/snapcraft.yaml b/snapcraft.yaml index ffbb36a342..dc8dd72692 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -9,7 +9,7 @@ name: mise title: mise-en-place -version: "2026.4.9" +version: "2026.4.10" summary: The front-end to your dev env description: | mise-en-place is a command line tool to manage your development environment. From d1d1e521c3735e786b806d74c86c9ed17b15d51e Mon Sep 17 00:00:00 2001 From: Matthias Grandl <50196894+MatthiasGrandl@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:23:35 +0200 Subject: [PATCH 2/4] fix(task): render dependency templates even when no args are passed (#9062) ## Summary - Fix `{{usage.*}}` templates in `depends` not being rendered when the task is invoked without arguments - The `!task.args.is_empty()` guard in both `run.rs` and `deps.rs` prevented template rendering when no CLI args were passed, even though the usage spec may define defaults - Dependencies with unrendered `{{usage.*}}` templates were silently dropped, causing the task to run with no dependencies at all --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- src/cli/run.rs | 9 ++++++++- src/task/deps.rs | 11 +++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/cli/run.rs b/src/cli/run.rs index 96847eb380..e5a78c1ab2 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -334,7 +334,14 @@ impl Run { // Re-render dependency templates with parent task's usage arg/flag values. // This enables patterns like: depends = ["child {{usage.app}}"] for task in &mut task_list { - if !task.args.is_empty() { + let has_usage_deps = |raw: &Option>| { + raw.as_ref() + .is_some_and(|r| r.iter().any(crate::task::dep_has_usage_ref)) + }; + if has_usage_deps(&task.depends_raw) + || has_usage_deps(&task.depends_post_raw) + || has_usage_deps(&task.wait_for_raw) + { let usage_values = crate::task::parse_usage_values_from_task(&config, task).await?; if !usage_values.is_empty() { task.render_depends_with_usage(&config, &usage_values) diff --git a/src/task/deps.rs b/src/task/deps.rs index 83c849568c..c96a0b11f7 100644 --- a/src/task/deps.rs +++ b/src/task/deps.rs @@ -85,16 +85,15 @@ impl Deps { fetcher.fetch_tasks(&mut tasks_to_fetch).await?; a = tasks_to_fetch.into_iter().next().unwrap(); } - // If this task received args (from a parent dependency), re-render - // its dependency templates with usage values so {{usage.*}} resolves. + // Re-render dependency templates with usage values (including defaults) + // so {{usage.*}} resolves. let has_usage_deps = |raw: &Option>| { raw.as_ref() .is_some_and(|r| r.iter().any(dep_has_usage_ref)) }; - if !a.args.is_empty() - && (has_usage_deps(&a.depends_raw) - || has_usage_deps(&a.depends_post_raw) - || has_usage_deps(&a.wait_for_raw)) + if has_usage_deps(&a.depends_raw) + || has_usage_deps(&a.depends_post_raw) + || has_usage_deps(&a.wait_for_raw) { let usage_values = parse_usage_values_from_task(config, &a).await?; if !usage_values.is_empty() { From 261f40635b976c76044be15e3c123a54e844fcea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20Mengu=C3=A9?= Date: Sun, 12 Apr 2026 23:57:42 +0200 Subject: [PATCH 3/4] fix(docs): typo in Go Backend (#9065) Fix "go install" flag for building a Go program with given build tags: --tags => -tags --- docs/dev-tools/backends/go.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev-tools/backends/go.md b/docs/dev-tools/backends/go.md index 213b60cdc8..3420577ee5 100644 --- a/docs/dev-tools/backends/go.md +++ b/docs/dev-tools/backends/go.md @@ -36,7 +36,7 @@ go in `[tools]` in `mise.toml`. ### `tags` -Specify go build tags (passed as `go install --tags`): +Specify go build tags (passed as `go install -tags`): ```toml [tools] From 885752fe4911dac6a60d6d614eb535786f540f18 Mon Sep 17 00:00:00 2001 From: Risu <79110363+risu729@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:58:29 +1000 Subject: [PATCH 4/4] fix: support npm semver ranges in devEngines (#9061) Refs #8935 ## Summary - preserve package.json devEngines semver ranges instead of simplifying them to exact-looking versions - resolve npm-compatible ranges with nodejs-semver when filtering backend versions - cover lower-bound, compound, caret, wildcard, and package manager range behavior ## Tests - cargo test config::config_file::idiomatic_version::package_json - cargo test semver - ./e2e/run_test e2e/core/test_package_json --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- Cargo.lock | 13 ++ Cargo.toml | 1 + e2e/core/test_package_json | 2 +- src/backend/mod.rs | 2 +- .../idiomatic_version/package_json.rs | 168 +++++++----------- src/semver.rs | 128 ++++++++++++- src/toolset/tool_version.rs | 38 +++- 7 files changed, 248 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c790c6af79..ee08c19ad3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5798,6 +5798,7 @@ dependencies = [ "mockito", "netrc-rs", "nix 0.31.2", + "nodejs-semver", "num_cpus", "number_prefix", "once_cell", @@ -6017,6 +6018,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nodejs-semver" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a29bc3430baa1e00e10d9aa5f4ed19ce4433eecb40e3926ade5ff2954cc2f3a" +dependencies = [ + "bytecount", + "miette", + "thiserror 2.0.18", + "winnow 0.7.15", +] + [[package]] name = "nom" version = "7.1.3" diff --git a/Cargo.toml b/Cargo.toml index 0c57bcadb5..74efa11346 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,6 +121,7 @@ minisign-verify = "0.2" md-5 = "0.11" miette = { version = "7", features = ["fancy"] } netrc-rs = "0.1" +nodejs-semver = "4.2" num_cpus = "1" number_prefix = "0.4" once_cell = "1" diff --git a/e2e/core/test_package_json b/e2e/core/test_package_json index 9ca90cd64c..305273b7f7 100644 --- a/e2e/core/test_package_json +++ b/e2e/core/test_package_json @@ -11,7 +11,7 @@ cat >package.json <<'EOF' "devEngines": { "runtime": { "name": "node", - "version": ">=22.0.0" + "version": ">=22.0.0 <23.0.0" } } } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 2e18be573d..ce323f9272 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -951,7 +951,7 @@ pub trait Backend: Debug + Send + Sync { /// every backend needing to implement `package.json` support. For other files, it /// delegates to `_parse_idiomatic_file`. async fn parse_idiomatic_file(&self, path: &Path) -> eyre::Result> { - if path.file_name().is_some_and(|f| f == "package.json") { + if crate::config::config_file::idiomatic_version::package_json::is_package_json(path) { return crate::config::config_file::idiomatic_version::package_json::parse( path, self.id(), diff --git a/src/config/config_file/idiomatic_version/package_json.rs b/src/config/config_file/idiomatic_version/package_json.rs index a5bd7ef310..853592353a 100644 --- a/src/config/config_file/idiomatic_version/package_json.rs +++ b/src/config/config_file/idiomatic_version/package_json.rs @@ -26,6 +26,11 @@ struct DevEngine { version: Option, } +pub fn is_package_json(path: &Path) -> bool { + path.file_name() + .is_some_and(|file_name| file_name == "package.json") +} + /// Deserialize a field that may be a single object or an array (take the first element). /// The npm devEngines spec allows both forms. fn deserialize_one_or_first<'de, D>( @@ -62,8 +67,8 @@ impl PackageJsonData { .and_then(|de| de.runtime.as_ref()) .filter(|r| r.name.as_deref() == Some(tool_name)) .and_then(|r| r.version.as_deref()) - .map(simplify_semver) .filter(|v| !v.is_empty()) + .map(str::to_string) } /// Extract a package manager version for the given tool name. @@ -75,8 +80,8 @@ impl PackageJsonData { .and_then(|de| de.package_manager.as_ref()) .filter(|pm| pm.name.as_deref() == Some(tool_name)) .and_then(|pm| pm.version.as_deref()) - .map(simplify_semver) .filter(|v| !v.is_empty()) + .map(str::to_string) .or_else(|| { // Fall back to packageManager field (e.g. "pnpm@9.1.0+sha256.abc") let pm_field = self.package_manager.as_deref()?; @@ -94,71 +99,6 @@ impl PackageJsonData { } } -/// Simplify a semver range to a mise-compatible version prefix. -/// -/// Strips range operators (>=, ^, ~) and trailing `.0` components to produce -/// a prefix that mise can match against. For exact versions, returns as-is. -/// Upper-bound operators (`<`, `<=`) are ignored since they don't indicate -/// a version to install. -/// -/// # TODO -/// This doesn't handle all edge cases correctly. For example, `^20.0.1` should not -/// match `20.0.0`, but our simplified approach strips it to `20` which would match. -/// Full semver range support may be added in the future. -fn simplify_semver(input: &str) -> String { - let input = input.trim(); - if input == "*" || input == "x" { - return "latest".to_string(); - } - - // Upper-bound operators don't indicate a version to install - if input.starts_with('<') || input.starts_with("<=") { - return String::new(); - } - - // Strip leading range operators - let version = input - .trim_start_matches(">=") - .trim_start_matches('>') - .trim_start_matches('^') - .trim_start_matches('~') - .trim_start_matches('=') - .trim(); - - if version.is_empty() { - return "latest".to_string(); - } - - // Replace wildcard segments (x, *) with truncation - // e.g. "18.x" -> "18", "18.2.*" -> "18.2" - let parts: Vec<&str> = version - .split('.') - .take_while(|p| *p != "x" && *p != "*") - .collect(); - if parts.is_empty() { - return "latest".to_string(); - } - if parts.len() < version.split('.').count() { - // Had wildcard segments, return truncated prefix - return parts.join("."); - } - - let had_operator = version != input; - - // Only strip trailing .0 components when a range operator was present, - // since ranges imply prefix matching. Exact versions are kept as-is. - if had_operator { - let trimmed: Vec<&str> = match parts.as_slice() { - [major, "0", "0"] => vec![major], - [major, minor, "0"] => vec![major, minor], - _ => parts, - }; - trimmed.join(".") - } else { - version.to_string() - } -} - pub fn parse(path: &Path, tool_name: &str) -> Result> { let pkg = PackageJsonData::parse(path)?; // We ignore unknown tools in package.json @@ -183,22 +123,6 @@ mod tests { use std::fs; use tempfile::tempdir; - #[test] - fn test_simplify_semver() { - assert_eq!(simplify_semver(">=18.0.0"), "18"); - assert_eq!(simplify_semver("^20.0.0"), "20"); - assert_eq!(simplify_semver("~18.2.0"), "18.2"); - assert_eq!(simplify_semver("9.1.0"), "9.1.0"); - assert_eq!(simplify_semver("9.1.2"), "9.1.2"); - assert_eq!(simplify_semver("18"), "18"); - assert_eq!(simplify_semver("*"), "latest"); - assert_eq!(simplify_semver("x"), "latest"); - assert_eq!(simplify_semver(">= 18.0.0"), "18"); - assert_eq!(simplify_semver("^18.2.0"), "18.2"); - assert_eq!(simplify_semver("~18.0.0"), "18"); - assert_eq!(simplify_semver("=18.0.0"), "18"); - } - #[test] fn test_parse_package_json() { let dir = tempdir().unwrap(); @@ -241,34 +165,55 @@ mod tests { } #[test] - fn test_simplify_semver_upper_bound() { - assert_eq!(simplify_semver("<18.0.0"), ""); - assert_eq!(simplify_semver("<=18.0.0"), ""); + fn test_runtime_version() { + let pkg: PackageJsonData = serde_json::from_str( + r#"{ + "devEngines": { + "runtime": { + "name": "node", + "version": ">=20.0.0" + } + } + }"#, + ) + .unwrap(); + assert_eq!(pkg.runtime_version("node"), Some(">=20.0.0".to_string())); + assert_eq!(pkg.runtime_version("bun"), None); } #[test] - fn test_simplify_semver_wildcards() { - assert_eq!(simplify_semver("18.x"), "18"); - assert_eq!(simplify_semver("18.*"), "18"); - assert_eq!(simplify_semver("18.2.x"), "18.2"); - assert_eq!(simplify_semver("18.2.*"), "18.2"); + fn test_runtime_version_lower_bound_range() { + let pkg: PackageJsonData = serde_json::from_str( + r#"{ + "devEngines": { + "runtime": { + "name": "node", + "version": ">=25.6.1" + } + } + }"#, + ) + .unwrap(); + assert_eq!(pkg.runtime_version("node"), Some(">=25.6.1".to_string())); } #[test] - fn test_runtime_version() { + fn test_runtime_version_compound_range() { let pkg: PackageJsonData = serde_json::from_str( r#"{ "devEngines": { "runtime": { "name": "node", - "version": ">=20.0.0" + "version": ">=20 <21 || >=22" } } }"#, ) .unwrap(); - assert_eq!(pkg.runtime_version("node"), Some("20".to_string())); - assert_eq!(pkg.runtime_version("bun"), None); + assert_eq!( + pkg.runtime_version("node"), + Some(">=20 <21 || >=22".to_string()) + ); } #[test] @@ -284,7 +229,7 @@ mod tests { }"#, ) .unwrap(); - assert_eq!(pkg.runtime_version("bun"), Some("1".to_string())); + assert_eq!(pkg.runtime_version("bun"), Some("^1.0.0".to_string())); assert_eq!(pkg.runtime_version("node"), None); } @@ -301,7 +246,7 @@ mod tests { }"#, ) .unwrap(); - assert_eq!(pkg.runtime_version("node"), Some("22".to_string())); + assert_eq!(pkg.runtime_version("node"), Some(">=22.0.0".to_string())); } #[test] @@ -332,10 +277,32 @@ mod tests { }"#, ) .unwrap(); - assert_eq!(pkg.package_manager_version("pnpm"), Some("9".to_string())); + assert_eq!( + pkg.package_manager_version("pnpm"), + Some(">=9.0.0".to_string()) + ); assert_eq!(pkg.package_manager_version("yarn"), None); } + #[test] + fn test_package_manager_version_dev_engines_lower_bound_range() { + let pkg: PackageJsonData = serde_json::from_str( + r#"{ + "devEngines": { + "packageManager": { + "name": "yarn", + "version": ">=4.12.0" + } + } + }"#, + ) + .unwrap(); + assert_eq!( + pkg.package_manager_version("yarn"), + Some(">=4.12.0".to_string()) + ); + } + #[test] fn test_package_manager_version_field() { let pkg: PackageJsonData = serde_json::from_str( @@ -379,7 +346,10 @@ mod tests { }"#, ) .unwrap(); - assert_eq!(pkg.package_manager_version("pnpm"), Some("10".to_string())); + assert_eq!( + pkg.package_manager_version("pnpm"), + Some("^10.0.0".to_string()) + ); } #[test] diff --git a/src/semver.rs b/src/semver.rs index 20077a8114..5e19b94334 100644 --- a/src/semver.rs +++ b/src/semver.rs @@ -1,3 +1,4 @@ +use nodejs_semver::{Range, Version as NodeVersion}; use versions::{Mess, Versioning}; /// splits a version number into an optional prefix and the remaining version string @@ -54,9 +55,58 @@ pub fn chunkify_version(v: &str) -> Vec { chunks } +/// Filter a list of version strings with an npm-compatible semver range. +/// +/// Returns `None` for non-range queries so callers can fall back to mise's +/// existing fuzzy matching for aliases and non-semver tools. +pub fn npm_semver_range_filter(versions: &[String], query: &str) -> Option> { + let query = query.trim(); + if !is_npm_semver_range_query(query) { + return None; + } + let range = Range::parse(query).ok()?; + + Some( + versions + .iter() + .filter(|v| { + let version = v.as_str(); + NodeVersion::parse(version) + .or_else(|_| NodeVersion::parse(version.trim_start_matches(['v', 'V']))) + .is_ok_and(|version| range.satisfies(&version)) + }) + .cloned() + .collect(), + ) +} + +pub fn is_npm_semver_range_query(query: &str) -> bool { + if query.is_empty() || query.eq_ignore_ascii_case("latest") { + return false; + } + if query == "*" || query.eq_ignore_ascii_case("x") { + return true; + } + if query.contains("||") || query.contains(" - ") { + return true; + } + if matches!( + query.as_bytes().first().copied(), + Some(b'<' | b'>' | b'=' | b'^' | b'~') + ) || query.contains('<') + || query.contains('>') + { + return true; + } + if query.split_whitespace().count() > 1 { + return true; + } + query.split('.').any(|part| matches!(part, "*" | "x" | "X")) +} + #[cfg(test)] mod tests { - use super::{chunkify_version, split_version_prefix}; + use super::{chunkify_version, npm_semver_range_filter, split_version_prefix}; #[test] fn test_split_version_prefix() { @@ -95,4 +145,80 @@ mod tests { vec!["2", ".3", ".4", "-beta"] ); } + + #[test] + fn test_npm_semver_range_filter_lower_bound() { + let versions = ["25.5.0", "25.6.1", "25.8.2"].map(String::from).to_vec(); + + assert_eq!( + npm_semver_range_filter(&versions, ">=25.6.1").unwrap(), + vec!["25.6.1".to_string(), "25.8.2".to_string()] + ); + assert_eq!( + npm_semver_range_filter(&versions, ">= 25.6.1").unwrap(), + vec!["25.6.1".to_string(), "25.8.2".to_string()] + ); + } + + #[test] + fn test_npm_semver_range_filter_compound_bounds() { + let versions = ["25.5.0", "25.6.1", "25.8.2", "26.0.0"] + .map(String::from) + .to_vec(); + + assert_eq!( + npm_semver_range_filter(&versions, ">=25.6.1 <26").unwrap(), + vec!["25.6.1".to_string(), "25.8.2".to_string()] + ); + } + + #[test] + fn test_npm_semver_range_filter_caret() { + let versions = ["20.0.0", "20.0.1", "20.1.0", "21.0.0"] + .map(String::from) + .to_vec(); + + assert_eq!( + npm_semver_range_filter(&versions, "^20.0.1").unwrap(), + vec!["20.0.1".to_string(), "20.1.0".to_string()] + ); + } + + #[test] + fn test_npm_semver_range_filter_alternatives() { + let versions = ["18.19.0", "20.0.0", "21.9.0", "22.0.0"] + .map(String::from) + .to_vec(); + + assert_eq!( + npm_semver_range_filter(&versions, ">=18 <20 || >=22").unwrap(), + vec!["18.19.0".to_string(), "22.0.0".to_string()] + ); + } + + #[test] + fn test_npm_semver_range_filter_preserves_v_prefix() { + let versions = ["v25.6.1", "v25.8.2"].map(String::from).to_vec(); + + assert_eq!( + npm_semver_range_filter(&versions, ">=25.8.0").unwrap(), + vec!["v25.8.2".to_string()] + ); + } + + #[test] + fn test_npm_semver_range_filter_non_range_queries_fall_back() { + assert_eq!( + npm_semver_range_filter(&["1.0.0".to_string()], "latest"), + None + ); + assert_eq!( + npm_semver_range_filter(&["1.0.0".to_string()], "temurin-"), + None + ); + assert_eq!( + npm_semver_range_filter(&["1.0.0".to_string()], "1.0.0"), + None + ); + } } diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index 8bfdb08f66..16f06a329b 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::{cmp::Ordering, sync::LazyLock}; use std::{collections::BTreeMap, sync::Arc}; -use crate::backend::ABackend; +use crate::backend::{ABackend, VersionInfo}; use crate::cli::args::BackendArg; use crate::config::{Config, Settings}; use crate::env; @@ -13,7 +13,7 @@ use crate::env; use crate::file; use crate::hash::hash_to_str; use crate::lockfile::{CondaPackageInfo, LockfileTool, PlatformInfo}; -use crate::toolset::{ToolRequest, ToolVersionOptions, tool_request}; +use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions, tool_request}; use console::style; use dashmap::DashMap; use eyre::{Result, bail}; @@ -296,6 +296,40 @@ impl ToolVersion { return build(v.clone()); } } + if matches!( + request.source(), + ToolSource::IdiomaticVersionFile(path) + if crate::config::config_file::idiomatic_version::package_json::is_package_json(path) + ) && crate::semver::is_npm_semver_range_query(&v) + { + if !opts.latest_versions { + let installed_versions = backend.list_installed_versions(); + if let Some(matches) = + crate::semver::npm_semver_range_filter(&installed_versions, &v) + && let Some(v) = matches.last() + { + return build(v.clone()); + } + } + if !is_offline { + let versions = match opts.before_date { + Some(before) => { + let versions_with_info = + backend.list_remote_versions_with_info(config).await?; + VersionInfo::filter_by_date(versions_with_info, before) + .into_iter() + .map(|v| v.version) + .collect() + } + None => backend.list_remote_versions(config).await?, + }; + if let Some(matches) = crate::semver::npm_semver_range_filter(&versions, &v) + && let Some(v) = matches.last() + { + return build(v.clone()); + } + } + } // When OFFLINE, skip ALL remote version fetching regardless of version format if is_offline { return build(v);