1use std::collections::BTreeMap;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13
14use crate::config::{ToolConfigEntry, ToolConfigValue};
15use crate::error::{Error, Result};
16use crate::version::resolver::{package_manager_from_package_json, PackageManagerRequest};
17
18pub const INSTALLER_OPTION: &str = "installer";
20
21pub const LOCKED_NPM_INSTALLER_OPTION: &str = "__osdk_npm_installer";
23pub const LOCKED_NPM_SCOPE_OPTION: &str = "__osdk_npm_scope";
24pub const LOCKED_NPM_NATIVE_LOCK_KIND_OPTION: &str = "__osdk_npm_native_lock_kind";
25pub const LOCKED_NPM_NATIVE_LOCK_FORMAT_OPTION: &str = "__osdk_npm_native_lock_format";
26pub const LOCKED_NPM_NATIVE_LOCK_SHA256_OPTION: &str = "__osdk_npm_native_lock_sha256";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
30#[serde(rename_all = "lowercase")]
31pub enum NpmInstaller {
32 #[default]
34 Auto,
35 Aube,
37 Npm,
39 Pnpm,
41}
42
43impl NpmInstaller {
44 pub const fn as_str(self) -> &'static str {
45 match self {
46 Self::Auto => "auto",
47 Self::Aube => "aube",
48 Self::Npm => "npm",
49 Self::Pnpm => "pnpm",
50 }
51 }
52
53 pub const fn executable(self) -> Option<&'static str> {
55 match self {
56 Self::Auto => None,
57 Self::Aube => Some("aube"),
58 Self::Npm => Some("npm"),
59 Self::Pnpm => Some("pnpm"),
60 }
61 }
62
63 pub const fn lockfile_name(self) -> Option<&'static str> {
65 match self {
66 Self::Auto => None,
67 Self::Aube => Some("aube-lock.yaml"),
68 Self::Npm => Some("package-lock.json"),
69 Self::Pnpm => Some("pnpm-lock.yaml"),
70 }
71 }
72
73 pub const fn lock_format(self) -> Option<&'static str> {
77 match self {
78 Self::Auto => None,
79 Self::Aube => Some("aube-v9"),
80 Self::Npm => Some("package-lock-v3"),
81 Self::Pnpm => Some("pnpm-v9"),
82 }
83 }
84
85 const fn is_native(self) -> bool {
86 matches!(self, Self::Npm | Self::Pnpm)
87 }
88}
89
90impl fmt::Display for NpmInstaller {
91 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92 formatter.write_str(self.as_str())
93 }
94}
95
96impl FromStr for NpmInstaller {
97 type Err = Error;
98
99 fn from_str(value: &str) -> Result<Self> {
100 match value.trim().to_ascii_lowercase().as_str() {
101 "auto" => Ok(Self::Auto),
102 "aube" => Ok(Self::Aube),
103 "npm" => Ok(Self::Npm),
104 "pnpm" => Ok(Self::Pnpm),
105 other => Err(Error::config(crate::t!(
106 "err.npm_installer_invalid",
107 installer = other
108 ))),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "lowercase")]
116pub enum ToolScope {
117 #[default]
118 Project,
119 Global,
120}
121
122impl ToolScope {
123 pub const fn as_str(self) -> &'static str {
124 match self {
125 Self::Project => "project",
126 Self::Global => "global",
127 }
128 }
129}
130
131impl fmt::Display for ToolScope {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 formatter.write_str(self.as_str())
134 }
135}
136
137impl FromStr for ToolScope {
138 type Err = Error;
139
140 fn from_str(value: &str) -> Result<Self> {
141 match value.trim().to_ascii_lowercase().as_str() {
142 "project" => Ok(Self::Project),
143 "global" => Ok(Self::Global),
144 other => Err(Error::config(crate::t!(
145 "err.npm_scope_invalid",
146 scope = other
147 ))),
148 }
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
155#[serde(rename_all = "kebab-case")]
156pub enum NativeLockKind {
157 Aube,
158 Pnpm,
159 PackageLock,
160 NpmShrinkwrap,
161}
162
163impl NativeLockKind {
164 pub const fn installer(self) -> NpmInstaller {
165 match self {
166 Self::Aube => NpmInstaller::Aube,
167 Self::Pnpm => NpmInstaller::Pnpm,
168 Self::PackageLock | Self::NpmShrinkwrap => NpmInstaller::Npm,
169 }
170 }
171
172 pub const fn file_name(self) -> &'static str {
173 match self {
174 Self::Aube => "aube-lock.yaml",
175 Self::Pnpm => "pnpm-lock.yaml",
176 Self::PackageLock => "package-lock.json",
177 Self::NpmShrinkwrap => "npm-shrinkwrap.json",
178 }
179 }
180
181 const fn format_prefix(self) -> &'static str {
182 match self {
183 Self::Aube => "aube-v",
184 Self::Pnpm => "pnpm-v",
185 Self::PackageLock => "package-lock-v",
186 Self::NpmShrinkwrap => "npm-shrinkwrap-v",
187 }
188 }
189}
190
191impl fmt::Display for NativeLockKind {
192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193 formatter.write_str(self.file_name())
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct NativeLock {
200 pub kind: NativeLockKind,
201 pub path: PathBuf,
202 pub version: u64,
204 pub format: String,
206 pub supported: bool,
208}
209
210impl NativeLock {
211 pub const fn installer(&self) -> NpmInstaller {
213 self.kind.installer()
214 }
215
216 pub const fn installer_name(&self) -> &'static str {
218 self.installer().as_str()
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct NpmProject {
226 pub root: PathBuf,
227 pub package_json: PathBuf,
228 pub declared_manager: Option<PackageManagerRequest>,
229 pub native_lock: Option<NativeLock>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct NpmInstallPlan {
235 pub scope: ToolScope,
236 pub requested: NpmInstaller,
237 pub installer: NpmInstaller,
238 pub project: Option<NpmProject>,
239}
240
241pub fn installer_from_tool_config(entry: Option<&ToolConfigEntry>) -> Result<NpmInstaller> {
245 let Some(value) = entry
246 .and_then(ToolConfigEntry::options)
247 .and_then(|options| options.get(INSTALLER_OPTION))
248 else {
249 return Ok(NpmInstaller::Auto);
250 };
251 match value {
252 ToolConfigValue::String(value) => value.parse(),
253 _ => Err(Error::config(crate::t!("err.npm_installer_option_type"))),
254 }
255}
256
257pub fn installer_from_request_options(options: &BTreeMap<String, String>) -> Result<NpmInstaller> {
260 options
261 .get(INSTALLER_OPTION)
262 .map(|value| value.parse())
263 .transpose()
264 .map(Option::unwrap_or_default)
265}
266
267pub fn find_nearest_package_json(start_dir: &Path) -> Result<Option<PathBuf>> {
271 for directory in start_dir.ancestors() {
272 let path = directory.join("package.json");
273 match std::fs::symlink_metadata(&path) {
274 Ok(metadata) if metadata.file_type().is_file() => return Ok(Some(path)),
275 Ok(_) => {
276 return Err(Error::config(crate::t!(
277 "err.npm_project_file_not_regular",
278 path = path.display()
279 )))
280 }
281 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
282 Err(error) => return Err(Error::io(path, error)),
283 }
284 }
285 Ok(None)
286}
287
288pub fn inspect_npm_project(start_dir: &Path) -> Result<Option<NpmProject>> {
290 let Some(package_json) = find_nearest_package_json(start_dir)? else {
291 return Ok(None);
292 };
293 let declared_manager =
294 package_manager_from_package_json(&package_json).map_err(Error::config)?;
295 let root = package_json
296 .parent()
297 .expect("a package.json path always has its containing directory")
298 .to_path_buf();
299 let native_lock = inspect_native_lock(&root)?;
300 Ok(Some(NpmProject {
301 root,
302 package_json,
303 declared_manager,
304 native_lock,
305 }))
306}
307
308pub fn plan_npm_installer(
313 start_dir: &Path,
314 requested: NpmInstaller,
315 scope: ToolScope,
316) -> Result<NpmInstallPlan> {
317 if scope == ToolScope::Global {
318 return Ok(NpmInstallPlan {
319 scope,
320 requested,
321 installer: concrete_or_aube(requested),
322 project: None,
323 });
324 }
325
326 let project = inspect_npm_project(start_dir)?;
327 let installer = match requested {
328 NpmInstaller::Auto => select_automatic_installer(project.as_ref())?,
329 concrete => {
330 validate_explicit_installer(concrete, project.as_ref())?;
331 concrete
332 }
333 };
334 debug_assert_ne!(installer, NpmInstaller::Auto);
335 Ok(NpmInstallPlan {
336 scope,
337 requested,
338 installer,
339 project,
340 })
341}
342
343fn concrete_or_aube(requested: NpmInstaller) -> NpmInstaller {
344 match requested {
345 NpmInstaller::Auto => NpmInstaller::Aube,
346 concrete => concrete,
347 }
348}
349
350fn select_automatic_installer(project: Option<&NpmProject>) -> Result<NpmInstaller> {
351 let Some(project) = project else {
352 return Ok(NpmInstaller::Aube);
353 };
354
355 if let Some(declared) = &project.declared_manager {
356 let declared_installer = declared_installer(declared)?;
357 if let Some(native_lock) = &project.native_lock {
358 if native_lock.installer() != declared_installer {
359 return Err(Error::config(crate::t!(
360 "err.npm_manager_lock_owner_conflict",
361 manifest = project.package_json.display(),
362 manager = declared.manager,
363 lock = native_lock.path.display(),
364 owner = native_lock.installer()
365 )));
366 }
367 return if native_lock.supported {
368 Ok(NpmInstaller::Aube)
369 } else {
370 Ok(declared_installer)
371 };
372 }
373 return Ok(NpmInstaller::Aube);
376 }
377
378 match &project.native_lock {
379 Some(native_lock) if !native_lock.supported => Ok(native_lock.installer()),
380 Some(_) | None => Ok(NpmInstaller::Aube),
381 }
382}
383
384fn declared_installer(declared: &PackageManagerRequest) -> Result<NpmInstaller> {
385 match declared.manager.as_str() {
386 "aube" => Ok(NpmInstaller::Aube),
387 "npm" => Ok(NpmInstaller::Npm),
388 "pnpm" => Ok(NpmInstaller::Pnpm),
389 other => Err(Error::config(crate::t!(
390 "err.npm_declared_installer_unsupported",
391 source = declared.source.display(),
392 installer = other
393 ))),
394 }
395}
396
397fn validate_explicit_installer(
398 requested: NpmInstaller,
399 project: Option<&NpmProject>,
400) -> Result<()> {
401 debug_assert_ne!(requested, NpmInstaller::Auto);
402 let Some(native_lock) = project.and_then(|project| project.native_lock.as_ref()) else {
403 return Ok(());
404 };
405
406 if requested == NpmInstaller::Aube {
407 if native_lock.supported {
408 return Ok(());
409 }
410 return Err(Error::config(crate::t!(
411 "err.npm_aube_lock_format_unsupported",
412 format = native_lock.format,
413 path = native_lock.path.display(),
414 owner = native_lock.installer()
415 )));
416 }
417
418 if requested.is_native() && native_lock.installer() != requested {
419 return Err(Error::config(crate::t!(
420 "err.npm_installer_lock_conflict",
421 installer = requested,
422 path = native_lock.path.display(),
423 owner = native_lock.installer()
424 )));
425 }
426 Ok(())
427}
428
429fn inspect_native_lock(project_root: &Path) -> Result<Option<NativeLock>> {
430 let mut present = Vec::new();
431 for kind in [
432 NativeLockKind::Aube,
433 NativeLockKind::Pnpm,
434 NativeLockKind::PackageLock,
435 NativeLockKind::NpmShrinkwrap,
436 ] {
437 let path = project_root.join(kind.file_name());
438 match std::fs::symlink_metadata(&path) {
439 Ok(metadata) if metadata.file_type().is_file() => present.push((kind, path)),
440 Ok(_) => {
441 return Err(Error::config(crate::t!(
442 "err.npm_project_file_not_regular",
443 path = path.display()
444 )))
445 }
446 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
447 Err(error) => return Err(Error::io(path, error)),
448 }
449 }
450
451 if present.len() > 1 {
452 return Err(Error::config(crate::t!(
453 "err.npm_project_lock_ambiguous",
454 paths = present
455 .iter()
456 .map(|(_, path)| path.display().to_string())
457 .collect::<Vec<_>>()
458 .join(", ")
459 )));
460 }
461 let Some((kind, path)) = present.pop() else {
462 return Ok(None);
463 };
464 parse_native_lock(kind, path).map(Some)
465}
466
467fn parse_native_lock(kind: NativeLockKind, path: PathBuf) -> Result<NativeLock> {
468 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
469 let version = match kind {
470 NativeLockKind::PackageLock | NativeLockKind::NpmShrinkwrap => {
471 let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| {
472 Error::config(crate::t!(
473 "err.npm_native_lock_parse",
474 path = path.display(),
475 error = error
476 ))
477 })?;
478 value
479 .get("lockfileVersion")
480 .and_then(serde_json::Value::as_u64)
481 .ok_or_else(|| {
482 Error::config(crate::t!(
483 "err.npm_native_lock_version_numeric_required",
484 path = path.display()
485 ))
486 })?
487 }
488 NativeLockKind::Aube | NativeLockKind::Pnpm => {
489 let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|error| {
490 Error::config(crate::t!(
491 "err.npm_native_lock_parse",
492 path = path.display(),
493 error = error
494 ))
495 })?;
496 let value = value.get("lockfileVersion").ok_or_else(|| {
497 Error::config(crate::t!(
498 "err.npm_native_lock_version_missing",
499 path = path.display()
500 ))
501 })?;
502 yaml_lock_major(value).ok_or_else(|| {
503 Error::config(crate::t!(
504 "err.npm_native_lock_version_malformed",
505 path = path.display()
506 ))
507 })?
508 }
509 };
510
511 let supported = match kind {
512 NativeLockKind::Aube | NativeLockKind::Pnpm => version == 9,
513 NativeLockKind::PackageLock | NativeLockKind::NpmShrinkwrap => {
514 matches!(version, 2 | 3)
515 }
516 };
517 if kind == NativeLockKind::Aube && !supported {
518 return Err(Error::config(crate::t!(
519 "err.npm_aube_lock_version_unsupported",
520 path = path.display(),
521 version = version
522 )));
523 }
524
525 Ok(NativeLock {
526 kind,
527 path,
528 version,
529 format: format!("{}{version}", kind.format_prefix()),
530 supported,
531 })
532}
533
534fn yaml_lock_major(value: &serde_yaml::Value) -> Option<u64> {
535 let raw = match value {
536 serde_yaml::Value::String(value) => value.clone(),
537 serde_yaml::Value::Number(value) => value.to_string(),
538 _ => return None,
539 };
540 let mut components = raw.trim().split('.');
541 let major = components.next()?.parse().ok()?;
542 if components.any(|component| component.parse::<u64>().is_err()) {
543 return None;
544 }
545 Some(major)
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 fn write_package(root: &Path, json: &str) {
553 std::fs::create_dir_all(root).unwrap();
554 std::fs::write(root.join("package.json"), json).unwrap();
555 }
556
557 #[test]
558 fn installer_parse_display_and_serde_round_trip() {
559 for (text, expected) in [
560 ("auto", NpmInstaller::Auto),
561 ("aube", NpmInstaller::Aube),
562 ("npm", NpmInstaller::Npm),
563 ("pnpm", NpmInstaller::Pnpm),
564 ] {
565 assert_eq!(text.parse::<NpmInstaller>().unwrap(), expected);
566 assert_eq!(expected.to_string(), text);
567 assert_eq!(
568 serde_json::to_string(&expected).unwrap(),
569 format!("\"{text}\"")
570 );
571 assert_eq!(
572 serde_json::from_str::<NpmInstaller>(&format!("\"{text}\"")).unwrap(),
573 expected
574 );
575 }
576 assert!("yarn".parse::<NpmInstaller>().is_err());
577 assert!("".parse::<NpmInstaller>().is_err());
578 }
579
580 #[test]
581 fn parses_installer_from_structured_and_merged_options() {
582 let entry = ToolConfigEntry::structured(
583 "3",
584 BTreeMap::from([(
585 INSTALLER_OPTION.into(),
586 ToolConfigValue::String("pnpm".into()),
587 )]),
588 );
589 assert_eq!(
590 installer_from_tool_config(Some(&entry)).unwrap(),
591 NpmInstaller::Pnpm
592 );
593 assert_eq!(
594 installer_from_tool_config(Some(&ToolConfigEntry::legacy("3"))).unwrap(),
595 NpmInstaller::Auto
596 );
597 assert_eq!(
598 installer_from_request_options(&BTreeMap::from([(
599 INSTALLER_OPTION.into(),
600 "npm".into()
601 )]))
602 .unwrap(),
603 NpmInstaller::Npm
604 );
605 assert!(installer_from_request_options(&BTreeMap::from([(
606 INSTALLER_OPTION.into(),
607 "yarn".into()
608 )]))
609 .is_err());
610
611 let invalid = ToolConfigEntry::structured(
612 "3",
613 BTreeMap::from([(INSTALLER_OPTION.into(), ToolConfigValue::Bool(true))]),
614 );
615 assert!(installer_from_tool_config(Some(&invalid)).is_err());
616 }
617
618 #[test]
619 fn nearest_manifest_is_a_hard_boundary() {
620 let temporary = tempfile::tempdir().unwrap();
621 let outer = temporary.path().join("outer");
622 let inner = outer.join("packages/app");
623 let nested = inner.join("src");
624 write_package(&outer, r#"{"packageManager":"npm@11.0.0"}"#);
625 write_package(&inner, "{}");
626 std::fs::create_dir_all(&nested).unwrap();
627
628 let project = inspect_npm_project(&nested).unwrap().unwrap();
629 assert_eq!(project.root, inner);
630 assert!(project.declared_manager.is_none());
631 }
632
633 #[cfg(unix)]
634 #[test]
635 fn symlinked_nearest_manifest_fails_closed() {
636 use std::os::unix::fs::symlink;
637
638 let temporary = tempfile::tempdir().unwrap();
639 let project = temporary.path().join("project");
640 std::fs::create_dir_all(&project).unwrap();
641 std::fs::write(temporary.path().join("manifest.json"), "{}").unwrap();
642 symlink(
643 temporary.path().join("manifest.json"),
644 project.join("package.json"),
645 )
646 .unwrap();
647
648 assert!(find_nearest_package_json(&project).is_err());
649 }
650
651 #[test]
652 fn package_manager_wins_over_dev_engines_while_auto_prefers_aube() {
653 let temporary = tempfile::tempdir().unwrap();
654 write_package(
655 temporary.path(),
656 r#"{"packageManager":"pnpm@9.15.0","devEngines":{"packageManager":{"name":"npm","version":"11.0.0"}}}"#,
657 );
658 let plan =
659 plan_npm_installer(temporary.path(), NpmInstaller::Auto, ToolScope::Project).unwrap();
660 assert_eq!(plan.installer, NpmInstaller::Aube);
661 assert_eq!(
662 plan.project.unwrap().declared_manager.unwrap().manager,
663 "pnpm"
664 );
665 }
666
667 #[test]
668 fn dev_engines_array_uses_first_manager() {
669 let temporary = tempfile::tempdir().unwrap();
670 write_package(
671 temporary.path(),
672 r#"{"devEngines":{"packageManager":[{"name":"npm","version":"11.0.0"},{"name":"pnpm","version":"9.0.0"}]}}"#,
673 );
674 let project = inspect_npm_project(temporary.path()).unwrap().unwrap();
675 assert_eq!(project.declared_manager.unwrap().manager, "npm");
676 }
677
678 #[test]
679 fn detects_each_supported_native_lock_format() {
680 let cases = [
681 (NativeLockKind::Aube, "lockfileVersion: '9.0'\n", "aube-v9"),
682 (NativeLockKind::Pnpm, "lockfileVersion: 9.0\n", "pnpm-v9"),
683 (
684 NativeLockKind::PackageLock,
685 r#"{"lockfileVersion":2}"#,
686 "package-lock-v2",
687 ),
688 (
689 NativeLockKind::NpmShrinkwrap,
690 r#"{"lockfileVersion":3}"#,
691 "npm-shrinkwrap-v3",
692 ),
693 ];
694 for (kind, contents, format) in cases {
695 let temporary = tempfile::tempdir().unwrap();
696 write_package(temporary.path(), "{}");
697 std::fs::write(temporary.path().join(kind.file_name()), contents).unwrap();
698 let lock = inspect_npm_project(temporary.path())
699 .unwrap()
700 .unwrap()
701 .native_lock
702 .unwrap();
703 assert_eq!(lock.kind, kind);
704 assert_eq!(lock.format, format);
705 assert!(lock.supported);
706 }
707 }
708
709 #[test]
710 fn multiple_recognized_locks_are_ambiguous_even_for_one_owner() {
711 let temporary = tempfile::tempdir().unwrap();
712 write_package(temporary.path(), "{}");
713 std::fs::write(
714 temporary.path().join("package-lock.json"),
715 r#"{"lockfileVersion":3}"#,
716 )
717 .unwrap();
718 std::fs::write(
719 temporary.path().join("npm-shrinkwrap.json"),
720 r#"{"lockfileVersion":3}"#,
721 )
722 .unwrap();
723
724 let error = inspect_npm_project(temporary.path()).unwrap_err();
725 let message = error.to_string();
726 assert!(message.contains("package-lock.json"));
727 assert!(message.contains("npm-shrinkwrap.json"));
728 }
729
730 #[test]
731 fn auto_uses_aube_for_supported_lock_and_native_owner_for_known_unsupported_lock() {
732 let temporary = tempfile::tempdir().unwrap();
733 write_package(temporary.path(), "{}");
734 let lock = temporary.path().join("pnpm-lock.yaml");
735 std::fs::write(&lock, "lockfileVersion: '9.0'\n").unwrap();
736 assert_eq!(
737 plan_npm_installer(temporary.path(), NpmInstaller::Auto, ToolScope::Project)
738 .unwrap()
739 .installer,
740 NpmInstaller::Aube
741 );
742
743 std::fs::write(&lock, "lockfileVersion: '8.0'\n").unwrap();
744 assert_eq!(
745 plan_npm_installer(temporary.path(), NpmInstaller::Auto, ToolScope::Project)
746 .unwrap()
747 .installer,
748 NpmInstaller::Pnpm
749 );
750 }
751
752 #[test]
753 fn auto_rejects_declared_manager_and_lock_owner_conflict() {
754 let temporary = tempfile::tempdir().unwrap();
755 write_package(temporary.path(), r#"{"packageManager":"npm@11.0.0"}"#);
756 std::fs::write(
757 temporary.path().join("pnpm-lock.yaml"),
758 "lockfileVersion: '9.0'\n",
759 )
760 .unwrap();
761 assert!(
762 plan_npm_installer(temporary.path(), NpmInstaller::Auto, ToolScope::Project).is_err()
763 );
764 }
765
766 #[test]
767 fn declared_manager_with_unsupported_owned_lock_uses_native_owner() {
768 for (manager, file, contents, expected) in [
769 (
770 "npm",
771 "package-lock.json",
772 r#"{"lockfileVersion":4}"#,
773 NpmInstaller::Npm,
774 ),
775 (
776 "pnpm",
777 "pnpm-lock.yaml",
778 "lockfileVersion: '8.0'\n",
779 NpmInstaller::Pnpm,
780 ),
781 ] {
782 let temporary = tempfile::tempdir().unwrap();
783 write_package(
784 temporary.path(),
785 &format!(r#"{{"packageManager":"{manager}@9.0.0"}}"#),
786 );
787 std::fs::write(temporary.path().join(file), contents).unwrap();
788 assert_eq!(
789 plan_npm_installer(temporary.path(), NpmInstaller::Auto, ToolScope::Project)
790 .unwrap()
791 .installer,
792 expected
793 );
794 }
795 }
796
797 #[test]
798 fn explicit_choice_overrides_declaration_but_respects_lock_ownership() {
799 let temporary = tempfile::tempdir().unwrap();
800 write_package(temporary.path(), r#"{"packageManager":"pnpm@9.0.0"}"#);
801 std::fs::write(
802 temporary.path().join("package-lock.json"),
803 r#"{"lockfileVersion":3}"#,
804 )
805 .unwrap();
806
807 assert_eq!(
808 plan_npm_installer(temporary.path(), NpmInstaller::Npm, ToolScope::Project)
809 .unwrap()
810 .installer,
811 NpmInstaller::Npm
812 );
813 assert_eq!(
814 plan_npm_installer(temporary.path(), NpmInstaller::Aube, ToolScope::Project)
815 .unwrap()
816 .installer,
817 NpmInstaller::Aube
818 );
819 assert!(
820 plan_npm_installer(temporary.path(), NpmInstaller::Pnpm, ToolScope::Project).is_err()
821 );
822 }
823
824 #[test]
825 fn malformed_or_unsupported_aube_locks_fail_without_fallback() {
826 let temporary = tempfile::tempdir().unwrap();
827 write_package(temporary.path(), "{}");
828 let lock = temporary.path().join("aube-lock.yaml");
829 std::fs::write(&lock, "importers: {}\n").unwrap();
830 assert!(inspect_npm_project(temporary.path()).is_err());
831
832 std::fs::write(&lock, "lockfileVersion: '8.0'\n").unwrap();
833 assert!(inspect_npm_project(temporary.path()).is_err());
834 }
835
836 #[test]
837 fn unsupported_npm_lock_is_preclassified_for_native_execution() {
838 let temporary = tempfile::tempdir().unwrap();
839 write_package(temporary.path(), "{}");
840 std::fs::write(
841 temporary.path().join("package-lock.json"),
842 r#"{"lockfileVersion":4}"#,
843 )
844 .unwrap();
845
846 let plan =
847 plan_npm_installer(temporary.path(), NpmInstaller::Auto, ToolScope::Project).unwrap();
848 assert_eq!(plan.installer, NpmInstaller::Npm);
849 let lock = plan.project.unwrap().native_lock.unwrap();
850 assert!(!lock.supported);
851 assert_eq!(lock.installer_name(), "npm");
852 assert_eq!(lock.format, "package-lock-v4");
853
854 assert!(
855 plan_npm_installer(temporary.path(), NpmInstaller::Aube, ToolScope::Project).is_err()
856 );
857 }
858
859 #[test]
860 fn unsupported_declared_managers_fail_in_auto_but_explicit_choice_wins() {
861 let temporary = tempfile::tempdir().unwrap();
862 write_package(temporary.path(), r#"{"packageManager":"yarn@4.10.3"}"#);
863
864 assert!(
865 plan_npm_installer(temporary.path(), NpmInstaller::Auto, ToolScope::Project).is_err()
866 );
867 assert_eq!(
868 plan_npm_installer(temporary.path(), NpmInstaller::Aube, ToolScope::Project)
869 .unwrap()
870 .installer,
871 NpmInstaller::Aube
872 );
873 }
874
875 #[test]
876 fn missing_or_malformed_lock_versions_fail_closed() {
877 let cases = [
878 ("package-lock.json", "{}"),
879 ("package-lock.json", r#"{"lockfileVersion":"3"}"#),
880 ("pnpm-lock.yaml", "packages: {}\n"),
881 ("pnpm-lock.yaml", "lockfileVersion: nope\n"),
882 ];
883 for (name, contents) in cases {
884 let temporary = tempfile::tempdir().unwrap();
885 write_package(temporary.path(), "{}");
886 std::fs::write(temporary.path().join(name), contents).unwrap();
887 assert!(inspect_npm_project(temporary.path()).is_err(), "{name}");
888 }
889 }
890
891 #[test]
892 fn global_plan_does_not_inspect_current_project() {
893 let temporary = tempfile::tempdir().unwrap();
894 write_package(temporary.path(), "not json");
895 std::fs::write(temporary.path().join("pnpm-lock.yaml"), "not yaml: [").unwrap();
896
897 let plan =
898 plan_npm_installer(temporary.path(), NpmInstaller::Auto, ToolScope::Global).unwrap();
899 assert_eq!(plan.installer, NpmInstaller::Aube);
900 assert!(plan.project.is_none());
901 }
902
903 #[test]
904 fn npm_scope_errors_render_in_chinese() {
905 let invalid_installer = crate::i18n::interpolate(
906 &crate::i18n::trl(crate::i18n::Lang::Zh, "err.npm_installer_invalid"),
907 &[("installer", "yarn")],
908 );
909 assert_eq!(
910 invalid_installer,
911 "无效的 npm 安装器 `yarn`(应为 auto|aube|npm|pnpm)"
912 );
913
914 let conflict = crate::i18n::interpolate(
915 &crate::i18n::trl(crate::i18n::Lang::Zh, "err.npm_manager_lock_owner_conflict"),
916 &[
917 ("manifest", "/repo/package.json"),
918 ("manager", "npm"),
919 ("lock", "/repo/pnpm-lock.yaml"),
920 ("owner", "pnpm"),
921 ],
922 );
923 assert_eq!(
924 conflict,
925 "/repo/package.json 声明的包管理器为 `npm`,但 /repo/pnpm-lock.yaml 属于 `pnpm`"
926 );
927 }
928}