Skip to main content

aube_lockfile/
lib.rs

1pub mod bun;
2pub mod dep_path_filename;
3pub mod graph_hash;
4pub mod merge;
5pub mod npm;
6pub mod pnpm;
7pub mod yarn;
8
9pub use merge::{MergeReport, merge_branch_lockfiles};
10
11use smallvec::SmallVec;
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14
15/// Most npm packages declare zero or one entry in `os`, `cpu`,
16/// `libc`. Two inline `SmallVec` slots cover empty on construction
17/// (zero heap alloc) and one-entry push (still zero heap) for ~99%
18/// of lockfile entries.
19pub type PlatformList = SmallVec<[String; 2]>;
20
21/// Represents a resolved dependency graph from any lockfile format.
22#[derive(Debug, Clone, Default)]
23pub struct LockfileGraph {
24    /// Direct dependencies of the root project (and workspace packages).
25    /// Key: importer path (e.g., "." for root), Value: list of (name, version) pairs.
26    pub importers: BTreeMap<String, Vec<DirectDep>>,
27    /// All resolved packages.
28    pub packages: BTreeMap<String, LockedPackage>,
29    /// Per-graph settings that round-trip through the lockfile header
30    /// (pnpm v9's `settings:` block). Don't affect graph structure;
31    /// stamped into the YAML when writing and read back when parsing,
32    /// so subsequent installs see the same resolution-mode state.
33    pub settings: LockfileSettings,
34    /// Dependency overrides recorded in pnpm-lock.yaml's top-level
35    /// `overrides:` block. Map of raw selector key → version specifier
36    /// (or `npm:` alias). Keys are the user's verbatim selector
37    /// strings — bare name, `foo>bar`, `foo@<2`, `**/foo`, or any
38    /// combination. Round-tripped so subsequent installs can detect
39    /// override drift on a string-compare of the key+value without
40    /// re-running the resolver. The resolver parses these into
41    /// `override_rule::OverrideRule`s at the start of each resolve
42    /// pass.
43    pub overrides: BTreeMap<String, String>,
44    /// Names listed in the root manifest's `pnpm.ignoredOptionalDependencies`.
45    /// The resolver drops entries in this set from every `optionalDependencies`
46    /// map before enqueueing, matching pnpm's read-package hook. Round-tripped
47    /// through pnpm-lock.yaml's top-level `ignoredOptionalDependencies:` list
48    /// so drift detection can notice when the user edits the field.
49    pub ignored_optional_dependencies: BTreeSet<String>,
50    /// Per-package publish timestamps, keyed by canonical `name@version`
51    /// (no peer suffix). Round-trips through pnpm-lock.yaml's top-level
52    /// `time:` block so `--resolution-mode=time-based` can compute a
53    /// `publishedBy` cutoff from packages already in the lockfile
54    /// without re-fetching packuments.
55    pub times: BTreeMap<String, String>,
56    /// Optional dependencies the resolver intentionally skipped on the
57    /// platform that wrote this lockfile (either filtered by
58    /// `os`/`cpu`/`libc`, or named in
59    /// `pnpm.ignoredOptionalDependencies`). Keyed by importer path,
60    /// inner map is name → specifier captured from `package.json` at
61    /// resolve time.
62    ///
63    /// Drift detection uses this to distinguish "user just added a new
64    /// optional dep" (which is real drift) from "this optional was
65    /// already considered and consciously dropped on this platform"
66    /// (which is *not* drift). Without it, every `--frozen-lockfile`
67    /// install on a platform that skipped a fixture would hard-fail.
68    pub skipped_optional_dependencies: BTreeMap<String, BTreeMap<String, String>>,
69    /// Resolved catalog entries, mirroring pnpm v9's top-level
70    /// `catalogs:` block. Outer key is the catalog name (`default` for
71    /// the unnamed `catalog:` field in `pnpm-workspace.yaml`); inner key
72    /// is the package name. Each entry pairs the original specifier
73    /// from the workspace catalog with the version the resolver chose
74    /// for it. Round-tripped through the lockfile so drift detection
75    /// can fire when a catalog spec changes without re-resolving.
76    pub catalogs: BTreeMap<String, BTreeMap<String, CatalogEntry>>,
77    /// bun's top-level `configVersion` — a second format counter bun
78    /// added alongside `lockfileVersion` to track its own config-
79    /// schema changes. Only the bun parser/writer ever touches this;
80    /// other formats leave it `None`. Round-tripping the parsed
81    /// value keeps the writer from silently downgrading the field
82    /// (e.g. from `2` back to `1`) when bun bumps it in a future
83    /// release.
84    pub bun_config_version: Option<u32>,
85}
86
87/// One entry in a lockfile catalog: the workspace-declared range and the
88/// resolved version. Mirrors pnpm v9's `catalogs:` block exactly.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CatalogEntry {
91    pub specifier: String,
92    pub version: String,
93}
94
95/// Per-graph settings that mirror pnpm v9's `settings:` header.
96/// Extend as more knobs become round-trip-aware.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct LockfileSettings {
99    /// pnpm's `auto-install-peers` — when false the resolver leaves
100    /// unmet peers alone (just warns) instead of dragging them in.
101    pub auto_install_peers: bool,
102    /// pnpm's `exclude-links-from-lockfile` — not yet honored by aube
103    /// but round-tripped for lockfile compatibility.
104    pub exclude_links_from_lockfile: bool,
105    /// pnpm's `lockfile-include-tarball-url` — when true the writer
106    /// emits the full registry tarball URL in each package's
107    /// `resolution.tarball:` field alongside `integrity:`. Makes the
108    /// lockfile self-contained so air-gapped installs don't need to
109    /// derive the URL from `.npmrc`. Round-tripped through the
110    /// `settings:` header so it survives parse/write cycles without
111    /// re-reading `.npmrc`.
112    pub lockfile_include_tarball_url: bool,
113}
114
115impl Default for LockfileSettings {
116    fn default() -> Self {
117        Self {
118            auto_install_peers: true,
119            exclude_links_from_lockfile: false,
120            lockfile_include_tarball_url: false,
121        }
122    }
123}
124
125/// A direct dependency of a workspace importer.
126#[derive(Debug, Clone)]
127pub struct DirectDep {
128    pub name: String,
129    /// The dep_path key in the lockfile (e.g., "is-odd@3.0.1")
130    pub dep_path: String,
131    pub dep_type: DepType,
132    /// The specifier as written in package.json at the time the lockfile was
133    /// generated (e.g., `"^4.17.0"`). Used by drift detection to compare against
134    /// the current manifest. Only populated by formats that record it
135    /// (pnpm-lock.yaml v9). `None` for npm/yarn/bun lockfiles.
136    pub specifier: Option<String>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum DepType {
141    Production,
142    Dev,
143    Optional,
144}
145
146/// Non-registry source for a locked package.
147///
148/// When a package comes from a local path (via `file:` or `link:` in
149/// `package.json`) it doesn't have a tarball URL or integrity hash, so we
150/// record the source separately and let the linker materialize it
151/// on-the-fly.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum LocalSource {
154    /// `file:<dir>` — a directory on disk whose contents should be
155    /// hardlink-copied into the virtual store like a normal package.
156    /// Path is stored relative to the project root.
157    Directory(PathBuf),
158    /// `file:<tarball>` — a `.tgz` on disk, extracted into the virtual
159    /// store the same way we extract registry tarballs.
160    Tarball(PathBuf),
161    /// `link:<dir>` — a plain symlink into `node_modules/<name>`, never
162    /// materialized into the virtual store. Transitive deps are the
163    /// target's responsibility.
164    Link(PathBuf),
165    /// `git+https://`, `git+ssh://`, `github:user/repo`, etc. — a
166    /// remote git repo. Cloned at fetch time and imported like a
167    /// `file:` directory. `url` is the normalized clone URL (what
168    /// gets passed to `git clone`). `committish` is the user-written
169    /// ref after `#` (branch, tag, or commit; `None` means HEAD).
170    /// `resolved` is the 40-char commit SHA that `git ls-remote`
171    /// pinned the ref to — the lockfile records this so repeat
172    /// installs reproduce bit-for-bit.
173    Git(GitSource),
174    /// `https://example.com/pkg.tgz` — a remote tarball URL. Fetched
175    /// once at resolve time so the resolver can read the enclosed
176    /// `package.json` for version + transitive deps and pin the
177    /// sha512 integrity. `integrity` stays empty on freshly-parsed
178    /// specifiers and is filled in by the resolver after download.
179    RemoteTarball(RemoteTarballSource),
180}
181
182/// A remote tarball dependency spec. See [`LocalSource::RemoteTarball`].
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct RemoteTarballSource {
185    pub url: String,
186    pub integrity: String,
187}
188
189/// A git dependency spec. See [`LocalSource::Git`].
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct GitSource {
192    pub url: String,
193    pub committish: Option<String>,
194    pub resolved: String,
195}
196
197impl LocalSource {
198    /// The original path (relative to the project root) the user wrote
199    /// in `package.json`. `None` for non-path sources like git.
200    pub fn path(&self) -> Option<&Path> {
201        match self {
202            LocalSource::Directory(p) | LocalSource::Tarball(p) | LocalSource::Link(p) => Some(p),
203            LocalSource::Git(_) | LocalSource::RemoteTarball(_) => None,
204        }
205    }
206
207    /// The protocol kind (`"file"` / `"link"` / `"git"` / `"url"`).
208    pub fn kind_str(&self) -> &'static str {
209        match self {
210            LocalSource::Directory(_) | LocalSource::Tarball(_) => "file",
211            LocalSource::Link(_) => "link",
212            LocalSource::Git(_) => "git",
213            LocalSource::RemoteTarball(_) => "url",
214        }
215    }
216
217    /// The path as a POSIX-style string with forward-slash separators.
218    /// `Path::display()` and `to_string_lossy()` honor the host's
219    /// separator (backslash on Windows), which would make `dep_path`
220    /// hashes and lockfile `specifier:` strings non-portable: the
221    /// same `file:./some/dir` would render as `some\dir` on Windows
222    /// and `some/dir` on Unix, producing two different hashes for
223    /// the same logical target. Always rendering with `/` keeps
224    /// lockfiles cross-platform identical.
225    pub fn path_posix(&self) -> String {
226        self.path()
227            .map(|p| p.to_string_lossy().replace('\\', "/"))
228            .unwrap_or_default()
229    }
230
231    /// Canonical specifier string as pnpm writes it in the `packages:`
232    /// and `snapshots:` keys (post-`<name>@` part). For `file:` /
233    /// `link:` this is `file:./vendor/foo` / `link:../sibling`. For
234    /// `git`, pnpm uses the resolved form `<url>#<commit>` (no
235    /// `git+` prefix) because the lockfile pins to the exact commit
236    /// regardless of what the user wrote. Always emits POSIX
237    /// separators so the resulting lockfile is portable.
238    pub fn specifier(&self) -> String {
239        match self {
240            LocalSource::Git(g) => format!("{}#{}", g.url, g.resolved),
241            LocalSource::RemoteTarball(t) => t.url.clone(),
242            _ => format!("{}:{}", self.kind_str(), self.path_posix()),
243        }
244    }
245
246    /// Internal FS-safe dep_path used as the key in
247    /// `LockfileGraph.packages` and as the `.aube/` subdir name.
248    ///
249    /// Distinct paths must map to distinct keys (otherwise the
250    /// linker would silently mix files between two local packages),
251    /// and the result must be a single filesystem component — no
252    /// `/`, `\`, `:`, or `..`. Ad-hoc character substitution trips
253    /// over cases like `../vendor` vs `__/vendor` or `a.b` vs `a_b`
254    /// collapsing to the same string, so we hash the raw path bytes
255    /// and suffix the first 16 hex chars (64 bits — more than enough
256    /// to avoid collisions inside a single project).
257    ///
258    /// The hash input is the POSIX-form path string so a checked-in
259    /// lockfile resolves to the same key regardless of which
260    /// platform ran `aube install`.
261    pub fn dep_path(&self, name: &str) -> String {
262        use sha2::{Digest, Sha256};
263        let mut hasher = Sha256::new();
264        match self {
265            LocalSource::Git(g) => {
266                hasher.update(g.url.as_bytes());
267                hasher.update(b"#");
268                hasher.update(g.resolved.as_bytes());
269            }
270            LocalSource::RemoteTarball(t) => {
271                hasher.update(t.url.as_bytes());
272            }
273            _ => hasher.update(self.path_posix().as_bytes()),
274        }
275        let digest = hasher.finalize();
276        let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
277        format!("{name}@{}+{short}", self.kind_str())
278    }
279
280    /// Classify a user-written `file:` / `link:` specifier against the
281    /// project root. Returns `None` if `spec` isn't a local specifier.
282    /// Resolves the target path relative to `project_root`; a `file:`
283    /// target that resolves to a `.tgz` / `.tar.gz` on disk is treated
284    /// as a tarball, anything else as a directory.
285    pub fn parse(spec: &str, project_root: &Path) -> Option<Self> {
286        // Check git first so URLs like `https://host/user/repo.git`
287        // aren't swallowed by the broader bare-http tarball check
288        // below.
289        if let Some((url, committish)) = parse_git_spec(spec) {
290            // `resolved` is filled in by the resolver after running
291            // `git ls-remote`. A lockfile round-trip that never
292            // re-resolves will leave this empty, which is the sentinel
293            // the resolver checks for before calling ls-remote.
294            return Some(LocalSource::Git(GitSource {
295                url,
296                committish,
297                resolved: String::new(),
298            }));
299        }
300        // Any remaining bare `http(s)://` URL is a remote tarball.
301        // npm semantics treat *all* non-git HTTP URLs in a dependency
302        // value as tarball URLs, so services that serve tarballs from
303        // URLs without a `.tgz` extension (pkg.pr.new, GitHub
304        // codeload, etc.) classify correctly here.
305        if Self::looks_like_remote_tarball_url(spec) {
306            return Some(LocalSource::RemoteTarball(RemoteTarballSource {
307                url: spec.to_string(),
308                integrity: String::new(),
309            }));
310        }
311        let (kind, rest) = if let Some(r) = spec.strip_prefix("file:") {
312            ("file", r)
313        } else if let Some(r) = spec.strip_prefix("link:") {
314            ("link", r)
315        } else {
316            return None;
317        };
318        let rel = PathBuf::from(rest);
319        let abs = project_root.join(&rel);
320        if kind == "link" {
321            return Some(LocalSource::Link(rel));
322        }
323        if abs.is_file() && Self::path_looks_like_tarball(&rel) {
324            return Some(LocalSource::Tarball(rel));
325        }
326        Some(LocalSource::Directory(rel))
327    }
328
329    /// Whether a specifier looks like a direct HTTP(S) URL that should
330    /// be fetched as a tarball. Per npm semantics, *any* `http://` or
331    /// `https://` URL in a dependency value is a tarball URL — services
332    /// like pkg.pr.new, GitHub codeload, and private registries with
333    /// auth-token query strings serve tarballs from URLs that don't
334    /// carry a `.tgz` extension. Git URLs must already have been
335    /// ruled out by the caller (see [`parse_git_spec`]) so a
336    /// `.git`-suffixed URL doesn't get misclassified here.
337    pub fn looks_like_remote_tarball_url(spec: &str) -> bool {
338        spec.starts_with("https://") || spec.starts_with("http://")
339    }
340
341    pub fn path_looks_like_tarball(path: &Path) -> bool {
342        let name = match path.file_name().and_then(|n| n.to_str()) {
343            Some(n) => n,
344            None => return false,
345        };
346        let lower = name.to_ascii_lowercase();
347        lower.ends_with(".tgz") || lower.ends_with(".tar.gz")
348    }
349}
350
351/// Parse a git dependency specifier into `(clone_url, committish)`.
352///
353/// Recognized forms:
354/// - `git+https://host/user/repo.git[#ref]`
355/// - `git+ssh://git@host/user/repo.git[#ref]`
356/// - `git://host/user/repo.git[#ref]`
357/// - `https://host/user/repo.git[#ref]` (only when ending in `.git`)
358/// - `github:user/repo[#ref]` → `https://github.com/user/repo.git`
359/// - `gitlab:user/repo[#ref]` → `https://gitlab.com/user/repo.git`
360/// - `bitbucket:user/repo[#ref]` → `https://bitbucket.org/user/repo.git`
361///
362/// Returns `None` for any specifier that doesn't look like a git URL,
363/// so the caller can fall through to other protocol parsers.
364pub fn parse_git_spec(spec: &str) -> Option<(String, Option<String>)> {
365    let (body, committish) = match spec.find('#') {
366        Some(idx) => (&spec[..idx], normalize_git_fragment(&spec[idx + 1..])),
367        None => (spec, None),
368    };
369    let is_bare_transport = body.starts_with("https://")
370        || body.starts_with("http://")
371        || body.starts_with("ssh://")
372        || body.starts_with("file://");
373    let url = if let Some(rest) = body.strip_prefix("git+") {
374        // `git+` explicitly tags the URL as git, so the `.git`
375        // suffix is optional (GitHub/GitLab accept both forms).
376        rest.to_string()
377    } else if body.starts_with("git://") {
378        body.to_string()
379    } else if let Some(path) = body.strip_prefix("github:") {
380        format!("https://github.com/{path}.git")
381    } else if let Some(path) = body.strip_prefix("gitlab:") {
382        format!("https://gitlab.com/{path}.git")
383    } else if let Some(path) = body.strip_prefix("bitbucket:") {
384        format!("https://bitbucket.org/{path}.git")
385    } else if is_bare_transport && body.ends_with(".git") {
386        body.to_string()
387    } else if is_bare_transport
388        && committish
389            .as_deref()
390            .is_some_and(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()))
391    {
392        // Lockfile round-trip form: `specifier()` writes the stored
393        // URL verbatim plus `#<sha>`. URLs that dropped the `git+`
394        // prefix (and happen to lack `.git`) are disambiguated from
395        // plain tarball URLs by the 40-hex committish suffix.
396        body.to_string()
397    } else {
398        return None;
399    };
400    Some((url, committish))
401}
402
403/// Normalize git URL fragments used by npm-compatible lockfiles.
404///
405/// Plain git accepts `#<ref>`, while npm and Yarn Berry also write
406/// key/value fragments such as `#commit=<sha>` for pinned git deps.
407/// Downstream code passes this value directly to `git ls-remote` and
408/// `git checkout`, so strip the selector key here and keep only the
409/// actual ref name or SHA.
410pub(crate) fn normalize_git_fragment(fragment: &str) -> Option<String> {
411    if fragment.is_empty() {
412        return None;
413    }
414
415    let mut fallback: Option<&str> = None;
416    let mut preferred: Option<&str> = None;
417    for part in fragment.split('&') {
418        if part.is_empty() {
419            continue;
420        }
421        let (key, value) = part.split_once('=').unwrap_or(("", part));
422        if value.is_empty() {
423            continue;
424        }
425        match key {
426            "commit" => {
427                preferred = Some(value);
428                break;
429            }
430            "tag" | "head" | "branch" | "" => {
431                fallback.get_or_insert(value);
432            }
433            _ => {}
434        }
435    }
436
437    preferred.or(fallback).map(ToString::to_string)
438}
439
440/// A single resolved package in the lockfile.
441///
442/// The `dependencies` map keys are dep names and values are the dependency's
443/// dep_path *tail* — i.e. the string that follows `<name>@`. For a plain
444/// package this is just the version (`"4.17.21"`); for a package with its
445/// own peer context it includes the suffix (`"18.2.0(prop-types@15.8.1)"`).
446/// Combining the key with its value reproduces the full dep_path (which is
447/// also the key in `LockfileGraph.packages`).
448#[derive(Debug, Clone, Default)]
449pub struct LockedPackage {
450    /// Package name (e.g., "lodash")
451    pub name: String,
452    /// Exact resolved version (e.g., "4.17.21")
453    pub version: String,
454    /// Integrity hash (e.g., "sha512-...")
455    pub integrity: Option<String>,
456    /// Dependencies of this package (name -> dep_path tail, see struct docs)
457    pub dependencies: BTreeMap<String, String>,
458    /// Optional dependency edges for this package. Active optional edges are
459    /// also mirrored in `dependencies` so graph walks and the linker continue
460    /// to see them; this separate map lets platform filtering prune optional
461    /// edges without touching regular dependencies.
462    pub optional_dependencies: BTreeMap<String, String>,
463    /// Peer dependency ranges as *declared* by the package (from its
464    /// package.json / packument). These are the constraints; the resolved
465    /// versions live in `dependencies` after the peer-context pass runs.
466    pub peer_dependencies: BTreeMap<String, String>,
467    /// `peerDependenciesMeta` entries, keyed by peer name.
468    pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
469    /// The dep_path key used in the lockfile. For packages with resolved
470    /// peer contexts this includes the suffix, e.g.
471    /// `"styled-components@6.1.0(react@18.2.0)"`.
472    pub dep_path: String,
473    /// Set for non-registry packages (those installed via `file:` or
474    /// `link:` specifiers). `None` for the common case of a package
475    /// resolved from an npm registry, where `integrity` is the full
476    /// record of where the bits came from.
477    pub local_source: Option<LocalSource>,
478    /// `os` / `cpu` / `libc` arrays from the package's manifest. Used
479    /// by the resolver to filter optional deps that can't run on the
480    /// current (or user-overridden) platform. Empty arrays mean no
481    /// constraint.
482    pub os: PlatformList,
483    pub cpu: PlatformList,
484    pub libc: PlatformList,
485    /// Names declared in the package's own `bundledDependencies`. These
486    /// ship inside the parent tarball's `node_modules/`, so the resolver
487    /// neither fetches nor recurses into them, and the linker avoids
488    /// creating sibling symlinks that would shadow the bundled tree.
489    /// An empty Vec means "no bundled deps"; `None` is kept as a
490    /// distinct value only inside the resolver and collapsed to empty
491    /// here because the lockfile round-trip doesn't need to preserve
492    /// the "unset" vs "empty list" distinction.
493    pub bundled_dependencies: Vec<String>,
494    /// Full registry tarball URL for registry-sourced packages. Only
495    /// populated when `LockfileSettings::lockfile_include_tarball_url`
496    /// is active on this graph; otherwise `None` and the lockfile
497    /// writer derives the URL at fetch time from the configured
498    /// registry. `local_source`-backed packages (file:, link:, git:,
499    /// remote tarball) already carry their own URL via `LocalSource`
500    /// and don't populate this field.
501    pub tarball_url: Option<String>,
502    /// For npm-alias deps (`"h3-v2": "npm:h3@2.0.1-rc.20"`): the real
503    /// package name on the registry (`"h3"`). `None` means the entry
504    /// is not aliased and `name` already holds the registry name.
505    ///
506    /// Install semantics when `Some(real)`:
507    /// - `name` is the *alias* — that's the folder under `node_modules/`,
508    ///   the symlink name for transitive deps, and the key every package
509    ///   that declares this dep refers to.
510    /// - `alias_of` is the real package name used for tarball URL lookup,
511    ///   store index keying, and packument fetches.
512    /// - `version` is the real resolved version.
513    ///
514    /// `registry_name()` returns the right name for registry IO; every
515    /// call site that talks to the registry or the CAS uses that helper.
516    pub alias_of: Option<String>,
517    /// Yarn berry's `checksum:` field, preserved verbatim when parsing a
518    /// yarn 2+ lockfile (e.g. `"10c0/<blake2b-hex>"`). The format is
519    /// yarn-specific — it uses a yarn-chosen hash family prefixed with
520    /// the `cacheKey` that produced it — and doesn't share a hash
521    /// algorithm with `integrity` (sha-512). When re-emitting a yarn
522    /// berry lockfile we write this field back as-is; packages that
523    /// didn't come through a berry parse (e.g. freshly-resolved entries
524    /// in a new install) leave this `None` and the writer omits the
525    /// `checksum:` field, which berry tolerates at the default
526    /// `checksumBehavior: throw` when the cache is fresh.
527    pub yarn_checksum: Option<String>,
528    /// `engines:` from the package's manifest, round-tripped through
529    /// the lockfile so pnpm-style writers can emit the same flow-form
530    /// `engines: {node: '>=8'}` line pnpm writes. Empty map means
531    /// "no engines declared" — the writer skips the field entirely.
532    pub engines: BTreeMap<String, String>,
533    /// `bin:` map from the package's manifest, normalized to
534    /// `name → path`. An empty map means "no bins declared".
535    ///
536    /// pnpm-style writers derive `hasBin: true` from
537    /// `!bin.is_empty()` (they don't preserve the names/paths); bun's
538    /// format emits the full map on the package's meta block. Keeping
539    /// the map here lets both writers render byte-identical output
540    /// without an extra tarball-level re-parse.
541    pub bin: BTreeMap<String, String>,
542    /// Dependency ranges as declared in this package's own
543    /// `package.json` — keyed by dep name, values are the raw
544    /// specifiers (`"^4.1.0"`, `"~1.1.4"`, `"workspace:*"`, …).
545    ///
546    /// Distinct from [`Self::dependencies`], which stores the
547    /// *resolved* dep_path tail (`"4.3.0"`). npm / yarn / bun
548    /// lockfiles preserve the declared ranges on every nested
549    /// package entry — rewriting them to the resolved pins is the
550    /// biggest source of round-trip churn against those formats. This
551    /// map lets writers emit the declared range when available and
552    /// fall back to the resolved pin otherwise (e.g. when the source
553    /// lockfile was pnpm, whose `snapshots:` only carries pins).
554    ///
555    /// Empty means "unknown" — writers should fall back to pins.
556    /// Covers production *and* optional dependencies in one map since
557    /// a package can't declare the same name twice across those
558    /// sections.
559    pub declared_dependencies: BTreeMap<String, String>,
560    /// Package's `license` field, collapsed to the simple string
561    /// form. Round-tripped so npm's lockfile keeps its per-entry
562    /// `"license": "MIT"` line; pnpm / yarn / bun don't record
563    /// licenses and leave this `None` on parse.
564    pub license: Option<String>,
565    /// Package's funding URL, extracted from whatever shape the
566    /// manifest's `funding:` field took (string / object / array).
567    /// Round-tripped so npm's lockfile keeps its per-entry
568    /// `"funding": {"url": "…"}` block.
569    pub funding_url: Option<String>,
570}
571
572impl LockedPackage {
573    /// The package name to use for registry / store operations — the real
574    /// name behind an npm-alias when aliased, otherwise just `name`. Used
575    /// at every site that derives a tarball URL, a packument URL, or an
576    /// aube-store cache key so aliased entries hit the actual package
577    /// instead of the alias-qualified name.
578    pub fn registry_name(&self) -> &str {
579        self.alias_of.as_deref().unwrap_or(&self.name)
580    }
581
582    /// Canonical `"name@version"` key used as a handle in patches,
583    /// approve-builds prompts, lockfile canonical maps, and display
584    /// paths. Not the dep-path — that includes peer-context suffixes.
585    pub fn spec_key(&self) -> String {
586        format!("{}@{}", self.name, self.version)
587    }
588}
589
590/// Metadata about a single declared peer dependency. Matches the shape of
591/// `peerDependenciesMeta` in package.json.
592#[derive(Debug, Clone, Default, PartialEq, Eq)]
593pub struct PeerDepMeta {
594    /// When true, an unmet peer is silently allowed rather than warned about.
595    pub optional: bool,
596}
597
598/// Which source lockfile format was parsed.
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum LockfileKind {
601    /// `aube-lock.yaml` — aube's default lockfile when no existing
602    /// lockfile is present. Same on-disk format as pnpm v9 for now
603    /// (we piggyback on pnpm::read/write).
604    Aube,
605    /// `pnpm-lock.yaml` — pnpm v9 format. If this is the existing
606    /// project lockfile, aube reads and writes it in place.
607    Pnpm,
608    Npm,
609    /// `yarn.lock` v1 (classic yarn). Line-based text format with
610    /// 2-space indented fields.
611    Yarn,
612    /// `yarn.lock` v2+ (yarn berry). YAML format with `__metadata:`
613    /// header, `resolution:` / `checksum:` fields, and
614    /// `languageName` / `linkType`. Same filename as `Yarn`; detection
615    /// peeks at the content for the `__metadata:` marker to pick
616    /// between the two.
617    YarnBerry,
618    NpmShrinkwrap,
619    Bun,
620}
621
622impl LockfileKind {
623    pub fn filename(self) -> &'static str {
624        match self {
625            LockfileKind::Aube => "aube-lock.yaml",
626            LockfileKind::Pnpm => "pnpm-lock.yaml",
627            LockfileKind::Npm => "package-lock.json",
628            LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
629            LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
630            LockfileKind::Bun => "bun.lock",
631        }
632    }
633}
634
635impl LockfileGraph {
636    /// Get all direct dependencies of the root project.
637    pub fn root_deps(&self) -> &[DirectDep] {
638        self.importers.get(".").map(|v| v.as_slice()).unwrap_or(&[])
639    }
640
641    /// Get a package by its dep_path key.
642    pub fn get_package(&self, dep_path: &str) -> Option<&LockedPackage> {
643        self.packages.get(dep_path)
644    }
645
646    /// `true` when at least one package in the graph carries a
647    /// non-empty `bin` map — a cheap signal that the source (lockfile
648    /// parser or fresh resolve) populated bin metadata. Bin-linking
649    /// passes use this to short-circuit the `package.json` read on
650    /// packages whose `bin` is empty (95%+ of a typical graph).
651    ///
652    /// The pnpm, bun, npm, and aube parsers all fill `bin`; a fresh
653    /// resolve fills it from packument data. The yarn-classic parser
654    /// leaves it empty, so a graph loaded exclusively from `yarn.lock`
655    /// returns `false` here and bin linking falls back to the full
656    /// `package.json` read. That's a correctness-over-speed choice:
657    /// misreading "empty" as "no bin" on yarn would silently drop
658    /// executables from `node_modules/.bin/`.
659    pub fn has_bin_metadata(&self) -> bool {
660        self.packages.values().any(|p| !p.bin.is_empty())
661    }
662
663    /// BFS the transitive closure of `roots` through `self.packages`,
664    /// returning every reachable dep_path (roots included). Missing
665    /// roots are skipped silently — a root without a matching package
666    /// is treated as a leaf, which matches what `filter_deps` /
667    /// `subset_to_importer` need when a retained importer points at a
668    /// package that was never fully installed (e.g. optional deps
669    /// filtered out on this platform).
670    ///
671    /// `LockedPackage.dependencies` maps `child_name → dep_path tail`,
672    /// so each child's full key reconstructs as `{child_name}@{tail}`.
673    fn transitive_closure<'a>(
674        &self,
675        roots: impl IntoIterator<Item = &'a str>,
676    ) -> std::collections::HashSet<String> {
677        let mut reachable: std::collections::HashSet<String> = std::collections::HashSet::new();
678        let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
679        for root in roots {
680            if reachable.insert(root.to_string()) {
681                queue.push_back(root.to_string());
682            }
683        }
684        while let Some(dep_path) = queue.pop_front() {
685            let Some(pkg) = self.packages.get(&dep_path) else {
686                continue;
687            };
688            for (child_name, child_version) in &pkg.dependencies {
689                let child_key = format!("{child_name}@{child_version}");
690                if reachable.insert(child_key.clone()) {
691                    queue.push_back(child_key);
692                }
693            }
694        }
695        reachable
696    }
697
698    /// Clone only the `packages` entries whose keys are in `reachable`.
699    /// Paired with `transitive_closure` to produce the pruned
700    /// `LockfileGraph.packages` for `filter_deps` / `subset_to_importer`.
701    fn packages_restricted_to(
702        &self,
703        reachable: &std::collections::HashSet<String>,
704    ) -> BTreeMap<String, LockedPackage> {
705        self.packages
706            .iter()
707            .filter(|(dep_path, _)| reachable.contains(*dep_path))
708            .map(|(k, v)| (k.clone(), v.clone()))
709            .collect()
710    }
711
712    /// Produce a new `LockfileGraph` containing only the direct deps that match
713    /// `keep` and the transitive deps reachable from them.
714    ///
715    /// Used by `install --prod` to drop `DepType::Dev` roots and everything
716    /// only reachable through them, and by `install --no-optional` for optional
717    /// deps. The filter runs over every importer's direct-dep list, so workspace
718    /// projects behave correctly.
719    ///
720    /// Packages that are reachable from a retained root through a transitive
721    /// chain are kept even if a pruned dev dep also happened to depend on them —
722    /// the check is "is this package reachable from any retained root?", not
723    /// "was this package introduced by a retained root?".
724    pub fn filter_deps<F>(&self, keep: F) -> LockfileGraph
725    where
726        F: Fn(&DirectDep) -> bool,
727    {
728        // Filter each importer's DirectDep list.
729        let importers: BTreeMap<String, Vec<DirectDep>> = self
730            .importers
731            .iter()
732            .map(|(path, deps)| {
733                let filtered: Vec<DirectDep> = deps.iter().filter(|d| keep(d)).cloned().collect();
734                (path.clone(), filtered)
735            })
736            .collect();
737
738        // BFS from every retained root across every importer.
739        let reachable = self.transitive_closure(
740            importers
741                .values()
742                .flat_map(|deps| deps.iter().map(|d| d.dep_path.as_str())),
743        );
744        let packages = self.packages_restricted_to(&reachable);
745
746        LockfileGraph {
747            importers,
748            packages,
749            // Preserve the source graph's settings — filter is a
750            // structural operation, not a resolution-mode reset.
751            // Writing the filtered graph (e.g. from `aube prune`) must
752            // emit the same `settings:` header the user chose.
753            settings: self.settings.clone(),
754            // Overrides are part of the user's resolution intent and
755            // should survive structural filters like `aube prune`.
756            overrides: self.overrides.clone(),
757            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
758            // Times follow the same round-trip invariant as settings:
759            // filter doesn't change what versions are locked, so the
760            // per-package publish timestamps carry through unchanged.
761            times: self.times.clone(),
762            skipped_optional_dependencies: self.skipped_optional_dependencies.clone(),
763            catalogs: self.catalogs.clone(),
764            bun_config_version: self.bun_config_version,
765        }
766    }
767
768    /// Produce a new `LockfileGraph` rooted at the importer at
769    /// `importer_path`, with its transitive closure preserved and every
770    /// other importer dropped. The retained importer is remapped to
771    /// `"."` because the consumer installs the result as a standalone
772    /// project.
773    ///
774    /// Used by `aube deploy`: reading the source workspace lockfile
775    /// and subsetting it to the deployed package lets a frozen install
776    /// in the target reproduce the workspace's exact versions without
777    /// re-resolving against the registry. `keep` filters the importer's
778    /// direct deps the same way `filter_deps` does, so `--prod` /
779    /// `--dev` / `--no-optional` deploys drop the matching roots.
780    ///
781    /// Returns `None` if `importer_path` is not present in
782    /// `self.importers`. Graph-wide metadata (`settings`, `overrides`,
783    /// `times`, `catalogs`, `ignored_optional_dependencies`) is copied
784    /// verbatim — structural pruning, not a resolution-mode reset.
785    /// Callers targeting a non-workspace install may want to clear
786    /// workspace-scope fields that would otherwise trigger drift
787    /// detection against a rewritten target manifest.
788    pub fn subset_to_importer<F>(&self, importer_path: &str, keep: F) -> Option<LockfileGraph>
789    where
790        F: Fn(&DirectDep) -> bool,
791    {
792        let src_deps = self.importers.get(importer_path)?;
793        let kept: Vec<DirectDep> = src_deps.iter().filter(|d| keep(d)).cloned().collect();
794
795        // BFS the transitive closure from retained roots, scoped to
796        // just this importer's kept direct deps.
797        let reachable = self.transitive_closure(kept.iter().map(|d| d.dep_path.as_str()));
798        let packages = self.packages_restricted_to(&reachable);
799
800        // Per-importer metadata: keep only the retained importer's
801        // entry, rekeyed to `.`. The source workspace's other
802        // importers are meaningless in a target that has exactly one.
803        let mut skipped_optional_dependencies = BTreeMap::new();
804        if let Some(skipped) = self.skipped_optional_dependencies.get(importer_path) {
805            skipped_optional_dependencies.insert(".".to_string(), skipped.clone());
806        }
807
808        let mut importers = BTreeMap::new();
809        importers.insert(".".to_string(), kept);
810
811        Some(LockfileGraph {
812            importers,
813            packages,
814            settings: self.settings.clone(),
815            overrides: self.overrides.clone(),
816            ignored_optional_dependencies: self.ignored_optional_dependencies.clone(),
817            times: self.times.clone(),
818            skipped_optional_dependencies,
819            catalogs: self.catalogs.clone(),
820            bun_config_version: self.bun_config_version,
821        })
822    }
823
824    /// Overlay per-package metadata fields from `prior` onto `self`
825    /// for every `(name, version)` that survives in both graphs.
826    /// Carries forward only fields the abbreviated packument (npm
827    /// corgi) doesn't ship — `license`, `funding_url`, and the
828    /// bun-format `configVersion` — so a fresh re-resolve against
829    /// the same spec set doesn't lose them.
830    ///
831    /// Keyed by canonical `name@version`, so a peer-context rewrite
832    /// between the old and new graph still lines up. `self`'s own
833    /// values win when set (fresh registry data is authoritative);
834    /// `prior`'s fill in only the `None` / empty slots. Safe to call
835    /// on any pair of graphs — parsing the old lockfile is the
836    /// caller's concern.
837    pub fn overlay_metadata_from(&mut self, prior: &LockfileGraph) {
838        // Build a canonical `name@version → prior pkg` lookup once so
839        // repeated peer-context variants in `self.packages` all hit
840        // the same prior entry.
841        let prior_index = build_canonical_map(prior);
842        for pkg in self.packages.values_mut() {
843            let key = pkg.spec_key();
844            let Some(prior_pkg) = prior_index.get(&key) else {
845                continue;
846            };
847            if pkg.license.is_none() && prior_pkg.license.is_some() {
848                pkg.license = prior_pkg.license.clone();
849            }
850            if pkg.funding_url.is_none() && prior_pkg.funding_url.is_some() {
851                pkg.funding_url = prior_pkg.funding_url.clone();
852            }
853        }
854        if self.bun_config_version.is_none() {
855            self.bun_config_version = prior.bun_config_version;
856        }
857    }
858
859    /// Compare this lockfile's root importer against a single manifest.
860    ///
861    /// Mirrors pnpm's `prefer-frozen-lockfile` check: a lockfile is "fresh" iff
862    /// every direct dep specifier in `package.json` exactly matches the specifier
863    /// recorded in the lockfile (string compare, not semver). Used to decide
864    /// whether to skip resolution and trust the lockfile (`Fresh`) or fall back
865    /// to a full re-resolve (`Stale { reason }`).
866    ///
867    /// For workspace projects, use [`check_drift_workspace`] instead — this
868    /// method only inspects the root importer.
869    ///
870    /// `workspace_overrides` is the `overrides:` block from
871    /// `pnpm-workspace.yaml` (pnpm v10 moved overrides there). Pass an
872    /// empty map when the project has no workspace-yaml overrides. Keys
873    /// are merged on top of `manifest.overrides_map()` before the drift
874    /// comparison, matching the resolver's effective-override set —
875    /// otherwise a lockfile written with a workspace override
876    /// immediately looks stale on the next `--frozen-lockfile` run.
877    ///
878    /// `workspace_ignored_optional` is the same idea for
879    /// `pnpm-workspace.yaml`'s `ignoredOptionalDependencies` block:
880    /// the resolver unions it with the manifest's list, so the drift
881    /// check has to see the same union or a freshly-written lockfile
882    /// immediately reads as stale.
883    ///
884    /// Lockfile formats that don't record specifiers (npm, yarn, bun) always
885    /// return `Fresh` since we have no way to detect drift without re-resolving.
886    ///
887    /// [`check_drift_workspace`]: Self::check_drift_workspace
888    pub fn check_drift(
889        &self,
890        manifest: &aube_manifest::PackageJson,
891        workspace_overrides: &BTreeMap<String, String>,
892        workspace_ignored_optional: &[String],
893    ) -> DriftStatus {
894        let effective = merge_manifest_and_workspace_overrides(manifest, workspace_overrides);
895        if let Some(reason) = overrides_drift_reason(&self.overrides, &effective) {
896            return DriftStatus::Stale { reason };
897        }
898        let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies();
899        effective_ignored.extend(workspace_ignored_optional.iter().cloned());
900        if let Some(reason) =
901            ignored_optional_drift_reason(&self.ignored_optional_dependencies, &effective_ignored)
902        {
903            return DriftStatus::Stale { reason };
904        }
905        self.check_drift_for_importer(".", manifest)
906    }
907
908    /// Workspace-aware drift check.
909    ///
910    /// Each entry in `manifests` is `(importer_path, manifest)` — for example
911    /// `(".", root_manifest), ("packages/app", app_manifest), ...`. Every
912    /// importer is checked against its own manifest; the first stale importer
913    /// determines the result.
914    ///
915    /// See [`check_drift`] for the `workspace_overrides` contract.
916    ///
917    /// [`check_drift`]: Self::check_drift
918    pub fn check_drift_workspace(
919        &self,
920        manifests: &[(String, aube_manifest::PackageJson)],
921        workspace_overrides: &BTreeMap<String, String>,
922        workspace_ignored_optional: &[String],
923    ) -> DriftStatus {
924        // Override drift is checked once at the workspace level, against
925        // the root manifest. Workspace-package manifests may declare
926        // their own `overrides` blocks but pnpm only honors the root's,
927        // so we mirror that here.
928        if let Some((_, root_manifest)) = manifests.iter().find(|(p, _)| p == ".") {
929            let effective =
930                merge_manifest_and_workspace_overrides(root_manifest, workspace_overrides);
931            if let Some(reason) = overrides_drift_reason(&self.overrides, &effective) {
932                return DriftStatus::Stale { reason };
933            }
934            let mut effective_ignored = root_manifest.pnpm_ignored_optional_dependencies();
935            effective_ignored.extend(workspace_ignored_optional.iter().cloned());
936            if let Some(reason) = ignored_optional_drift_reason(
937                &self.ignored_optional_dependencies,
938                &effective_ignored,
939            ) {
940                return DriftStatus::Stale { reason };
941            }
942        }
943        for (importer_path, manifest) in manifests {
944            match self.check_drift_for_importer(importer_path, manifest) {
945                DriftStatus::Fresh => continue,
946                stale => return stale,
947            }
948        }
949        DriftStatus::Fresh
950    }
951
952    /// Compare this lockfile's catalog snapshot against the current
953    /// `pnpm-workspace.yaml` catalogs.
954    ///
955    /// pnpm only writes catalog entries that at least one importer
956    /// references — unused entries are absent from the lockfile. So
957    /// "missing from lockfile" doesn't mean "added by the user", it
958    /// means "declared but unreferenced", which is not drift. The
959    /// transition from unused → used is caught by the importer-level
960    /// drift check, since a fresh `catalog:` reference shows up as a
961    /// new dep in some `package.json`.
962    ///
963    /// We fire on two cases only:
964    /// - the spec changed for an entry the lockfile already records
965    ///   (the entry is in use, and re-resolution must rerun);
966    /// - the workspace removed an entry that the lockfile records
967    ///   (the importer using `catalog:` now points at nothing).
968    ///
969    /// Resolved versions are deliberately not part of the comparison —
970    /// the version is an *output* of resolution, so a stale lockfile
971    /// version is what re-resolution is supposed to fix. Drift only
972    /// fires on user intent (the specifier).
973    pub fn check_catalogs_drift(
974        &self,
975        workspace_catalogs: &BTreeMap<String, BTreeMap<String, String>>,
976    ) -> DriftStatus {
977        for (cat_name, cat) in workspace_catalogs {
978            let Some(locked) = self.catalogs.get(cat_name) else {
979                continue;
980            };
981            for (pkg, spec) in cat {
982                if let Some(entry) = locked.get(pkg)
983                    && entry.specifier != *spec
984                {
985                    return DriftStatus::Stale {
986                        reason: format!(
987                            "catalogs.{cat_name}.{pkg}: workspace says {spec}, lockfile says {}",
988                            entry.specifier
989                        ),
990                    };
991                }
992            }
993        }
994        for (cat_name, cat) in &self.catalogs {
995            let workspace_cat = workspace_catalogs.get(cat_name);
996            for pkg in cat.keys() {
997                if workspace_cat.map(|c| c.contains_key(pkg)) != Some(true) {
998                    return DriftStatus::Stale {
999                        reason: format!("catalogs.{cat_name}: workspace removed {pkg}"),
1000                    };
1001                }
1002            }
1003        }
1004        DriftStatus::Fresh
1005    }
1006
1007    /// Compare a single importer's `DirectDep` list against the corresponding
1008    /// `package.json`. Used by both [`check_drift`] and [`check_drift_workspace`].
1009    ///
1010    /// [`check_drift`]: Self::check_drift
1011    /// [`check_drift_workspace`]: Self::check_drift_workspace
1012    fn check_drift_for_importer(
1013        &self,
1014        importer_path: &str,
1015        manifest: &aube_manifest::PackageJson,
1016    ) -> DriftStatus {
1017        let label = if importer_path == "." {
1018            String::new()
1019        } else {
1020            format!("{importer_path}: ")
1021        };
1022
1023        let importer_deps: &[DirectDep] = self
1024            .importers
1025            .get(importer_path)
1026            .map(|v| v.as_slice())
1027            .unwrap_or(&[]);
1028
1029        // Skip the check entirely if no DirectDep has a specifier (non-pnpm format).
1030        if importer_deps.iter().all(|d| d.specifier.is_none()) {
1031            return DriftStatus::Fresh;
1032        }
1033        let lockfile_specs: BTreeMap<&str, &str> = importer_deps
1034            .iter()
1035            .filter_map(|d| d.specifier.as_deref().map(|s| (d.name.as_str(), s)))
1036            .collect();
1037
1038        // Optionals the previous resolve recorded as intentionally
1039        // skipped on this importer's platform — keyed by name, value
1040        // is the specifier captured at that time. Distinct from
1041        // `ignored_optional_dependencies`, which is the user's static
1042        // ignore list; this map captures *runtime* platform skips.
1043        let skipped_optionals: BTreeMap<&str, &str> = self
1044            .skipped_optional_dependencies
1045            .get(importer_path)
1046            .map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
1047            .unwrap_or_default();
1048
1049        // Iterate prod / dev / optional with a flag so the
1050        // skipped-optional exemption only applies to deps that came
1051        // from `optional_dependencies`. Without the flag, moving a
1052        // previously-skipped optional into `dependencies` with the same
1053        // specifier would silently report Fresh and the dep would
1054        // never install as a required dep.
1055        //
1056        // Optionals named in `ignored_optional_dependencies` are
1057        // dropped from the manifest-side scan: the resolver never
1058        // enqueues them, so the lockfile importer never has them
1059        // either, and the loop would otherwise report drift on every
1060        // install. (Their *spec* is still verified separately by the
1061        // round-tripped `ignored_optional_dependencies` block below.)
1062        let ignored = &self.ignored_optional_dependencies;
1063        let manifest_deps = manifest
1064            .dependencies
1065            .iter()
1066            .map(|(k, v)| (k, v, false))
1067            .chain(manifest.dev_dependencies.iter().map(|(k, v)| (k, v, false)))
1068            .chain(
1069                manifest
1070                    .optional_dependencies
1071                    .iter()
1072                    .filter(|(name, _)| !ignored.contains(name.as_str()))
1073                    .map(|(k, v)| (k, v, true)),
1074            );
1075
1076        for (name, spec, is_optional) in manifest_deps {
1077            match lockfile_specs.get(name.as_str()) {
1078                None => {
1079                    // A *missing* optional dep is only "fresh" if the
1080                    // previous resolve recorded it as intentionally
1081                    // skipped (platform mismatch or
1082                    // `pnpm.ignoredOptionalDependencies`) AND the
1083                    // recorded specifier still matches what's in the
1084                    // manifest. A genuinely *new* optional that the
1085                    // resolver has never seen is real drift — without
1086                    // that branch, adding `fsevents` to a fresh manifest
1087                    // would silently never get installed.
1088                    if is_optional && let Some(locked_spec) = skipped_optionals.get(name.as_str()) {
1089                        if *locked_spec == spec {
1090                            continue;
1091                        }
1092                        return DriftStatus::Stale {
1093                            reason: format!(
1094                                "{label}{name}: manifest says {spec}, lockfile (skipped) says {locked_spec}"
1095                            ),
1096                        };
1097                    }
1098                    return DriftStatus::Stale {
1099                        reason: format!("{label}manifest adds {name}@{spec}"),
1100                    };
1101                }
1102                Some(locked_spec) if *locked_spec != spec => {
1103                    return DriftStatus::Stale {
1104                        reason: format!(
1105                            "{label}{name}: manifest says {spec}, lockfile says {locked_spec}"
1106                        ),
1107                    };
1108                }
1109                Some(_) => {}
1110            }
1111        }
1112
1113        // Anything in the lockfile but missing from the manifest is stale
1114        // — UNLESS it was auto-hoisted as a peer by the resolver. pnpm-style
1115        // `auto-install-peers=true` puts peers into the importer's
1116        // `dependencies` without the user having written them in
1117        // `package.json`, so we have to recognize those as derived state
1118        // rather than user intent.
1119        //
1120        // Critically, we identify an auto-hoisted entry by matching its
1121        // *recorded specifier* against peer ranges declared in the graph,
1122        // not just by name. A name-only check would silently exempt a
1123        // user-pinned `react` that the user later removed (if any package
1124        // anywhere in the graph peer-declares react, the name match would
1125        // fire and we'd report Fresh forever — defeating the drift check).
1126        //
1127        // The rule: a lockfile entry whose (name, specifier) pair exactly
1128        // matches some package's declared (peer_name, peer_range) is
1129        // auto-hoisted. If the user had pinned react with a different
1130        // specifier string and then removed it, the (name, specifier)
1131        // pair no longer matches any peer range, and drift correctly
1132        // fires so the resolver re-runs and rewrites the lockfile.
1133        let manifest_names: std::collections::HashSet<&str> = manifest
1134            .dependencies
1135            .keys()
1136            .chain(manifest.dev_dependencies.keys())
1137            .chain(
1138                manifest
1139                    .optional_dependencies
1140                    .keys()
1141                    .filter(|name| !ignored.contains(name.as_str())),
1142            )
1143            .map(|s| s.as_str())
1144            .collect();
1145        let auto_hoisted_peer_specs: std::collections::HashSet<(&str, &str)> = self
1146            .packages
1147            .values()
1148            .flat_map(|p| {
1149                p.peer_dependencies
1150                    .iter()
1151                    .map(|(name, range)| (name.as_str(), range.as_str()))
1152            })
1153            .collect();
1154        for (locked_name, locked_spec) in &lockfile_specs {
1155            if manifest_names.contains(locked_name) {
1156                continue;
1157            }
1158            if auto_hoisted_peer_specs.contains(&(*locked_name, *locked_spec)) {
1159                continue;
1160            }
1161            return DriftStatus::Stale {
1162                reason: format!("{label}manifest removed {locked_name}"),
1163            };
1164        }
1165
1166        DriftStatus::Fresh
1167    }
1168}
1169
1170/// Merge `pnpm-workspace.yaml` overrides on top of the manifest's
1171/// `overrides_map()`. Workspace entries win on key conflict, matching
1172/// pnpm v10's behavior where the workspace yaml is the canonical
1173/// home for overrides. Callers pass this into `overrides_drift_reason`
1174/// so the drift check sees the same effective map the resolver used.
1175fn merge_manifest_and_workspace_overrides(
1176    manifest: &aube_manifest::PackageJson,
1177    workspace_overrides: &BTreeMap<String, String>,
1178) -> BTreeMap<String, String> {
1179    let mut out = manifest.overrides_map();
1180    for (k, v) in workspace_overrides {
1181        out.insert(k.clone(), v.clone());
1182    }
1183    out
1184}
1185
1186/// Compare two override maps and return a human-readable reason
1187/// describing the first difference, or `None` if they're identical.
1188/// Drift messages cite the offending key by name so users can act on
1189/// them — `(lockfile: N entries, manifest: M entries)` is useless
1190/// when N == M but a value changed.
1191fn overrides_drift_reason(
1192    lockfile: &BTreeMap<String, String>,
1193    manifest: &BTreeMap<String, String>,
1194) -> Option<String> {
1195    for (k, v) in manifest {
1196        match lockfile.get(k) {
1197            None => return Some(format!("overrides: manifest adds {k}@{v}")),
1198            Some(locked) if locked != v => {
1199                return Some(format!("overrides: {k} changed ({locked} → {v})"));
1200            }
1201            Some(_) => {}
1202        }
1203    }
1204    for k in lockfile.keys() {
1205        if !manifest.contains_key(k) {
1206            return Some(format!("overrides: manifest removes {k}"));
1207        }
1208    }
1209    None
1210}
1211
1212/// Compare two `ignoredOptionalDependencies` sets and return a drift
1213/// reason string for the first difference, or `None` if identical.
1214fn ignored_optional_drift_reason(
1215    lockfile: &BTreeSet<String>,
1216    manifest: &BTreeSet<String>,
1217) -> Option<String> {
1218    for name in manifest {
1219        if !lockfile.contains(name) {
1220            return Some(format!("ignoredOptionalDependencies: manifest adds {name}"));
1221        }
1222    }
1223    for name in lockfile {
1224        if !manifest.contains(name) {
1225            return Some(format!(
1226                "ignoredOptionalDependencies: manifest removes {name}"
1227            ));
1228        }
1229    }
1230    None
1231}
1232
1233/// Result of comparing a lockfile against a manifest.
1234#[derive(Debug, Clone, PartialEq, Eq)]
1235pub enum DriftStatus {
1236    /// The lockfile is in sync with the manifest. Safe to use without re-resolving.
1237    Fresh,
1238    /// The lockfile is out of date. The reason describes the first mismatch found.
1239    Stale { reason: String },
1240}
1241
1242/// Atomic lockfile write. Tempfile in the same dir, fsync, rename
1243/// over the target. Every format writer goes through this so a
1244/// crash or Ctrl+C mid-write cannot leave a truncated lockfile on
1245/// disk. Rename is atomic on POSIX, on Windows MoveFileEx gives
1246/// the same guarantee post Win10. Caller passes the serialized
1247/// bytes already formatted, this just handles the IO layer.
1248pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
1249    // Raw open + rename, same dir. Atomic on POSIX (rename(2)) and
1250    // on Windows (MoveFileEx under fs::rename). Crash between
1251    // write and rename leaves the old file intact, plus a dotfile
1252    // tmp sibling, old file still parses fine so next install
1253    // succeeds.
1254    //
1255    // Tried tempfile::NamedTempFile::persist first but on Windows
1256    // it collided with tests that passed a NamedTempFile as the
1257    // target path. The persist rename hit Access Denied because
1258    // the outer NamedTempFile handle blocked the replacement.
1259    // Direct open/write/rename sidesteps that.
1260    use std::io::Write as _;
1261    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1262    let file_name = path
1263        .file_name()
1264        .and_then(|s| s.to_str())
1265        .unwrap_or("lockfile");
1266    let nanos = std::time::SystemTime::now()
1267        .duration_since(std::time::UNIX_EPOCH)
1268        .map(|d| d.as_nanos())
1269        .unwrap_or(0);
1270    // Dot prefix keeps the tmp hidden on Unix and out of most
1271    // file explorers on Windows. Pid + nanos avoid collision
1272    // between racing processes even if the outer project lock
1273    // somehow missed.
1274    let tmp_name = format!(".{}.aube-tmp-{}-{}", file_name, std::process::id(), nanos);
1275    let tmp_path = parent.join(tmp_name);
1276    // Helper closure so every failure path cleans up the tmp file.
1277    // Without this, a disk-full or permission error mid-write left
1278    // a `.lockfile.aube-tmp-<pid>-<nanos>` sibling on disk forever.
1279    // Enough aborted installs and these pile up in the project dir.
1280    let write_then_rename = || -> Result<(), Error> {
1281        let mut f = std::fs::OpenOptions::new()
1282            .write(true)
1283            .create_new(true)
1284            .open(&tmp_path)
1285            .map_err(|e| Error::Io(tmp_path.clone(), e))?;
1286        f.write_all(body)
1287            .map_err(|e| Error::Io(tmp_path.clone(), e))?;
1288        // sync_all before rename. Rename is a metadata op that can
1289        // commit before the data blocks reach stable storage, so a
1290        // crash right after rename would leave a valid-looking
1291        // file with zero bytes. fsync forces the data first.
1292        f.sync_all().map_err(|e| Error::Io(tmp_path.clone(), e))?;
1293        // Drop the file handle explicitly before rename. Windows
1294        // ReplaceFileW is happier when the source handle is closed.
1295        drop(f);
1296        std::fs::rename(&tmp_path, path).map_err(|e| Error::Io(path.to_path_buf(), e))
1297    };
1298    match write_then_rename() {
1299        Ok(()) => Ok(()),
1300        Err(e) => {
1301            let _ = std::fs::remove_file(&tmp_path);
1302            Err(e)
1303        }
1304    }
1305}
1306
1307/// Write a lockfile to the given project directory using aube's default
1308/// filename (`aube-lock.yaml`, or `aube-lock.<branch>.yaml` when branch
1309/// lockfiles are enabled).
1310pub fn write_lockfile(
1311    project_dir: &Path,
1312    graph: &LockfileGraph,
1313    manifest: &aube_manifest::PackageJson,
1314) -> Result<(), Error> {
1315    write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
1316    Ok(())
1317}
1318
1319/// Write a lockfile using the existing project lockfile kind, or
1320/// Collapse peer-context variants from `graph` into a single map keyed
1321/// by `"name@version"`, pointing at the first-seen package. Several
1322/// writers (npm, yarn, …) share this shape: one canonical entry per
1323/// `(name, version)` pair regardless of how many peer suffixes the
1324/// full graph emits.
1325pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
1326    let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
1327    for pkg in graph.packages.values() {
1328        canonical.entry(pkg.spec_key()).or_insert(pkg);
1329    }
1330    canonical
1331}
1332
1333/// `aube-lock.yaml` when the project does not have one yet.
1334///
1335/// This is the default write path for commands that mutate the active
1336/// project graph (`install`, `add`, `remove`, `update`, `dedupe`, ...).
1337pub fn write_lockfile_preserving_existing(
1338    project_dir: &Path,
1339    graph: &LockfileGraph,
1340    manifest: &aube_manifest::PackageJson,
1341) -> Result<PathBuf, Error> {
1342    let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
1343    write_lockfile_as(project_dir, graph, manifest, kind)
1344}
1345
1346/// Write `graph` in the requested lockfile format into `project_dir`.
1347///
1348/// Returns the path that was actually written (useful for logging
1349/// since `Aube` may resolve to a branch-specific filename). Callers
1350/// that want to preserve whatever format was already on disk should
1351/// pair this with [`detect_existing_lockfile_kind`].
1352///
1353/// All supported formats: `Aube`, `Pnpm`, `Npm`, `NpmShrinkwrap`,
1354/// `Yarn`, and `Bun`. This preserves the lockfile kind that already
1355/// exists in the project; callers should pass `Aube` only when no
1356/// lockfile exists yet. See each writer module's doc comment for
1357/// per-format lossy areas (peer contexts, `resolved` URLs, etc.).
1358pub fn write_lockfile_as(
1359    project_dir: &Path,
1360    graph: &LockfileGraph,
1361    manifest: &aube_manifest::PackageJson,
1362    kind: LockfileKind,
1363) -> Result<PathBuf, Error> {
1364    let filename = match kind {
1365        LockfileKind::Aube => aube_lock_filename(project_dir),
1366        LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
1367        LockfileKind::Npm => "package-lock.json".to_string(),
1368        LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json".to_string(),
1369        LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock".to_string(),
1370        LockfileKind::Bun => "bun.lock".to_string(),
1371    };
1372    let path = project_dir.join(&filename);
1373    match kind {
1374        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
1375        LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
1376        LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
1377        LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
1378        LockfileKind::Bun => bun::write(&path, graph, manifest)?,
1379    }
1380    Ok(path)
1381}
1382
1383/// Return the [`LockfileKind`] of the lockfile already on disk in
1384/// `project_dir`, if any. Follows the same precedence as
1385/// [`parse_lockfile_with_kind`] (aube > pnpm > bun > yarn >
1386/// npm-shrinkwrap > npm). Used by install to preserve a project's
1387/// existing lockfile format when rewriting after a re-resolve — a
1388/// user with only `pnpm-lock.yaml`, `package-lock.json`, or another
1389/// supported lockfile gets that file written back, not a surprise
1390/// `aube-lock.yaml` alongside it.
1391pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
1392    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
1393        if path.exists() {
1394            return Some(refine_yarn_kind(&path, kind));
1395        }
1396    }
1397    None
1398}
1399
1400/// Resolve the canonical lockfile filename for `project_dir` (aube's own).
1401///
1402/// Returns `aube-lock.<branch>.yaml` when `gitBranchLockfile: true` is
1403/// set in `pnpm-workspace.yaml` (or `aube-workspace.yaml`) and the
1404/// project is inside a git checkout with a current branch. Forward
1405/// slashes in the branch name are encoded as `!`, matching pnpm. Falls
1406/// back to plain `aube-lock.yaml` in every other case.
1407///
1408/// Memoized per `project_dir` for the lifetime of the process: a
1409/// single install resolves this 3–5 times (lockfile_candidates,
1410/// write_lockfile, debug log, state read/write), and
1411/// `check_needs_install` runs on every `aube run`/`aube exec` via
1412/// `ensure_installed`. Without caching, every command would pay for a
1413/// YAML parse + a `git branch --show-current` subprocess just to
1414/// recompute a value that can't change mid-process.
1415pub fn aube_lock_filename(project_dir: &Path) -> String {
1416    use std::sync::{Mutex, OnceLock};
1417    static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
1418    let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
1419    if let Ok(map) = cache.lock()
1420        && let Some(hit) = map.get(project_dir)
1421    {
1422        return hit.clone();
1423    }
1424    let resolved = if !git_branch_lockfile_enabled(project_dir) {
1425        "aube-lock.yaml".to_string()
1426    } else {
1427        match current_git_branch(project_dir) {
1428            Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
1429            None => "aube-lock.yaml".to_string(),
1430        }
1431    };
1432    if let Ok(mut map) = cache.lock() {
1433        map.insert(project_dir.to_path_buf(), resolved.clone());
1434    }
1435    resolved
1436}
1437
1438/// Resolve the pnpm lockfile filename for `project_dir`.
1439///
1440/// Mirrors [`aube_lock_filename`] for branch lockfiles, but keeps the
1441/// pnpm filename prefix so projects with an existing `pnpm-lock.yaml`
1442/// keep writing to pnpm's file.
1443pub fn pnpm_lock_filename(project_dir: &Path) -> String {
1444    let aube_name = aube_lock_filename(project_dir);
1445    // `aube_lock_filename` always returns "aube-lock.<rest>", so strip_prefix
1446    // always succeeds. The fallback is purely defensive.
1447    aube_name
1448        .strip_prefix("aube-lock.")
1449        .map(|rest| format!("pnpm-lock.{rest}"))
1450        .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
1451}
1452
1453fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
1454    // Goes through the build-time-generated typed accessor in
1455    // `aube_settings::resolved` so the alias list is driven off
1456    // `settings.toml` — no hand-maintained typed field. This path
1457    // reads only `pnpm-workspace.yaml`; `.npmrc` values are out of
1458    // scope here because aube-lockfile doesn't want a dependency on
1459    // aube-registry just to load npmrc (and the historical behavior
1460    // never read `.npmrc` either).
1461    let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
1462        return false;
1463    };
1464    let npmrc: Vec<(String, String)> = Vec::new();
1465    let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
1466    aube_settings::resolved::git_branch_lockfile(&ctx)
1467}
1468
1469pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
1470    let out = std::process::Command::new("git")
1471        .args(["-C"])
1472        .arg(project_dir)
1473        .args(["branch", "--show-current"])
1474        .output()
1475        .ok()?;
1476    if !out.status.success() {
1477        return None;
1478    }
1479    let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
1480    if branch.is_empty() {
1481        None
1482    } else {
1483        Some(branch)
1484    }
1485}
1486
1487/// Detect and parse the lockfile in the given project directory.
1488///
1489/// Priority: `aube-lock.yaml` → `pnpm-lock.yaml` → `bun.lock` →
1490/// `yarn.lock` → `npm-shrinkwrap.json` → `package-lock.json`.
1491/// (Shrinkwrap takes priority over package-lock.json when both exist, matching npm's behavior.)
1492///
1493/// `manifest` is needed to classify direct vs transitive deps when
1494/// reading yarn.lock (which has no notion of that distinction).
1495pub fn parse_lockfile(
1496    project_dir: &Path,
1497    manifest: &aube_manifest::PackageJson,
1498) -> Result<LockfileGraph, Error> {
1499    let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
1500    Ok(graph)
1501}
1502
1503/// Like [`parse_lockfile`] but also returns which format was read.
1504pub fn parse_lockfile_with_kind(
1505    project_dir: &Path,
1506    manifest: &aube_manifest::PackageJson,
1507) -> Result<(LockfileGraph, LockfileKind), Error> {
1508    reject_bun_binary(project_dir)?;
1509    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ true) {
1510        if !path.exists() {
1511            continue;
1512        }
1513        let kind = refine_yarn_kind(&path, kind);
1514        let graph = parse_one(&path, kind, manifest)?;
1515        return Ok((graph, kind));
1516    }
1517    Err(Error::NotFound(project_dir.to_path_buf()))
1518}
1519
1520/// Variant of [`parse_lockfile_with_kind`] used by `aube import`.
1521///
1522/// Skips `aube-lock.yaml` — if the project already has one, there's
1523/// nothing to import. `pnpm-lock.yaml` *is* included because the whole
1524/// point of `aube import` is to convert a foreign lockfile (including
1525/// pnpm's) into `aube-lock.yaml`.
1526pub fn parse_for_import(
1527    project_dir: &Path,
1528    manifest: &aube_manifest::PackageJson,
1529) -> Result<(LockfileGraph, LockfileKind), Error> {
1530    reject_bun_binary(project_dir)?;
1531    for (path, kind) in lockfile_candidates(project_dir, /*include_aube=*/ false) {
1532        if !path.exists() {
1533            continue;
1534        }
1535        let kind = refine_yarn_kind(&path, kind);
1536        let graph = parse_one(&path, kind, manifest)?;
1537        return Ok((graph, kind));
1538    }
1539    Err(Error::NotFound(project_dir.to_path_buf()))
1540}
1541
1542/// If only `bun.lockb` is present (without a text `bun.lock`), surface an
1543/// actionable error instead of silently falling through to another format.
1544fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
1545    let lockb = project_dir.join("bun.lockb");
1546    let text = project_dir.join("bun.lock");
1547    if lockb.exists() && !text.exists() {
1548        return Err(Error::Parse(
1549            lockb,
1550            "bun.lockb (binary format) is not supported — run `bun install --save-text-lockfile` to generate a bun.lock text file first, or upgrade to bun 1.2+ where text is the default".to_string(),
1551        ));
1552    }
1553    Ok(())
1554}
1555
1556fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
1557    let mut out = Vec::new();
1558    if include_aube {
1559        // Prefer the branch-specific lockfile (if `gitBranchLockfile` is on
1560        // and we resolve a branch); fall through to plain `aube-lock.yaml`
1561        // so a freshly-enabled branch still picks up the base lockfile.
1562        let branch_name = aube_lock_filename(project_dir);
1563        if branch_name != "aube-lock.yaml" {
1564            out.push((project_dir.join(&branch_name), LockfileKind::Aube));
1565        }
1566        out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
1567    }
1568    // Preserve pnpm lockfiles in place. Branch-specific
1569    // `pnpm-lock.<branch>.yaml` mirrors the aube branch lockfile naming
1570    // logic, so a project that already uses pnpm branch lockfiles keeps
1571    // writing through that file.
1572    let pnpm_branch = {
1573        let mut s = aube_lock_filename(project_dir);
1574        if let Some(rest) = s.strip_prefix("aube-lock.") {
1575            s = format!("pnpm-lock.{rest}");
1576        }
1577        s
1578    };
1579    if pnpm_branch != "pnpm-lock.yaml" {
1580        out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
1581    }
1582    out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
1583    out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
1584    out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
1585    out.push((
1586        project_dir.join("npm-shrinkwrap.json"),
1587        LockfileKind::NpmShrinkwrap,
1588    ));
1589    out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
1590    out
1591}
1592
1593fn parse_one(
1594    path: &Path,
1595    kind: LockfileKind,
1596    manifest: &aube_manifest::PackageJson,
1597) -> Result<LockfileGraph, Error> {
1598    match kind {
1599        // `aube-lock.yaml` uses the same on-disk format as pnpm v9 for
1600        // now — same parser, same writer — so we piggyback on the pnpm
1601        // module. Keeping the variant distinct lets detection/import
1602        // treat the two differently even though the bytes are the same.
1603        LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
1604        // yarn.rs::parse peeks the file for `__metadata:` and
1605        // dispatches between classic (v1) and berry (v2+) internally,
1606        // so we can hand both kinds to the same entry point. The
1607        // caller keeps the kind label it resolved from
1608        // `refine_yarn_kind` for downstream write-back.
1609        LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
1610        LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
1611        LockfileKind::Bun => bun::parse(path),
1612    }
1613}
1614
1615/// Replace `LockfileKind::Yarn` with `LockfileKind::YarnBerry` when
1616/// the yarn.lock at `path` is actually a yarn 2+ lockfile. Other
1617/// kinds pass through unchanged.
1618///
1619/// `lockfile_candidates` only knows filenames, not content, so the
1620/// yarn entry is always tagged `Yarn`. Callers that need the precise
1621/// variant (install write-back, import conversions, drift logging)
1622/// funnel through this helper after confirming the candidate exists.
1623fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
1624    if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
1625        LockfileKind::YarnBerry
1626    } else {
1627        kind
1628    }
1629}
1630
1631#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1632pub enum Error {
1633    #[error("no lockfile found in {0}")]
1634    NotFound(std::path::PathBuf),
1635    #[error("unsupported lockfile format: {0}")]
1636    UnsupportedFormat(String),
1637    #[error("failed to read lockfile {0}: {1}")]
1638    Io(std::path::PathBuf, std::io::Error),
1639    /// Structural/serialization lockfile errors that have no source
1640    /// location — shape checks (`must be a mapping`), version guards
1641    /// (`lockfileVersion N unsupported`), and `serde_yaml::to_string`
1642    /// failures during write.
1643    #[error("failed to parse lockfile {0}: {1}")]
1644    Parse(std::path::PathBuf, String),
1645    /// Deserialization failure with a byte offset into the source
1646    /// content, so miette's `fancy` handler can draw a pointer at the
1647    /// offending byte of the lockfile. Reuses `aube_manifest`'s
1648    /// `ParseError` — identical shape, identical rendering — via the
1649    /// same `ParseDiag` pattern `aube-workspace` uses.
1650    #[error(transparent)]
1651    #[diagnostic(transparent)]
1652    ParseDiag(Box<aube_manifest::ParseError>),
1653}
1654
1655/// Read a lockfile from disk, mapping I/O errors to `Error::Io`.
1656pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
1657    std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
1658}
1659
1660/// Parse a JSON lockfile document, attaching a miette source span on
1661/// failure so the fancy handler can point at the offending byte.
1662pub fn parse_json<T: serde::de::DeserializeOwned>(
1663    path: &std::path::Path,
1664    content: String,
1665) -> Result<T, Error> {
1666    match serde_json::from_str(&content) {
1667        Ok(v) => Ok(v),
1668        Err(e) => Err(Error::parse_json_err(path, content, &e)),
1669    }
1670}
1671
1672impl Error {
1673    pub fn parse_json_err(
1674        path: &std::path::Path,
1675        content: String,
1676        err: &serde_json::Error,
1677    ) -> Self {
1678        Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
1679            path, content, err,
1680        )))
1681    }
1682
1683    pub fn parse_yaml_err(
1684        path: &std::path::Path,
1685        content: String,
1686        err: &serde_yaml::Error,
1687    ) -> Self {
1688        Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
1689            path, content, err,
1690        )))
1691    }
1692}
1693
1694#[cfg(test)]
1695mod has_bin_metadata_tests {
1696    use super::*;
1697
1698    fn pkg_with_bin(bin: BTreeMap<String, String>) -> LockedPackage {
1699        LockedPackage {
1700            name: "p".to_string(),
1701            version: "1.0.0".to_string(),
1702            dep_path: "p@1.0.0".to_string(),
1703            bin,
1704            ..Default::default()
1705        }
1706    }
1707
1708    #[test]
1709    fn empty_graph_has_no_bin_metadata() {
1710        let g = LockfileGraph::default();
1711        assert!(!g.has_bin_metadata());
1712    }
1713
1714    #[test]
1715    fn graph_with_no_bins_returns_false() {
1716        let mut g = LockfileGraph::default();
1717        g.packages
1718            .insert("a@1.0.0".to_string(), pkg_with_bin(BTreeMap::new()));
1719        g.packages
1720            .insert("b@2.0.0".to_string(), pkg_with_bin(BTreeMap::new()));
1721        assert!(!g.has_bin_metadata());
1722    }
1723
1724    #[test]
1725    fn any_non_empty_bin_flips_to_true() {
1726        let mut g = LockfileGraph::default();
1727        g.packages
1728            .insert("a@1.0.0".to_string(), pkg_with_bin(BTreeMap::new()));
1729        let mut bin = BTreeMap::new();
1730        bin.insert("tool".to_string(), "bin/tool.js".to_string());
1731        g.packages.insert("b@2.0.0".to_string(), pkg_with_bin(bin));
1732        assert!(g.has_bin_metadata());
1733    }
1734
1735    /// pnpm parsers record `hasBin: true` as a one-entry placeholder
1736    /// map (empty key + empty value). That still flips the flag.
1737    #[test]
1738    fn pnpm_placeholder_bin_counts() {
1739        let mut g = LockfileGraph::default();
1740        let mut bin = BTreeMap::new();
1741        bin.insert(String::new(), String::new());
1742        g.packages.insert("a@1.0.0".to_string(), pkg_with_bin(bin));
1743        assert!(g.has_bin_metadata());
1744    }
1745}
1746
1747#[cfg(test)]
1748mod parse_diag_tests {
1749    use super::*;
1750    use std::path::Path;
1751
1752    /// Trailing `,` in an otherwise fine JSON lockfile — confirm the
1753    /// helper attaches a `NamedSource` pointed at the lockfile path and
1754    /// the span stays in bounds so miette can render a pointer.
1755    #[test]
1756    fn parse_json_attaches_span_for_bad_input() {
1757        let path = Path::new("package-lock.json");
1758        let content = r#"{"name":"x","#.to_string();
1759        let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
1760        else {
1761            panic!("parse_json must produce ParseDiag on malformed input");
1762        };
1763        let offset: usize = pe.span.offset();
1764        let len: usize = pe.span.len();
1765        assert!(offset + len <= content.len());
1766        assert_eq!(pe.path, path);
1767    }
1768
1769    /// Same story for YAML — serde_yaml reports a `Location` with a
1770    /// byte index directly, so no line/col conversion is exercised
1771    /// here. Both production sites (`pnpm.rs`, `yarn.rs`) call
1772    /// `Error::parse_yaml_err` directly (one iterates multiple YAML
1773    /// documents, the other has only borrowed content), so that's the
1774    /// entry point this test locks down.
1775    #[test]
1776    fn parse_yaml_err_attaches_span_for_bad_input() {
1777        let path = Path::new("yarn.lock");
1778        let content = "packages:\n\t- pkg\n".to_string();
1779        let yaml_err: serde_yaml::Error = serde_yaml::from_str::<serde_yaml::Value>(&content)
1780            .expect_err("tab-indented YAML must fail");
1781        let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
1782            panic!("parse_yaml_err must produce ParseDiag");
1783        };
1784        let offset: usize = pe.span.offset();
1785        let len: usize = pe.span.len();
1786        assert!(offset + len <= content.len());
1787        assert_eq!(pe.path, path);
1788    }
1789}
1790
1791#[cfg(test)]
1792mod looks_like_remote_tarball_url_tests {
1793    use super::*;
1794
1795    #[test]
1796    fn matches_https_tgz() {
1797        assert!(LocalSource::looks_like_remote_tarball_url(
1798            "https://example.com/pkg-1.0.0.tgz"
1799        ));
1800    }
1801
1802    #[test]
1803    fn matches_http_tar_gz() {
1804        assert!(LocalSource::looks_like_remote_tarball_url(
1805            "http://example.com/pkg-1.0.0.tar.gz"
1806        ));
1807    }
1808
1809    #[test]
1810    fn strips_fragment_before_suffix_check() {
1811        assert!(LocalSource::looks_like_remote_tarball_url(
1812            "https://example.com/pkg-1.0.0.tgz#sha512-abc"
1813        ));
1814    }
1815
1816    #[test]
1817    fn strips_query_string_before_suffix_check() {
1818        // Auth-token URLs from private registries (JFrog, Nexus,
1819        // CodeArtifact, …) routinely trail `?token=…` after the
1820        // filename. Must still classify as a tarball URL.
1821        assert!(LocalSource::looks_like_remote_tarball_url(
1822            "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
1823        ));
1824        assert!(LocalSource::looks_like_remote_tarball_url(
1825            "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
1826        ));
1827    }
1828
1829    #[test]
1830    fn matches_bare_http_url_without_tarball_suffix() {
1831        // pkg.pr.new serves tarballs from URLs without a `.tgz`
1832        // extension; npm treats all non-git http(s) URLs as tarball
1833        // URLs, so these must classify as remote tarballs.
1834        assert!(LocalSource::looks_like_remote_tarball_url(
1835            "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
1836        ));
1837        assert!(LocalSource::looks_like_remote_tarball_url(
1838            "https://codeload.github.com/user/repo/tar.gz/main"
1839        ));
1840    }
1841
1842    #[test]
1843    fn rejects_non_http_schemes() {
1844        assert!(!LocalSource::looks_like_remote_tarball_url(
1845            "ftp://example.com/pkg.tgz"
1846        ));
1847        assert!(!LocalSource::looks_like_remote_tarball_url(
1848            "git://example.com/repo.git"
1849        ));
1850    }
1851
1852    #[test]
1853    fn parse_classifies_bare_http_url_as_remote_tarball() {
1854        use std::path::Path;
1855        let parsed = LocalSource::parse(
1856            "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
1857            Path::new(""),
1858        );
1859        assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
1860    }
1861
1862    #[test]
1863    fn parse_prefers_git_over_tarball_for_dot_git_url() {
1864        use std::path::Path;
1865        let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
1866        assert!(matches!(parsed, Some(LocalSource::Git(_))));
1867    }
1868}
1869
1870#[cfg(test)]
1871mod filename_tests {
1872    use super::*;
1873
1874    #[test]
1875    fn defaults_to_plain_lockfile_when_setting_absent() {
1876        let dir = tempfile::tempdir().unwrap();
1877        assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
1878        assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
1879    }
1880
1881    #[test]
1882    fn defaults_to_plain_lockfile_when_setting_explicit_false() {
1883        let dir = tempfile::tempdir().unwrap();
1884        std::fs::write(
1885            dir.path().join("pnpm-workspace.yaml"),
1886            "gitBranchLockfile: false\n",
1887        )
1888        .unwrap();
1889        assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
1890    }
1891
1892    #[test]
1893    fn uses_branch_filename_when_enabled_inside_git_repo() {
1894        let dir = tempfile::tempdir().unwrap();
1895        std::fs::write(
1896            dir.path().join("pnpm-workspace.yaml"),
1897            "gitBranchLockfile: true\n",
1898        )
1899        .unwrap();
1900        // git init + checkout a branch with a `/` so we exercise the
1901        // pnpm-style `!` encoding.
1902        let run = |args: &[&str]| {
1903            std::process::Command::new("git")
1904                .args(["-C"])
1905                .arg(dir.path())
1906                .args(args)
1907                .output()
1908                .unwrap()
1909        };
1910        if run(&["init", "-q"]).status.success() {
1911            run(&["checkout", "-q", "-b", "feature/x"]);
1912            assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
1913            assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
1914        }
1915    }
1916}
1917
1918#[cfg(test)]
1919mod git_spec_tests {
1920    use super::*;
1921
1922    #[test]
1923    fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
1924        // Initial parse: `git+https://…/repo` (no `.git`).
1925        let (url, committish) = parse_git_spec("git+https://host/user/repo").unwrap();
1926        assert_eq!(url, "https://host/user/repo");
1927        assert_eq!(committish, None);
1928
1929        // After resolving, the serializer writes `<url>#<sha>` into
1930        // the lockfile's importer `version:` field.
1931        let sha = "abcdef0123456789abcdef0123456789abcdef01";
1932        let source = LocalSource::Git(GitSource {
1933            url: url.clone(),
1934            committish: None,
1935            resolved: sha.to_string(),
1936        });
1937        let lockfile_version = source.specifier();
1938        assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
1939
1940        // Re-parse must recognize the bare URL because the 40-hex
1941        // committish suffix unambiguously tags it as git.
1942        let (round_url, round_committish) = parse_git_spec(&lockfile_version).unwrap();
1943        assert_eq!(round_url, "https://host/user/repo");
1944        assert_eq!(round_committish.as_deref(), Some(sha));
1945    }
1946
1947    #[test]
1948    fn bare_https_without_dot_git_and_no_committish_is_not_git() {
1949        // A plain `https://…` URL with no `.git` and no SHA could be
1950        // anything (including a tarball); don't claim it.
1951        assert!(parse_git_spec("https://example.com/pkg").is_none());
1952    }
1953
1954    #[test]
1955    fn github_shorthand_expands_and_roundtrips() {
1956        let (url, _) = parse_git_spec("github:user/repo").unwrap();
1957        assert_eq!(url, "https://github.com/user/repo.git");
1958    }
1959
1960    #[test]
1961    fn commit_selector_fragment_normalizes_to_sha() {
1962        let sha = "abcdef0123456789abcdef0123456789abcdef01";
1963        let (url, committish) =
1964            parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
1965        assert_eq!(url, "https://host/user/repo.git");
1966        assert_eq!(committish.as_deref(), Some(sha));
1967    }
1968
1969    #[test]
1970    fn named_selector_fragment_normalizes_to_ref() {
1971        let (url, committish) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
1972        assert_eq!(url, "https://host/user/repo");
1973        assert_eq!(committish.as_deref(), Some("v1.2.3"));
1974    }
1975}
1976
1977#[cfg(test)]
1978mod drift_tests {
1979    use super::*;
1980    use aube_manifest::PackageJson;
1981    use std::collections::BTreeMap;
1982
1983    fn make_manifest(deps: &[(&str, &str)]) -> PackageJson {
1984        let mut m = PackageJson {
1985            name: Some("test".into()),
1986            version: Some("1.0.0".into()),
1987            dependencies: BTreeMap::new(),
1988            dev_dependencies: BTreeMap::new(),
1989            peer_dependencies: BTreeMap::new(),
1990            optional_dependencies: BTreeMap::new(),
1991            update_config: None,
1992            scripts: BTreeMap::new(),
1993            engines: BTreeMap::new(),
1994            workspaces: None,
1995            bundled_dependencies: None,
1996            extra: BTreeMap::new(),
1997        };
1998        for (name, spec) in deps {
1999            m.dependencies.insert((*name).into(), (*spec).into());
2000        }
2001        m
2002    }
2003
2004    fn make_graph(deps: &[(&str, &str, &str)]) -> LockfileGraph {
2005        // (name, specifier, dep_path)
2006        let direct: Vec<DirectDep> = deps
2007            .iter()
2008            .map(|(name, spec, dep_path)| DirectDep {
2009                name: (*name).into(),
2010                dep_path: (*dep_path).into(),
2011                dep_type: DepType::Production,
2012                specifier: Some((*spec).into()),
2013            })
2014            .collect();
2015        let mut importers = BTreeMap::new();
2016        importers.insert(".".to_string(), direct);
2017        LockfileGraph {
2018            importers,
2019            packages: BTreeMap::new(),
2020            ..Default::default()
2021        }
2022    }
2023
2024    #[test]
2025    fn fresh_when_specifiers_match() {
2026        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2027        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2028        assert_eq!(
2029            graph.check_drift(&manifest, &BTreeMap::new(), &[]),
2030            DriftStatus::Fresh
2031        );
2032    }
2033
2034    #[test]
2035    fn stale_when_specifier_changes() {
2036        let manifest = make_manifest(&[("lodash", "^4.18.0")]);
2037        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2038        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2039            DriftStatus::Stale { reason } => assert!(reason.contains("lodash")),
2040            DriftStatus::Fresh => panic!("expected Stale"),
2041        }
2042    }
2043
2044    #[test]
2045    fn stale_when_manifest_adds_dep() {
2046        let manifest = make_manifest(&[("lodash", "^4.17.0"), ("express", "^4.18.0")]);
2047        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2048        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2049            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2050            DriftStatus::Fresh => panic!("expected Stale"),
2051        }
2052    }
2053
2054    #[test]
2055    fn stale_when_manifest_removes_dep() {
2056        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2057        let graph = make_graph(&[
2058            ("lodash", "^4.17.0", "lodash@4.17.21"),
2059            ("express", "^4.18.0", "express@4.18.0"),
2060        ]);
2061        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2062            DriftStatus::Stale { reason } => assert!(reason.contains("express")),
2063            DriftStatus::Fresh => panic!("expected Stale"),
2064        }
2065    }
2066
2067    // Regression guard for #42: the drift check must recognize
2068    // auto-hoisted peers as derived state, not as "manifest removed X".
2069    // Without this, every project that has any peer dep would trigger
2070    // a full re-resolve on every install, defeating lockfile caching.
2071    #[test]
2072    fn fresh_when_lockfile_has_auto_hoisted_peer() {
2073        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2074        let mut graph = make_graph(&[
2075            (
2076                "use-sync-external-store",
2077                "1.2.0",
2078                "use-sync-external-store@1.2.0",
2079            ),
2080            // Hoisted peer — in the lockfile importers but not in the
2081            // user's package.json.
2082            ("react", "^16.8.0 || ^17.0.0 || ^18.0.0", "react@18.3.1"),
2083        ]);
2084        // The declaring package must list react as a peer for the
2085        // drift check to recognize the hoist. We add that here.
2086        let mut declaring_pkg = LockedPackage {
2087            name: "use-sync-external-store".into(),
2088            version: "1.2.0".into(),
2089            dep_path: "use-sync-external-store@1.2.0".into(),
2090            ..Default::default()
2091        };
2092        declaring_pkg
2093            .peer_dependencies
2094            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2095        graph
2096            .packages
2097            .insert("use-sync-external-store@1.2.0".into(), declaring_pkg);
2098
2099        assert_eq!(
2100            graph.check_drift(&manifest, &BTreeMap::new(), &[]),
2101            DriftStatus::Fresh
2102        );
2103    }
2104
2105    // Regression: when a user explicitly pinned a dep that also happens
2106    // to share its name with a peer declaration elsewhere in the graph,
2107    // removing that pin from package.json must still be flagged as
2108    // stale — otherwise the old pinned version gets locked forever.
2109    // The check must key on (name, specifier), not name alone.
2110    #[test]
2111    fn stale_when_user_removes_pinned_dep_that_shares_name_with_a_peer() {
2112        // Manifest after the user removed react entirely. Only
2113        // use-sync-external-store remains.
2114        let manifest = make_manifest(&[("use-sync-external-store", "1.2.0")]);
2115
2116        // Lockfile still has the user's old `react: 17.0.2` pin alongside
2117        // use-sync-external-store. Pre-removal state.
2118        let mut graph = make_graph(&[
2119            (
2120                "use-sync-external-store",
2121                "1.2.0",
2122                "use-sync-external-store@1.2.0",
2123            ),
2124            ("react", "17.0.2", "react@17.0.2"),
2125        ]);
2126        // Add the peer declaration on the consumer package. This is
2127        // the case that previously defeated the name-only check:
2128        // react's specifier "17.0.2" doesn't match the declared peer
2129        // range, so the hoist recognizer must reject it.
2130        let mut consumer = LockedPackage {
2131            name: "use-sync-external-store".into(),
2132            version: "1.2.0".into(),
2133            dep_path: "use-sync-external-store@1.2.0".into(),
2134            ..Default::default()
2135        };
2136        consumer
2137            .peer_dependencies
2138            .insert("react".into(), "^16.8.0 || ^17.0.0 || ^18.0.0".into());
2139        graph
2140            .packages
2141            .insert("use-sync-external-store@1.2.0".into(), consumer);
2142
2143        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2144            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2145            DriftStatus::Fresh => panic!(
2146                "drift check should flag a removed user-pinned dep as stale, \
2147                 even when its name matches a peer declaration"
2148            ),
2149        }
2150    }
2151
2152    // But if the lockfile has a user-removed dep that ISN'T declared as a
2153    // peer anywhere, we still need to flag it as stale.
2154    #[test]
2155    fn stale_when_lockfile_has_removed_non_peer_dep() {
2156        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2157        let graph = make_graph(&[
2158            ("lodash", "^4.17.0", "lodash@4.17.21"),
2159            ("chalk", "^5.0.0", "chalk@5.0.0"),
2160        ]);
2161        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2162            DriftStatus::Stale { reason } => assert!(reason.contains("chalk")),
2163            DriftStatus::Fresh => panic!("expected Stale"),
2164        }
2165    }
2166
2167    #[test]
2168    fn fresh_when_no_specifiers_recorded() {
2169        // Non-pnpm formats (npm/yarn/bun) don't store specifiers, so we can't
2170        // detect drift — we treat them as fresh and let the resolver decide.
2171        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2172        let graph = LockfileGraph {
2173            importers: {
2174                let mut m = BTreeMap::new();
2175                m.insert(
2176                    ".".to_string(),
2177                    vec![DirectDep {
2178                        name: "lodash".into(),
2179                        dep_path: "lodash@4.17.21".into(),
2180                        dep_type: DepType::Production,
2181                        specifier: None,
2182                    }],
2183                );
2184                m
2185            },
2186            packages: BTreeMap::new(),
2187            ..Default::default()
2188        };
2189        assert_eq!(
2190            graph.check_drift(&manifest, &BTreeMap::new(), &[]),
2191            DriftStatus::Fresh
2192        );
2193    }
2194
2195    #[test]
2196    fn stale_when_manifest_adds_override() {
2197        // Lockfile recorded no overrides; manifest now has one. Drift
2198        // must fire so the next install re-runs the resolver and bakes
2199        // the override into the graph.
2200        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2201        manifest
2202            .extra
2203            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2204        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2205        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2206            DriftStatus::Stale { reason } => assert!(reason.contains("overrides")),
2207            DriftStatus::Fresh => panic!("expected Stale"),
2208        }
2209    }
2210
2211    #[test]
2212    fn stale_drift_message_names_changed_override_key() {
2213        // Both sides have one entry, but the value differs. The reason
2214        // should name the key — the previous "lockfile: 1 entries,
2215        // manifest: 1 entries" message looked like nothing changed.
2216        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2217        manifest
2218            .extra
2219            .insert("overrides".into(), serde_json::json!({"lodash": "5.0.0"}));
2220        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2221        graph.overrides.insert("lodash".into(), "4.17.21".into());
2222        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2223            DriftStatus::Stale { reason } => {
2224                assert!(reason.contains("lodash"), "expected key in: {reason}");
2225                assert!(
2226                    reason.contains("4.17.21"),
2227                    "expected old value in: {reason}"
2228                );
2229                assert!(reason.contains("5.0.0"), "expected new value in: {reason}");
2230            }
2231            DriftStatus::Fresh => panic!("expected Stale"),
2232        }
2233    }
2234
2235    #[test]
2236    fn stale_when_manifest_removes_override() {
2237        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2238        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2239        graph.overrides.insert("lodash".into(), "4.17.21".into());
2240        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2241            DriftStatus::Stale { reason } => {
2242                assert!(reason.contains("removes"));
2243                assert!(reason.contains("lodash"));
2244            }
2245            DriftStatus::Fresh => panic!("expected Stale"),
2246        }
2247    }
2248
2249    #[test]
2250    fn fresh_when_overrides_match() {
2251        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2252        manifest
2253            .extra
2254            .insert("overrides".into(), serde_json::json!({"lodash": "4.17.21"}));
2255        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2256        graph.overrides.insert("lodash".into(), "4.17.21".into());
2257        assert_eq!(
2258            graph.check_drift(&manifest, &BTreeMap::new(), &[]),
2259            DriftStatus::Fresh
2260        );
2261    }
2262
2263    #[test]
2264    fn fresh_when_workspace_yaml_overrides_match_lockfile() {
2265        // pnpm v10 moved `overrides` to pnpm-workspace.yaml. When the
2266        // resolver wrote them into `self.overrides`, the drift check
2267        // must see the same map — otherwise the second install run
2268        // rejects the lockfile as stale with "manifest removes ..."
2269        // (reported in discussion #174).
2270        let manifest = make_manifest(&[("semver", "^7.5.0")]);
2271        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2272        graph.overrides.insert("semver".into(), "7.7.1".into());
2273        let mut ws_overrides = BTreeMap::new();
2274        ws_overrides.insert("semver".into(), "7.7.1".into());
2275        assert_eq!(
2276            graph.check_drift(&manifest, &ws_overrides, &[]),
2277            DriftStatus::Fresh,
2278        );
2279    }
2280
2281    #[test]
2282    fn workspace_yaml_overrides_win_over_package_json() {
2283        // When both pnpm-workspace.yaml and package.json declare an
2284        // override for the same key, the workspace yaml wins — pnpm
2285        // v10's precedence. The drift check must apply the merged
2286        // effective map.
2287        let mut manifest = make_manifest(&[("semver", "^7.5.0")]);
2288        manifest
2289            .extra
2290            .insert("overrides".into(), serde_json::json!({"semver": "7.0.0"}));
2291        let mut graph = make_graph(&[("semver", "^7.5.0", "semver@7.7.1")]);
2292        graph.overrides.insert("semver".into(), "7.7.1".into());
2293        let mut ws_overrides = BTreeMap::new();
2294        ws_overrides.insert("semver".into(), "7.7.1".into());
2295        assert_eq!(
2296            graph.check_drift(&manifest, &ws_overrides, &[]),
2297            DriftStatus::Fresh,
2298        );
2299    }
2300
2301    #[test]
2302    fn fresh_when_workspace_yaml_ignored_optional_matches_lockfile() {
2303        // Same drift-shaped bug as overrides: the resolver unions
2304        // `ignoredOptionalDependencies` from package.json and
2305        // pnpm-workspace.yaml, so the lockfile's
2306        // `ignored_optional_dependencies` carries the union, and the
2307        // drift check has to see the same union or the next
2308        // `--frozen-lockfile` run fails with "manifest removes".
2309        let manifest = make_manifest(&[("lodash", "^4.17.0")]);
2310        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2311        graph
2312            .ignored_optional_dependencies
2313            .insert("fsevents".to_string());
2314        let ws_ignored = vec!["fsevents".to_string()];
2315        assert_eq!(
2316            graph.check_drift(&manifest, &BTreeMap::new(), &ws_ignored),
2317            DriftStatus::Fresh,
2318        );
2319    }
2320
2321    #[test]
2322    fn fresh_when_optional_dep_was_recorded_as_skipped() {
2323        // Regression: a platform-skipped optional dep would otherwise
2324        // loop forever as "manifest adds X". When the previous
2325        // resolve recorded it under skipped_optional_dependencies with
2326        // a matching specifier, drift must report Fresh.
2327        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2328        manifest
2329            .optional_dependencies
2330            .insert("fsevents".into(), "^2.3.0".into());
2331        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2332        let mut inner = BTreeMap::new();
2333        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
2334        graph
2335            .skipped_optional_dependencies
2336            .insert(".".to_string(), inner);
2337        assert_eq!(
2338            graph.check_drift(&manifest, &BTreeMap::new(), &[]),
2339            DriftStatus::Fresh
2340        );
2341    }
2342
2343    #[test]
2344    fn stale_when_new_optional_dep_was_never_seen() {
2345        // Cursor Bugbot regression: a brand-new optional dep that the
2346        // previous resolve never saw must trigger drift, otherwise it
2347        // would silently never get installed. Distinct from a
2348        // platform-skipped optional, which has an entry in
2349        // `skipped_optional_dependencies`.
2350        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2351        manifest
2352            .optional_dependencies
2353            .insert("fsevents".into(), "^2.3.0".into());
2354        let graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2355        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2356            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
2357            DriftStatus::Fresh => panic!("expected Stale on new optional dep"),
2358        }
2359    }
2360
2361    #[test]
2362    fn stale_when_skipped_optional_dep_specifier_changes() {
2363        // The user bumped the range on a previously-skipped optional;
2364        // the recorded specifier no longer matches the manifest, so we
2365        // need to re-resolve.
2366        let mut manifest = make_manifest(&[("lodash", "^4.17.0")]);
2367        manifest
2368            .optional_dependencies
2369            .insert("fsevents".into(), "^2.4.0".into());
2370        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2371        let mut inner = BTreeMap::new();
2372        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
2373        graph
2374            .skipped_optional_dependencies
2375            .insert(".".to_string(), inner);
2376        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2377            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
2378            DriftStatus::Fresh => panic!("expected Stale on skipped optional spec change"),
2379        }
2380    }
2381
2382    #[test]
2383    fn stale_when_skipped_optional_is_promoted_to_required() {
2384        // Cursor Bugbot regression: if the user moves a previously-
2385        // skipped optional into `dependencies` (same specifier), the
2386        // skipped-list exemption must NOT fire — the dep is now
2387        // required and the lockfile genuinely doesn't include it.
2388        let mut manifest = make_manifest(&[("lodash", "^4.17.0"), ("fsevents", "^2.3.0")]);
2389        // Note: fsevents lives in `dependencies`, not
2390        // `optional_dependencies`, even though the lockfile recorded
2391        // it under skipped optionals from a previous resolve.
2392        manifest.optional_dependencies.clear();
2393        let mut graph = make_graph(&[("lodash", "^4.17.0", "lodash@4.17.21")]);
2394        let mut inner = BTreeMap::new();
2395        inner.insert("fsevents".to_string(), "^2.3.0".to_string());
2396        graph
2397            .skipped_optional_dependencies
2398            .insert(".".to_string(), inner);
2399        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2400            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
2401            DriftStatus::Fresh => {
2402                panic!("expected Stale: skipped-optional exemption must not apply to required deps")
2403            }
2404        }
2405    }
2406
2407    #[test]
2408    fn stale_when_optional_dep_specifier_changes_in_lockfile() {
2409        // Spec changes on optionals that *are* present must still
2410        // drift, so the resolver re-runs when the user bumps a range.
2411        let mut manifest = make_manifest(&[]);
2412        manifest
2413            .optional_dependencies
2414            .insert("fsevents".into(), "^2.4.0".into());
2415        let mut graph = make_graph(&[]);
2416        graph.importers.get_mut(".").unwrap().push(DirectDep {
2417            name: "fsevents".into(),
2418            dep_path: "fsevents@2.3.0".into(),
2419            dep_type: DepType::Optional,
2420            specifier: Some("^2.3.0".into()),
2421        });
2422        match graph.check_drift(&manifest, &BTreeMap::new(), &[]) {
2423            DriftStatus::Stale { reason } => assert!(reason.contains("fsevents"), "{reason}"),
2424            DriftStatus::Fresh => panic!("expected Stale on optional spec change"),
2425        }
2426    }
2427
2428    #[test]
2429    fn fresh_for_empty_manifest_and_lockfile() {
2430        let manifest = make_manifest(&[]);
2431        let graph = make_graph(&[]);
2432        assert_eq!(
2433            graph.check_drift(&manifest, &BTreeMap::new(), &[]),
2434            DriftStatus::Fresh
2435        );
2436    }
2437
2438    #[test]
2439    fn workspace_drift_detects_change_in_non_root_importer() {
2440        // Build a graph with two importers: root and packages/app.
2441        let root_dep = DirectDep {
2442            name: "lodash".into(),
2443            dep_path: "lodash@4.17.21".into(),
2444            dep_type: DepType::Production,
2445            specifier: Some("^4.17.0".into()),
2446        };
2447        let app_dep = DirectDep {
2448            name: "express".into(),
2449            dep_path: "express@4.18.0".into(),
2450            dep_type: DepType::Production,
2451            specifier: Some("^4.18.0".into()),
2452        };
2453        let mut importers = BTreeMap::new();
2454        importers.insert(".".to_string(), vec![root_dep]);
2455        importers.insert("packages/app".to_string(), vec![app_dep]);
2456        let graph = LockfileGraph {
2457            importers,
2458            packages: BTreeMap::new(),
2459            ..Default::default()
2460        };
2461
2462        let root_manifest = make_manifest(&[("lodash", "^4.17.0")]);
2463        // App manifest changed express to ^5.0.0 — should be detected as stale.
2464        let app_manifest = make_manifest(&[("express", "^5.0.0")]);
2465
2466        let workspace_manifests = vec![
2467            (".".to_string(), root_manifest.clone()),
2468            ("packages/app".to_string(), app_manifest),
2469        ];
2470        match graph.check_drift_workspace(&workspace_manifests, &BTreeMap::new(), &[]) {
2471            DriftStatus::Stale { reason } => {
2472                assert!(reason.contains("packages/app"));
2473                assert!(reason.contains("express"));
2474            }
2475            DriftStatus::Fresh => panic!("expected Stale"),
2476        }
2477
2478        // Single-importer check_drift on root only would say Fresh.
2479        assert_eq!(
2480            graph.check_drift(&root_manifest, &BTreeMap::new(), &[]),
2481            DriftStatus::Fresh
2482        );
2483    }
2484
2485    #[test]
2486    fn filter_deps_prunes_dev_only_subtree() {
2487        // Graph: prod-root (foo) + dev-root (jest) with transitive chains.
2488        // After filtering out Dev, jest + its transitives should be pruned,
2489        // foo + its transitives should remain.
2490        let mut importers = BTreeMap::new();
2491        importers.insert(
2492            ".".to_string(),
2493            vec![
2494                DirectDep {
2495                    name: "foo".into(),
2496                    dep_path: "foo@1.0.0".into(),
2497                    dep_type: DepType::Production,
2498                    specifier: Some("^1.0.0".into()),
2499                },
2500                DirectDep {
2501                    name: "jest".into(),
2502                    dep_path: "jest@29.0.0".into(),
2503                    dep_type: DepType::Dev,
2504                    specifier: Some("^29.0.0".into()),
2505                },
2506            ],
2507        );
2508
2509        let mut packages = BTreeMap::new();
2510        let mut foo_deps = BTreeMap::new();
2511        foo_deps.insert("bar".to_string(), "2.0.0".to_string());
2512        packages.insert(
2513            "foo@1.0.0".to_string(),
2514            LockedPackage {
2515                name: "foo".into(),
2516                version: "1.0.0".into(),
2517                integrity: None,
2518                dependencies: foo_deps,
2519                dep_path: "foo@1.0.0".into(),
2520                ..Default::default()
2521            },
2522        );
2523        packages.insert(
2524            "bar@2.0.0".to_string(),
2525            LockedPackage {
2526                name: "bar".into(),
2527                version: "2.0.0".into(),
2528                integrity: None,
2529                dependencies: BTreeMap::new(),
2530                dep_path: "bar@2.0.0".into(),
2531                ..Default::default()
2532            },
2533        );
2534        let mut jest_deps = BTreeMap::new();
2535        jest_deps.insert("jest-core".to_string(), "29.0.0".to_string());
2536        packages.insert(
2537            "jest@29.0.0".to_string(),
2538            LockedPackage {
2539                name: "jest".into(),
2540                version: "29.0.0".into(),
2541                integrity: None,
2542                dependencies: jest_deps,
2543                dep_path: "jest@29.0.0".into(),
2544                ..Default::default()
2545            },
2546        );
2547        packages.insert(
2548            "jest-core@29.0.0".to_string(),
2549            LockedPackage {
2550                name: "jest-core".into(),
2551                version: "29.0.0".into(),
2552                integrity: None,
2553                dependencies: BTreeMap::new(),
2554                dep_path: "jest-core@29.0.0".into(),
2555                ..Default::default()
2556            },
2557        );
2558
2559        let graph = LockfileGraph {
2560            importers,
2561            packages,
2562            ..Default::default()
2563        };
2564
2565        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
2566
2567        // Direct deps: only foo, jest dropped
2568        let roots = prod.root_deps();
2569        assert_eq!(roots.len(), 1);
2570        assert_eq!(roots[0].name, "foo");
2571
2572        // Reachable packages: foo + bar (transitive), NOT jest or jest-core
2573        assert!(prod.packages.contains_key("foo@1.0.0"));
2574        assert!(prod.packages.contains_key("bar@2.0.0"));
2575        assert!(!prod.packages.contains_key("jest@29.0.0"));
2576        assert!(!prod.packages.contains_key("jest-core@29.0.0"));
2577    }
2578
2579    // Regression for #50 feedback: `filter_deps` is a structural
2580    // operation and must preserve the source graph's `settings:`
2581    // metadata. A filtered graph that's handed to the lockfile writer
2582    // (as `aube prune` does today) would otherwise reset
2583    // `autoInstallPeers` to its default and silently flip the user's
2584    // choice on the next install.
2585    #[test]
2586    fn filter_deps_preserves_lockfile_settings() {
2587        let graph = LockfileGraph {
2588            importers: BTreeMap::new(),
2589            packages: BTreeMap::new(),
2590            settings: LockfileSettings {
2591                auto_install_peers: false,
2592                exclude_links_from_lockfile: true,
2593                lockfile_include_tarball_url: false,
2594            },
2595            ..Default::default()
2596        };
2597        let filtered = graph.filter_deps(|_| true);
2598        assert!(!filtered.settings.auto_install_peers);
2599        assert!(filtered.settings.exclude_links_from_lockfile);
2600    }
2601
2602    #[test]
2603    fn filter_deps_keeps_shared_transitive_reachable_via_prod() {
2604        // Graph: prod foo → shared, dev jest → shared
2605        // Filtering out Dev should still keep `shared` because foo → shared
2606        // keeps it reachable.
2607        let mut importers = BTreeMap::new();
2608        importers.insert(
2609            ".".to_string(),
2610            vec![
2611                DirectDep {
2612                    name: "foo".into(),
2613                    dep_path: "foo@1.0.0".into(),
2614                    dep_type: DepType::Production,
2615                    specifier: Some("^1.0.0".into()),
2616                },
2617                DirectDep {
2618                    name: "jest".into(),
2619                    dep_path: "jest@29.0.0".into(),
2620                    dep_type: DepType::Dev,
2621                    specifier: Some("^29.0.0".into()),
2622                },
2623            ],
2624        );
2625
2626        let mut packages = BTreeMap::new();
2627        for (name, ver, deps) in [
2628            ("foo", "1.0.0", vec![("shared", "1.0.0")]),
2629            ("jest", "29.0.0", vec![("shared", "1.0.0")]),
2630            ("shared", "1.0.0", vec![]),
2631        ] {
2632            let mut dep_map = BTreeMap::new();
2633            for (n, v) in deps {
2634                dep_map.insert(n.to_string(), v.to_string());
2635            }
2636            packages.insert(
2637                format!("{name}@{ver}"),
2638                LockedPackage {
2639                    name: name.into(),
2640                    version: ver.into(),
2641                    integrity: None,
2642                    dependencies: dep_map,
2643                    dep_path: format!("{name}@{ver}"),
2644                    ..Default::default()
2645                },
2646            );
2647        }
2648
2649        let graph = LockfileGraph {
2650            importers,
2651            packages,
2652            ..Default::default()
2653        };
2654        let prod = graph.filter_deps(|d| d.dep_type != DepType::Dev);
2655
2656        assert!(prod.packages.contains_key("foo@1.0.0"));
2657        assert!(prod.packages.contains_key("shared@1.0.0"));
2658        assert!(!prod.packages.contains_key("jest@29.0.0"));
2659    }
2660
2661    #[test]
2662    fn subset_to_importer_returns_none_for_missing_importer() {
2663        let graph = LockfileGraph {
2664            importers: BTreeMap::new(),
2665            packages: BTreeMap::new(),
2666            ..Default::default()
2667        };
2668        assert!(graph.subset_to_importer("packages/lib", |_| true).is_none());
2669    }
2670
2671    #[test]
2672    fn subset_to_importer_keeps_only_requested_importer_transitive_closure() {
2673        // Workspace graph with two importers that own independent
2674        // subtrees: packages/lib pulls is-odd → is-number, packages/app
2675        // pulls express. Subsetting to packages/lib must yield a graph
2676        // rooted at `.` containing only is-odd + is-number, with
2677        // express pruned. Matches what `aube deploy --filter @test/lib`
2678        // should write into the target.
2679        let mut importers = BTreeMap::new();
2680        importers.insert(".".to_string(), vec![]);
2681        importers.insert(
2682            "packages/lib".to_string(),
2683            vec![DirectDep {
2684                name: "is-odd".into(),
2685                dep_path: "is-odd@3.0.1".into(),
2686                dep_type: DepType::Production,
2687                specifier: Some("^3.0.1".into()),
2688            }],
2689        );
2690        importers.insert(
2691            "packages/app".to_string(),
2692            vec![DirectDep {
2693                name: "express".into(),
2694                dep_path: "express@4.18.0".into(),
2695                dep_type: DepType::Production,
2696                specifier: Some("^4.18.0".into()),
2697            }],
2698        );
2699
2700        let mut packages = BTreeMap::new();
2701        let mut is_odd_deps = BTreeMap::new();
2702        is_odd_deps.insert("is-number".to_string(), "6.0.0".to_string());
2703        packages.insert(
2704            "is-odd@3.0.1".to_string(),
2705            LockedPackage {
2706                name: "is-odd".into(),
2707                version: "3.0.1".into(),
2708                dependencies: is_odd_deps,
2709                dep_path: "is-odd@3.0.1".into(),
2710                ..Default::default()
2711            },
2712        );
2713        packages.insert(
2714            "is-number@6.0.0".to_string(),
2715            LockedPackage {
2716                name: "is-number".into(),
2717                version: "6.0.0".into(),
2718                dep_path: "is-number@6.0.0".into(),
2719                ..Default::default()
2720            },
2721        );
2722        packages.insert(
2723            "express@4.18.0".to_string(),
2724            LockedPackage {
2725                name: "express".into(),
2726                version: "4.18.0".into(),
2727                dep_path: "express@4.18.0".into(),
2728                ..Default::default()
2729            },
2730        );
2731
2732        let graph = LockfileGraph {
2733            importers,
2734            packages,
2735            ..Default::default()
2736        };
2737        let subset = graph
2738            .subset_to_importer("packages/lib", |_| true)
2739            .expect("packages/lib importer present");
2740
2741        assert_eq!(subset.importers.len(), 1);
2742        let roots = subset.root_deps();
2743        assert_eq!(roots.len(), 1);
2744        assert_eq!(roots[0].name, "is-odd");
2745
2746        assert!(subset.packages.contains_key("is-odd@3.0.1"));
2747        assert!(subset.packages.contains_key("is-number@6.0.0"));
2748        assert!(!subset.packages.contains_key("express@4.18.0"));
2749    }
2750
2751    #[test]
2752    fn subset_to_importer_honors_keep_predicate_for_prod_deploys() {
2753        // packages/lib has both prod (is-odd) and dev (jest) deps.
2754        // `aube deploy --prod` should pass `|d| d.dep_type != Dev` as
2755        // the keep filter; the resulting subset retains only is-odd
2756        // so drift against the target's dev-stripped manifest stays
2757        // clean.
2758        let mut importers = BTreeMap::new();
2759        importers.insert(
2760            "packages/lib".to_string(),
2761            vec![
2762                DirectDep {
2763                    name: "is-odd".into(),
2764                    dep_path: "is-odd@3.0.1".into(),
2765                    dep_type: DepType::Production,
2766                    specifier: Some("^3.0.1".into()),
2767                },
2768                DirectDep {
2769                    name: "jest".into(),
2770                    dep_path: "jest@29.0.0".into(),
2771                    dep_type: DepType::Dev,
2772                    specifier: Some("^29.0.0".into()),
2773                },
2774            ],
2775        );
2776        let mut packages = BTreeMap::new();
2777        packages.insert(
2778            "is-odd@3.0.1".to_string(),
2779            LockedPackage {
2780                name: "is-odd".into(),
2781                version: "3.0.1".into(),
2782                dep_path: "is-odd@3.0.1".into(),
2783                ..Default::default()
2784            },
2785        );
2786        packages.insert(
2787            "jest@29.0.0".to_string(),
2788            LockedPackage {
2789                name: "jest".into(),
2790                version: "29.0.0".into(),
2791                dep_path: "jest@29.0.0".into(),
2792                ..Default::default()
2793            },
2794        );
2795        let graph = LockfileGraph {
2796            importers,
2797            packages,
2798            ..Default::default()
2799        };
2800
2801        let prod = graph
2802            .subset_to_importer("packages/lib", |d| d.dep_type != DepType::Dev)
2803            .expect("importer present");
2804        let roots = prod.root_deps();
2805        assert_eq!(roots.len(), 1);
2806        assert_eq!(roots[0].name, "is-odd");
2807        assert!(prod.packages.contains_key("is-odd@3.0.1"));
2808        assert!(!prod.packages.contains_key("jest@29.0.0"));
2809    }
2810
2811    #[test]
2812    fn subset_to_importer_preserves_graph_settings() {
2813        // Structural pruning, not a resolution-mode reset: a deploy
2814        // into a target that uses the source workspace's settings
2815        // header (autoInstallPeers / lockfileIncludeTarballUrl)
2816        // should write them through unchanged so a frozen install in
2817        // the target sees the same resolution-mode state.
2818        let mut importers = BTreeMap::new();
2819        importers.insert("packages/lib".to_string(), vec![]);
2820        let graph = LockfileGraph {
2821            importers,
2822            packages: BTreeMap::new(),
2823            settings: LockfileSettings {
2824                auto_install_peers: false,
2825                exclude_links_from_lockfile: true,
2826                lockfile_include_tarball_url: true,
2827            },
2828            ..Default::default()
2829        };
2830        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
2831        assert!(!subset.settings.auto_install_peers);
2832        assert!(subset.settings.exclude_links_from_lockfile);
2833        assert!(subset.settings.lockfile_include_tarball_url);
2834    }
2835
2836    #[test]
2837    fn subset_to_importer_rekeys_skipped_optionals_to_root() {
2838        // `skipped_optional_dependencies` is per-importer. After
2839        // subsetting, only the retained importer's entry should
2840        // survive — rekeyed to `.` so a frozen install in the target
2841        // (which has exactly one importer) doesn't see ghost entries.
2842        let mut importers = BTreeMap::new();
2843        importers.insert("packages/lib".to_string(), vec![]);
2844        importers.insert("packages/app".to_string(), vec![]);
2845        let mut skipped = BTreeMap::new();
2846        let mut lib_skip = BTreeMap::new();
2847        lib_skip.insert("fsevents".to_string(), "^2".to_string());
2848        skipped.insert("packages/lib".to_string(), lib_skip);
2849        let mut app_skip = BTreeMap::new();
2850        app_skip.insert("ghost".to_string(), "*".to_string());
2851        skipped.insert("packages/app".to_string(), app_skip);
2852        let graph = LockfileGraph {
2853            importers,
2854            packages: BTreeMap::new(),
2855            skipped_optional_dependencies: skipped,
2856            ..Default::default()
2857        };
2858        let subset = graph.subset_to_importer("packages/lib", |_| true).unwrap();
2859        assert_eq!(subset.skipped_optional_dependencies.len(), 1);
2860        let root = subset.skipped_optional_dependencies.get(".").unwrap();
2861        assert!(root.contains_key("fsevents"));
2862        assert!(!root.contains_key("ghost"));
2863    }
2864
2865    #[test]
2866    fn workspace_drift_fresh_when_all_importers_match() {
2867        let root_dep = DirectDep {
2868            name: "lodash".into(),
2869            dep_path: "lodash@4.17.21".into(),
2870            dep_type: DepType::Production,
2871            specifier: Some("^4.17.0".into()),
2872        };
2873        let app_dep = DirectDep {
2874            name: "express".into(),
2875            dep_path: "express@4.18.0".into(),
2876            dep_type: DepType::Production,
2877            specifier: Some("^4.18.0".into()),
2878        };
2879        let mut importers = BTreeMap::new();
2880        importers.insert(".".to_string(), vec![root_dep]);
2881        importers.insert("packages/app".to_string(), vec![app_dep]);
2882        let graph = LockfileGraph {
2883            importers,
2884            packages: BTreeMap::new(),
2885            ..Default::default()
2886        };
2887
2888        let workspace_manifests = vec![
2889            (".".to_string(), make_manifest(&[("lodash", "^4.17.0")])),
2890            (
2891                "packages/app".to_string(),
2892                make_manifest(&[("express", "^4.18.0")]),
2893            ),
2894        ];
2895        assert_eq!(
2896            graph.check_drift_workspace(&workspace_manifests, &BTreeMap::new(), &[]),
2897            DriftStatus::Fresh
2898        );
2899    }
2900
2901    #[allow(clippy::type_complexity)]
2902    fn mk_catalogs(
2903        entries: &[(&str, &[(&str, &str, &str)])],
2904    ) -> BTreeMap<String, BTreeMap<String, CatalogEntry>> {
2905        let mut out: BTreeMap<String, BTreeMap<String, CatalogEntry>> = BTreeMap::new();
2906        for (cat, pkgs) in entries {
2907            let mut inner = BTreeMap::new();
2908            for (pkg, spec, ver) in *pkgs {
2909                inner.insert(
2910                    (*pkg).to_string(),
2911                    CatalogEntry {
2912                        specifier: (*spec).to_string(),
2913                        version: (*ver).to_string(),
2914                    },
2915                );
2916            }
2917            out.insert((*cat).to_string(), inner);
2918        }
2919        out
2920    }
2921
2922    fn mk_workspace_catalogs(
2923        entries: &[(&str, &[(&str, &str)])],
2924    ) -> BTreeMap<String, BTreeMap<String, String>> {
2925        entries
2926            .iter()
2927            .map(|(cat, pkgs)| {
2928                (
2929                    (*cat).to_string(),
2930                    pkgs.iter()
2931                        .map(|(p, s)| ((*p).to_string(), (*s).to_string()))
2932                        .collect(),
2933                )
2934            })
2935            .collect()
2936    }
2937
2938    #[test]
2939    fn catalog_drift_fresh_when_specifiers_match() {
2940        let graph = LockfileGraph {
2941            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2942            ..Default::default()
2943        };
2944        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18.0.0")])]);
2945        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2946    }
2947
2948    #[test]
2949    fn catalog_drift_stale_on_changed_specifier() {
2950        let graph = LockfileGraph {
2951            catalogs: mk_catalogs(&[("default", &[("react", "^18.0.0", "18.2.0")])]),
2952            ..Default::default()
2953        };
2954        let ws = mk_workspace_catalogs(&[("default", &[("react", "^19.0.0")])]);
2955        match graph.check_catalogs_drift(&ws) {
2956            DriftStatus::Stale { reason } => assert!(reason.contains("react")),
2957            other => panic!("expected stale, got {other:?}"),
2958        }
2959    }
2960
2961    #[test]
2962    fn catalog_drift_fresh_when_workspace_adds_unused_entry() {
2963        // pnpm only writes referenced entries — an unreferenced
2964        // workspace entry is not drift. The "newly used" transition
2965        // is caught by the importer-level drift check.
2966        let graph = LockfileGraph::default();
2967        let ws = mk_workspace_catalogs(&[("default", &[("react", "^18")])]);
2968        assert_eq!(graph.check_catalogs_drift(&ws), DriftStatus::Fresh);
2969    }
2970
2971    #[test]
2972    fn catalog_drift_stale_on_removed_workspace_entry() {
2973        let graph = LockfileGraph {
2974            catalogs: mk_catalogs(&[("default", &[("react", "^18", "18.2.0")])]),
2975            ..Default::default()
2976        };
2977        let ws = mk_workspace_catalogs(&[]);
2978        assert!(matches!(
2979            graph.check_catalogs_drift(&ws),
2980            DriftStatus::Stale { .. }
2981        ));
2982    }
2983}