Skip to main content

osdk_core/
npm_tools.rs

1//! Installer selection for npm-backed developer tools.
2//!
3//! Planning is deliberately side-effect free. The selected installer is fixed
4//! before a project is mutated, so callers must never retry a failed Aube
5//! operation with npm or pnpm (or vice versa).
6
7use 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
18/// Public structured-tool/request option used to select an installer.
19pub const INSTALLER_OPTION: &str = "installer";
20
21/// Private resolved-plan options persisted through lock/install requests.
22pub 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/// Installer requested for an npm-backed tool.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
30#[serde(rename_all = "lowercase")]
31pub enum NpmInstaller {
32    /// Select an installer from project declarations and lockfile compatibility.
33    #[default]
34    Auto,
35    /// Use osdk's embedded Aube engine.
36    Aube,
37    /// Delegate once to the managed npm executable.
38    Npm,
39    /// Delegate once to the managed pnpm executable.
40    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    /// Executable name for a concrete installer. `auto` has no executable.
54    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    /// Canonical lockfile created by a concrete installer.
64    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    /// Canonical format created by the current concrete installer. Existing
74    /// compatible locks can have a different supported format (for example,
75    /// npm's package-lock v2); use [`NativeLock::format`] when inspecting one.
76    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/// Whether a tool declaration belongs to the current project or the user.
114#[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/// Which on-disk lockfile was found. Package-lock and shrinkwrap stay distinct
153/// so diagnostics and integrity metadata identify the file that was consumed.
154#[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/// A recognized lockfile next to the nearest `package.json`.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct NativeLock {
200    pub kind: NativeLockKind,
201    pub path: PathBuf,
202    /// Parsed major lockfile version.
203    pub version: u64,
204    /// Stable format identity such as `pnpm-v9` or `package-lock-v2`.
205    pub format: String,
206    /// Whether embedded Aube can safely read and round-trip this format.
207    pub supported: bool,
208}
209
210impl NativeLock {
211    /// Concrete installer that owns this native lockfile.
212    pub const fn installer(&self) -> NpmInstaller {
213        self.kind.installer()
214    }
215
216    /// Serialized owner label used by lock metadata.
217    pub const fn installer_name(&self) -> &'static str {
218        self.installer().as_str()
219    }
220}
221
222/// The nearest Node project and the package-manager signals found at its hard
223/// boundary.
224#[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/// Fully preflighted installer choice. `installer` is always concrete.
233#[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
241/// Read `installer` from one structured `[tools.<name>]` entry. Legacy entries
242/// and structured entries without the option mean `auto`. Non-string values
243/// are rejected rather than being silently stringified.
244pub 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
257/// Read `installer` after CLI and config options have been merged into a tool
258/// request. Missing means `auto`; unknown or empty values are errors.
259pub 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
267/// Find the closest ancestor `package.json`. A manifest is a hard boundary: an
268/// unsafe symlink or non-regular entry fails instead of falling through to an
269/// outer project.
270pub 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
288/// Inspect the nearest package boundary without choosing an installer.
289pub 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
308/// Select an installer without running it. Global tools deliberately ignore
309/// the current project. Project auto-selection uses declarations first, then
310/// lock compatibility; known unsupported npm/pnpm lock versions are delegated
311/// to their native owner.
312pub 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        // With no incumbent lock there is nothing manager-specific to
374        // preserve, so the default remains the embedded Aube engine.
375        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}