1use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use crate::config::{parse_tool_versions, PROJECT_CONFIG_NAMES};
17
18#[derive(Debug, Clone)]
20pub struct ActiveVersion {
21 pub tool: String,
22 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
44pub 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
208pub 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 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 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 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 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 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
324fn 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 match name {
332 "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 }
346 "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" => {
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 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}