Skip to main content

osdk_core/version/
resolver.rs

1//! Active-version resolution for a working directory.
2//!
3//! Resolution order for a tool, walking up from the start dir:
4//! 1. project config (`osdk.toml`) `[tools]` entry
5//! 2. `.tool-versions` entry
6//! 3. idiomatic version files (`.nvmrc`, `.node-version`, ...) — per backend
7//! 4. structured project metadata (`package.json` for Node)
8//! 5. user global config `[tools]` entry
9//!
10//! This module is intentionally synchronous and dependency-light so the
11//! `osdk-shim` launcher can use it on the hot path without a tokio runtime.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use crate::config::{parse_tool_versions, PROJECT_CONFIG_NAMES};
17
18/// A resolved active version for a tool, plus where it came from.
19#[derive(Debug, Clone)]
20pub struct ActiveVersion {
21    pub tool: String,
22    /// The raw version spec string (e.g. "20", "lts", "20.11.1").
23    pub spec: String,
24    pub source: VersionOrigin,
25    pub is_range: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum VersionOrigin {
30    ProjectConfig(PathBuf),
31    ToolVersions(PathBuf),
32    IdiomaticFile(PathBuf),
33    ProjectMetadata(PathBuf),
34    GlobalConfig,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PackageManagerRequest {
39    pub manager: String,
40    pub version: String,
41    pub source: PathBuf,
42}
43
44/// Read the package-manager declaration from one specific `package.json`.
45///
46/// Unlike [`resolve_package_manager`], this helper does not walk ancestors and
47/// does not restrict the manager name. Callers that treat the nearest manifest
48/// as a hard project boundary can therefore inspect that exact declaration and
49/// apply their own supported-manager policy. `packageManager` wins over
50/// `devEngines.packageManager`, matching Node/Corepack conventions.
51pub fn package_manager_from_package_json(
52    path: &Path,
53) -> Result<Option<PackageManagerRequest>, String> {
54    let text = std::fs::read_to_string(path).map_err(|error| {
55        crate::t!(
56            "err.package_manager_manifest_read",
57            path = path.display(),
58            error = error
59        )
60    })?;
61    let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| {
62        crate::t!(
63            "err.package_manager_manifest_parse",
64            path = path.display(),
65            error = error
66        )
67    })?;
68
69    if let Some(value) = value.get("packageManager") {
70        let raw = value
71            .as_str()
72            .ok_or_else(|| crate::t!("err.package_manager_field_type", path = path.display()))?;
73        return parse_package_manager_declaration(raw, path.to_path_buf()).map(Some);
74    }
75
76    let Some(package_manager) = value
77        .get("devEngines")
78        .and_then(|value| value.get("packageManager"))
79    else {
80        return Ok(None);
81    };
82    let package_manager = if let Some(items) = package_manager.as_array() {
83        items.first().ok_or_else(|| {
84            crate::t!(
85                "err.package_manager_dev_engines_empty",
86                path = path.display()
87            )
88        })?
89    } else {
90        package_manager
91    };
92    let manager = package_manager
93        .get("name")
94        .and_then(serde_json::Value::as_str)
95        .ok_or_else(|| crate::t!("err.package_manager_name_missing", path = path.display()))?;
96    let version = package_manager
97        .get("version")
98        .and_then(serde_json::Value::as_str)
99        .ok_or_else(|| crate::t!("err.package_manager_version_missing", path = path.display()))?;
100    parse_package_manager_declaration_parts(manager, version, path.to_path_buf()).map(Some)
101}
102
103pub fn resolve_package_manager(start_dir: &Path) -> Result<Option<PackageManagerRequest>, String> {
104    for directory in start_dir.ancestors() {
105        for name in PROJECT_CONFIG_NAMES {
106            let path = directory.join(name);
107            if path.is_file() {
108                if let Some((manager, version)) = read_project_package_manager(&path) {
109                    return parse_package_manager(&manager, &version, path).map(Some);
110                }
111            }
112        }
113    }
114    for directory in start_dir.ancestors() {
115        let path = directory.join("package.json");
116        if !path.is_file() {
117            continue;
118        }
119        if let Some(request) = package_manager_from_package_json(&path)? {
120            return validate_supported_package_manager(request).map(Some);
121        }
122    }
123    Ok(None)
124}
125
126fn read_project_package_manager(path: &Path) -> Option<(String, String)> {
127    let text = std::fs::read_to_string(path).ok()?;
128    let value: toml::Value = toml::from_str(&text).ok()?;
129    let tools = value.get("tools")?;
130    for manager in ["npm", "pnpm", "yarn"] {
131        if let Some(value) = tools.get(manager) {
132            if let Some(version) = read_tool_value(value) {
133                return Some((manager.into(), version));
134            }
135        }
136    }
137    None
138}
139
140fn parse_package_manager_declaration(
141    raw: &str,
142    source: PathBuf,
143) -> Result<PackageManagerRequest, String> {
144    let Some((manager, version)) = raw.split_once('@') else {
145        return Err(crate::t!(
146            "err.package_manager_declaration_invalid",
147            path = source.display()
148        ));
149    };
150    parse_package_manager_declaration_parts(manager, version, source)
151}
152
153fn parse_package_manager(
154    manager: &str,
155    version: &str,
156    source: PathBuf,
157) -> Result<PackageManagerRequest, String> {
158    let request = parse_package_manager_declaration_parts(manager, version, source)?;
159    validate_supported_package_manager(request)
160}
161
162fn validate_supported_package_manager(
163    request: PackageManagerRequest,
164) -> Result<PackageManagerRequest, String> {
165    if matches!(request.manager.as_str(), "npm" | "pnpm" | "yarn") {
166        Ok(request)
167    } else {
168        Err(crate::t!(
169            "err.package_manager_unsupported",
170            path = request.source.display(),
171            manager = request.manager
172        ))
173    }
174}
175
176fn parse_package_manager_declaration_parts(
177    manager: &str,
178    version: &str,
179    source: PathBuf,
180) -> Result<PackageManagerRequest, String> {
181    let manager = manager.trim();
182    let version = version.trim();
183    if manager.is_empty()
184        || manager.contains(char::is_whitespace)
185        || manager.contains(['/', '\\', '#', '+'])
186    {
187        return Err(crate::t!(
188            "err.package_manager_name_invalid",
189            path = source.display(),
190            manager = manager
191        ));
192    }
193    if version.contains(['#', '+', '/', '\\']) || semver::Version::parse(version).is_err() {
194        return Err(crate::t!(
195            "err.package_manager_version_not_exact",
196            path = source.display(),
197            manager = manager,
198            version = version
199        ));
200    }
201    Ok(PackageManagerRequest {
202        manager: manager.to_ascii_lowercase(),
203        version: version.to_string(),
204        source,
205    })
206}
207
208/// Resolve the active spec for `tool`, walking up from `start_dir`. Global
209/// config pins are consulted last. `idiomatic` maps a tool to its idiomatic
210/// filenames.
211pub fn resolve_active(
212    tool: &str,
213    start_dir: &Path,
214    global_tools: &BTreeMap<String, String>,
215    idiomatic_files: &[&str],
216) -> Option<ActiveVersion> {
217    if matches!(tool, "npm" | "pnpm" | "yarn") {
218        if let Ok(Some(package_manager)) = resolve_package_manager(start_dir) {
219            if package_manager.manager == tool {
220                return Some(ActiveVersion {
221                    tool: tool.into(),
222                    spec: package_manager.version,
223                    source: VersionOrigin::ProjectMetadata(package_manager.source),
224                    is_range: false,
225                });
226            }
227        }
228    }
229    let ancestors: Vec<&Path> = start_dir.ancestors().collect();
230
231    // 1. project config
232    for dir in &ancestors {
233        for name in PROJECT_CONFIG_NAMES {
234            let p = dir.join(name);
235            if p.is_file() {
236                if let Some(spec) = read_project_tool(&p, tool) {
237                    return Some(ActiveVersion {
238                        tool: tool.to_string(),
239                        spec,
240                        source: VersionOrigin::ProjectConfig(p),
241                        is_range: false,
242                    });
243                }
244            }
245        }
246    }
247
248    // 2. .tool-versions
249    for dir in &ancestors {
250        let tv = dir.join(".tool-versions");
251        if tv.is_file() {
252            if let Ok(text) = std::fs::read_to_string(&tv) {
253                let map = parse_tool_versions(&text);
254                if let Some(spec) = map.get(tool) {
255                    return Some(ActiveVersion {
256                        tool: tool.to_string(),
257                        spec: spec.clone(),
258                        source: VersionOrigin::ToolVersions(tv),
259                        is_range: false,
260                    });
261                }
262            }
263        }
264    }
265
266    // 3. idiomatic files, preserving each backend's declared priority.
267    for name in idiomatic_files {
268        for dir in &ancestors {
269            let p = dir.join(name);
270            if p.is_file() {
271                if let Some(spec) = read_idiomatic(&p) {
272                    return Some(ActiveVersion {
273                        tool: tool.to_string(),
274                        spec,
275                        source: VersionOrigin::IdiomaticFile(p),
276                        is_range: false,
277                    });
278                }
279            }
280        }
281    }
282
283    // 4. structured project metadata.
284    if tool == "node" {
285        for dir in &ancestors {
286            let package = dir.join("package.json");
287            if package.is_file() {
288                if let Some(spec) = read_node_package(&package) {
289                    return Some(ActiveVersion {
290                        tool: tool.to_string(),
291                        spec,
292                        source: VersionOrigin::ProjectMetadata(package),
293                        is_range: true,
294                    });
295                }
296            }
297        }
298    }
299
300    // 5. global config
301    global_tools.get(tool).map(|spec| ActiveVersion {
302        tool: tool.to_string(),
303        spec: spec.clone(),
304        source: VersionOrigin::GlobalConfig,
305        is_range: false,
306    })
307}
308
309fn read_project_tool(path: &Path, tool: &str) -> Option<String> {
310    let text = std::fs::read_to_string(path).ok()?;
311    let value: toml::Value = toml::from_str(&text).ok()?;
312    read_tool_value(value.get("tools")?.get(tool)?)
313}
314
315fn read_tool_value(value: &toml::Value) -> Option<String> {
316    value.as_str().map(str::to_string).or_else(|| {
317        value
318            .get("version")
319            .and_then(toml::Value::as_str)
320            .map(str::to_string)
321    })
322}
323
324/// Read a simple idiomatic version file (`.nvmrc`, `.python-version`, ...).
325/// Takes the first non-empty, non-comment line and trims a leading `v`.
326fn read_idiomatic(path: &Path) -> Option<String> {
327    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
328    let text = std::fs::read_to_string(path).ok()?;
329
330    // Structured files need real parsing, not first-line heuristics.
331    match name {
332        // rust-toolchain.toml: [toolchain] channel = "1.79.0" | "stable"
333        "rust-toolchain.toml" => {
334            if let Ok(v) = toml::from_str::<toml::Value>(&text) {
335                if let Some(ch) = v
336                    .get("toolchain")
337                    .and_then(|t| t.get("channel"))
338                    .and_then(|c| c.as_str())
339                {
340                    return Some(ch.to_string());
341                }
342            }
343            // legacy `rust-toolchain` may itself be TOML or a bare string;
344            // fall through to plain handling.
345        }
346        // legacy `rust-toolchain` (no extension): a bare channel string, but
347        // could also be TOML. Try TOML first.
348        "rust-toolchain" => {
349            if let Ok(v) = toml::from_str::<toml::Value>(&text) {
350                if let Some(ch) = v
351                    .get("toolchain")
352                    .and_then(|t| t.get("channel"))
353                    .and_then(|c| c.as_str())
354                {
355                    return Some(ch.to_string());
356                }
357            }
358        }
359        // go.mod: the `go 1.22` directive.
360        "go.mod" => {
361            for line in text.lines() {
362                let line = line.trim();
363                if let Some(rest) = line.strip_prefix("go ") {
364                    let v = rest.trim();
365                    if !v.is_empty() {
366                        return Some(v.to_string());
367                    }
368                }
369            }
370            return None;
371        }
372        _ => {}
373    }
374
375    // Plain single-value files (.nvmrc, .python-version, .java-version, ...).
376    for line in text.lines() {
377        let line = line.split('#').next().unwrap_or("").trim();
378        if line.is_empty() {
379            continue;
380        }
381        let v = line.trim_start_matches('v').trim();
382        if !v.is_empty() {
383            return Some(v.to_string());
384        }
385    }
386    None
387}
388
389fn read_node_package(path: &Path) -> Option<String> {
390    let text = std::fs::read_to_string(path).ok()?;
391    let value: serde_json::Value = serde_json::from_str(&text).ok()?;
392    value
393        .get("engines")
394        .and_then(|engines| engines.get("node"))
395        .and_then(serde_json::Value::as_str)
396        .map(str::to_string)
397        .or_else(|| {
398            let runtime = value.get("devEngines")?.get("runtime")?;
399            let runtime = runtime
400                .as_array()
401                .and_then(|items| {
402                    items.iter().find(|item| {
403                        item.get("name").and_then(serde_json::Value::as_str) == Some("node")
404                    })
405                })
406                .unwrap_or(runtime);
407            let name = runtime
408                .get("name")
409                .and_then(serde_json::Value::as_str)
410                .unwrap_or("node");
411            if name != "node" {
412                return None;
413            }
414            runtime
415                .get("version")
416                .and_then(serde_json::Value::as_str)
417                .map(str::to_string)
418        })
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use std::io::Write;
425
426    #[test]
427    fn project_config_beats_global() {
428        let td = tempfile::tempdir().unwrap();
429        let dir = td.path().join("proj");
430        std::fs::create_dir_all(&dir).unwrap();
431        let mut f = std::fs::File::create(dir.join("osdk.toml")).unwrap();
432        writeln!(f, "[tools]\nnode = \"20.11.1\"").unwrap();
433
434        let mut global = BTreeMap::new();
435        global.insert("node".to_string(), "18".to_string());
436
437        let av = resolve_active("node", &dir, &global, &[".nvmrc"]).unwrap();
438        assert_eq!(av.spec, "20.11.1");
439        assert!(matches!(av.source, VersionOrigin::ProjectConfig(_)));
440    }
441
442    #[test]
443    fn nvmrc_resolved_when_no_config() {
444        let td = tempfile::tempdir().unwrap();
445        let dir = td.path().join("proj");
446        std::fs::create_dir_all(&dir).unwrap();
447        std::fs::write(dir.join(".nvmrc"), "v20.11.1\n").unwrap();
448
449        let global = BTreeMap::new();
450        let av = resolve_active("node", &dir, &global, &[".nvmrc", ".node-version"]).unwrap();
451        assert_eq!(av.spec, "20.11.1");
452        assert!(matches!(av.source, VersionOrigin::IdiomaticFile(_)));
453    }
454
455    #[test]
456    fn node_package_engines_and_dev_engines_are_resolved_last() {
457        let td = tempfile::tempdir().unwrap();
458        let dir = td.path().join("proj");
459        std::fs::create_dir_all(&dir).unwrap();
460        std::fs::write(
461            dir.join("package.json"),
462            r#"{"engines":{"node":">=20 <23"},"devEngines":{"runtime":{"name":"node","version":"^22.0.0"}}}"#,
463        )
464        .unwrap();
465
466        let global = BTreeMap::new();
467        let active = resolve_active("node", &dir, &global, &[".nvmrc", ".node-version"]).unwrap();
468        assert_eq!(active.spec, ">=20 <23");
469        assert!(matches!(active.source, VersionOrigin::ProjectMetadata(_)));
470
471        std::fs::write(
472            dir.join("package.json"),
473            r#"{"devEngines":{"runtime":{"name":"node","version":"^22.0.0"}}}"#,
474        )
475        .unwrap();
476        let active = resolve_active("node", &dir, &global, &[".nvmrc", ".node-version"]).unwrap();
477        assert_eq!(active.spec, "^22.0.0");
478    }
479
480    #[test]
481    fn package_manager_field_and_dev_engines_parse_exact_versions() {
482        let temp = tempfile::tempdir().unwrap();
483        std::fs::write(
484            temp.path().join("package.json"),
485            r#"{"packageManager":"pnpm@9.15.0","devEngines":{"packageManager":{"name":"yarn","version":"4.10.3"}}}"#,
486        )
487        .unwrap();
488        let selected = resolve_package_manager(temp.path()).unwrap().unwrap();
489        assert_eq!(selected.manager, "pnpm");
490        assert_eq!(selected.version, "9.15.0");
491
492        std::fs::write(
493            temp.path().join("package.json"),
494            r#"{"devEngines":{"packageManager":{"name":"yarn","version":"4.10.3"}}}"#,
495        )
496        .unwrap();
497        let selected = resolve_package_manager(temp.path()).unwrap().unwrap();
498        assert_eq!(selected.manager, "yarn");
499        assert_eq!(selected.version, "4.10.3");
500    }
501
502    #[test]
503    fn exact_package_json_helper_stays_at_one_manifest_and_preserves_unknown_manager() {
504        let temporary = tempfile::tempdir().unwrap();
505        let package = temporary.path().join("package.json");
506        std::fs::write(&package, r#"{"packageManager":"bun@1.2.3"}"#).unwrap();
507
508        let selected = package_manager_from_package_json(&package)
509            .unwrap()
510            .unwrap();
511        assert_eq!(selected.manager, "bun");
512        assert_eq!(selected.version, "1.2.3");
513        assert_eq!(selected.source, package);
514    }
515
516    #[test]
517    fn project_tools_beat_package_manager_and_invalid_values_fail() {
518        let temp = tempfile::tempdir().unwrap();
519        std::fs::write(temp.path().join("osdk.toml"), "[tools]\nnpm = \"11.5.2\"\n").unwrap();
520        std::fs::write(
521            temp.path().join("package.json"),
522            r#"{"packageManager":"pnpm@9.15.0"}"#,
523        )
524        .unwrap();
525        let selected = resolve_package_manager(temp.path()).unwrap().unwrap();
526        assert_eq!(selected.manager, "npm");
527        assert_eq!(selected.version, "11.5.2");
528
529        std::fs::remove_file(temp.path().join("osdk.toml")).unwrap();
530        for value in [
531            "pnpm",
532            "bun@1.2.3",
533            "npm@https://example.test/npm.tgz",
534            "yarn@4.0.0+sha",
535        ] {
536            std::fs::write(
537                temp.path().join("package.json"),
538                format!(r#"{{"packageManager":"{value}"}}"#),
539            )
540            .unwrap();
541            assert!(resolve_package_manager(temp.path()).is_err(), "{value}");
542        }
543    }
544
545    #[test]
546    fn package_manager_errors_render_in_chinese() {
547        let invalid = crate::i18n::interpolate(
548            &crate::i18n::trl(
549                crate::i18n::Lang::Zh,
550                "err.package_manager_version_not_exact",
551            ),
552            &[
553                ("path", "/repo/package.json"),
554                ("manager", "npm"),
555                ("version", "latest"),
556            ],
557        );
558        assert_eq!(
559            invalid,
560            "/repo/package.json 中的包管理器 `npm` 必须使用不含 URL/hash 后缀的精确 semver:`latest`"
561        );
562    }
563
564    #[test]
565    fn structured_project_tool_version_is_resolved() {
566        let td = tempfile::tempdir().unwrap();
567        let dir = td.path().join("proj");
568        std::fs::create_dir_all(&dir).unwrap();
569        std::fs::write(
570            dir.join("osdk.toml"),
571            "[tools]\nnode = { version = \"20.11.1\", engine = \"node\" }\nnpm = { version = \"11.5.2\", allow_builds = [\"esbuild\"] }\n",
572        )
573        .unwrap();
574
575        let global = BTreeMap::new();
576        let active = resolve_active("node", &dir, &global, &[".nvmrc"]).unwrap();
577        assert_eq!(active.spec, "20.11.1");
578
579        let package_manager = resolve_package_manager(&dir).unwrap().unwrap();
580        assert_eq!(package_manager.manager, "npm");
581        assert_eq!(package_manager.version, "11.5.2");
582    }
583
584    #[test]
585    fn node_version_files_beat_package_json_and_invalid_ranges_are_preserved_for_validation() {
586        let td = tempfile::tempdir().unwrap();
587        std::fs::write(td.path().join(".node-version"), "21.7.3\n").unwrap();
588        std::fs::write(
589            td.path().join("package.json"),
590            r#"{"engines":{"node":"definitely-not-semver"}}"#,
591        )
592        .unwrap();
593        let global = BTreeMap::new();
594        let active =
595            resolve_active("node", td.path(), &global, &[".nvmrc", ".node-version"]).unwrap();
596        assert_eq!(active.spec, "21.7.3");
597
598        std::fs::remove_file(td.path().join(".node-version")).unwrap();
599        let active =
600            resolve_active("node", td.path(), &global, &[".nvmrc", ".node-version"]).unwrap();
601        assert_eq!(active.spec, "definitely-not-semver");
602        assert!(active.is_range);
603    }
604
605    #[test]
606    fn walks_up_to_parent() {
607        let td = tempfile::tempdir().unwrap();
608        std::fs::write(td.path().join(".tool-versions"), "go 1.22.5\n").unwrap();
609        let nested = td.path().join("a/b");
610        std::fs::create_dir_all(&nested).unwrap();
611
612        let global = BTreeMap::new();
613        let av = resolve_active("go", &nested, &global, &[]).unwrap();
614        assert_eq!(av.spec, "1.22.5");
615        assert!(matches!(av.source, VersionOrigin::ToolVersions(_)));
616    }
617
618    #[test]
619    fn higher_priority_parent_file_beats_lower_priority_child_file() {
620        let td = tempfile::tempdir().unwrap();
621        std::fs::write(td.path().join("osdk.toml"), "[tools]\nnode = \"22\"\n").unwrap();
622        let nested = td.path().join("nested");
623        std::fs::create_dir_all(&nested).unwrap();
624        std::fs::write(nested.join(".nvmrc"), "20\n").unwrap();
625        std::fs::write(nested.join("package.json"), r#"{"engines":{"node":"18"}}"#).unwrap();
626
627        let active = resolve_active(
628            "node",
629            &nested,
630            &BTreeMap::new(),
631            &[".nvmrc", ".node-version"],
632        )
633        .unwrap();
634        assert_eq!(active.spec, "22");
635        assert!(matches!(active.source, VersionOrigin::ProjectConfig(_)));
636    }
637
638    #[test]
639    fn falls_back_to_global() {
640        let td = tempfile::tempdir().unwrap();
641        let mut global = BTreeMap::new();
642        global.insert("node".to_string(), "18".to_string());
643        let av = resolve_active("node", td.path(), &global, &[]).unwrap();
644        assert_eq!(av.spec, "18");
645        assert_eq!(av.source, VersionOrigin::GlobalConfig);
646    }
647
648    #[test]
649    fn rust_toolchain_toml_channel_parsed() {
650        let td = tempfile::tempdir().unwrap();
651        std::fs::write(
652            td.path().join("rust-toolchain.toml"),
653            "[toolchain]\nchannel = \"1.79.0\"\ncomponents = [\"clippy\"]\n",
654        )
655        .unwrap();
656        let global = BTreeMap::new();
657        let av = resolve_active("rust", td.path(), &global, &["rust-toolchain.toml"]).unwrap();
658        assert_eq!(av.spec, "1.79.0");
659    }
660
661    #[test]
662    fn go_mod_directive_parsed() {
663        let td = tempfile::tempdir().unwrap();
664        std::fs::write(
665            td.path().join("go.mod"),
666            "module example.com/x\n\ngo 1.22\n\nrequire foo v1.0.0\n",
667        )
668        .unwrap();
669        let global = BTreeMap::new();
670        let av = resolve_active("go", td.path(), &global, &["go.mod"]).unwrap();
671        assert_eq!(av.spec, "1.22");
672    }
673}