diff --git a/pacquet/Cargo.lock b/pacquet/Cargo.lock index 2e02780de0..6a193e9c21 100644 --- a/pacquet/Cargo.lock +++ b/pacquet/Cargo.lock @@ -2155,6 +2155,7 @@ dependencies = [ "dunce", "futures-util", "httpdate", + "indexmap", "insta", "miette 7.6.0", "node-semver", @@ -2170,6 +2171,7 @@ dependencies = [ "pacquet-package-is-installable", "pacquet-package-manifest", "pacquet-patching", + "pacquet-real-hoist", "pacquet-registry", "pacquet-registry-mock", "pacquet-reporter", diff --git a/pacquet/crates/package-manager/Cargo.toml b/pacquet/crates/package-manager/Cargo.toml index fc71a14d02..feaa4eddb3 100644 --- a/pacquet/crates/package-manager/Cargo.toml +++ b/pacquet/crates/package-manager/Cargo.toml @@ -23,6 +23,7 @@ pacquet-graph-hasher = { workspace = true } pacquet-package-manifest = { workspace = true } pacquet-package-is-installable = { workspace = true } pacquet-patching = { workspace = true } +pacquet-real-hoist = { workspace = true } pacquet-registry = { workspace = true } pacquet-reporter = { workspace = true } pacquet-store-dir = { workspace = true } @@ -34,6 +35,7 @@ dashmap = { workspace = true } derive_more = { workspace = true } futures-util = { workspace = true } httpdate = { workspace = true } +indexmap = { workspace = true } node-semver = { workspace = true } pipe-trait = { workspace = true } rayon = { workspace = true } diff --git a/pacquet/crates/package-manager/src/hoisted_dep_graph.rs b/pacquet/crates/package-manager/src/hoisted_dep_graph.rs index dfc3e040be..68124f55a3 100644 --- a/pacquet/crates/package-manager/src/hoisted_dep_graph.rs +++ b/pacquet/crates/package-manager/src/hoisted_dep_graph.rs @@ -5,11 +5,14 @@ //! and the supporting types factored into //! [`deps/graph-builder/src/lockfileToDepGraph.ts`](https://github.com/pnpm/pnpm/blob/94240bc046/deps/graph-builder/src/lockfileToDepGraph.ts). //! -//! This module is types-only. The walker that produces a result -//! from a lockfile and a `pacquet_real_hoist::HoisterResult` -//! lands in a follow-up; the types are pinned here first so the -//! walker, the installability filter, and the eventual linker can -//! all be reviewed against a fixed shape. +//! The walker [`lockfile_to_hoisted_dep_graph`] takes a lockfile +//! and runs `pacquet_real_hoist::hoist` to get the directory shape, +//! then assembles a [`LockfileToDepGraphResult`] keyed by the +//! computed absolute directory of every node. Store I/O +//! (`fetching` / `files_index_file`), the installability check, +//! and the `prev_graph` diff still land in follow-ups; this walker +//! produces a correct graph topology for the eventual store +//! integration to layer fetch results onto. //! //! Unlike the depPath-keyed [`crate::deps_graph`] module (which is //! a hashing-side adapter for the build cache), the graph defined @@ -19,12 +22,16 @@ //! to nest. Hoisting decisions are made at directory granularity, //! not depPath granularity. -use pacquet_lockfile::LockfileResolution; +use derive_more::{Display, Error, From}; +use indexmap::IndexSet; +use miette::Diagnostic; +use pacquet_lockfile::{Lockfile, LockfileResolution, PackageKey, ParsePkgNameVerPeerError}; use pacquet_modules_yaml::DepPath; use pacquet_patching::PatchInfo; +use pacquet_real_hoist::{HoistError, HoistOpts, HoisterResult, RcByPtr, hoist}; use std::{ collections::{BTreeMap, BTreeSet}, - path::PathBuf, + path::{Path, PathBuf}, }; /// One node in a hoisted-linker dependency graph. Keyed in the @@ -174,6 +181,331 @@ pub struct LockfileToHoistedDepGraphOptions { pub skipped: BTreeSet, } +/// Failure modes of [`lockfile_to_hoisted_dep_graph`]. Marked +/// `#[non_exhaustive]` so adding variants in later sub-slices (the +/// installability filter, the store-fetch integration) isn't a +/// breaking API change. +#[derive(Debug, Display, Error, Diagnostic, From)] +#[non_exhaustive] +pub enum HoistedDepGraphError { + /// The hoister refused the lockfile (broken snapshot, + /// unsupported workspace, etc.). Surfaced verbatim so callers + /// see the same error code as upstream. + #[display("{_0}")] + Hoist(#[error(source)] HoistError), + /// A `HoisterResult` node carried a reference string that + /// doesn't parse as a `name@version[(peers)]` package key. + /// Should never happen for hoister output produced from a + /// valid lockfile — the hoister only emits references it + /// already validated — but the conversion is fallible at the + /// type level, so a typed error is the honest surface. + #[display("Unparsable snapshot reference {reference:?} on hoisted node")] + #[diagnostic(code(ERR_PACQUET_HOISTED_GRAPH_BAD_REFERENCE))] + BadReference { + reference: String, + #[error(source)] + source: ParsePkgNameVerPeerError, + }, +} + +/// Build a directory-keyed [`LockfileToDepGraphResult`] from a +/// lockfile by running the hoist algorithm and walking the +/// resulting tree. Ports upstream's +/// [`lockfileToHoistedDepGraph`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L65-L85) +/// minus the store-controller-bound fetch step (the +/// `DependenciesGraphNode` fields that depend on the store +/// remain default-valued; a follow-up wires the fetch in) and +/// minus the `currentLockfile`-driven `prev_graph` diff (lands in +/// the same follow-up that adds the orphan-removal pass to the +/// linker). +/// +/// Single-importer only today — multi-importer (workspace) +/// lockfiles surface as `HoistError::UnsupportedWorkspace` via +/// the hoister. +pub fn lockfile_to_hoisted_dep_graph( + lockfile: &Lockfile, + opts: &LockfileToHoistedDepGraphOptions, +) -> Result { + let hoist_opts = + HoistOpts { auto_install_peers: opts.auto_install_peers, ..HoistOpts::default() }; + let hoister_result = hoist(lockfile, &hoist_opts)?; + + let modules_dir = opts.lockfile_dir.join("node_modules"); + let mut state = WalkState { + lockfile, + lockfile_dir: &opts.lockfile_dir, + skipped: &opts.skipped, + graph: DependenciesGraph::new(), + pkg_locations_by_dep_path: BTreeMap::new(), + hoisted_locations: BTreeMap::new(), + injection_targets_by_dep_path: BTreeMap::new(), + }; + let root_deps = hoister_result.dependencies.borrow(); + let root_hierarchy = walk_deps(&mut state, &modules_dir, &root_deps)?; + drop(root_deps); + + // Pass 2 — fill in each node's `children` map from the + // now-complete `pkg_locations_by_dep_path`. Mirrors upstream's + // post-await `graph[dir].children = getChildren(...)` line. + // + // The walk above intentionally leaves `children` empty: in + // upstream's parallel-async walker, every sibling and + // descendant of a node has its directory pushed to + // `pkgLocationsByDepPath` during the sync prologue of its + // `async (dep) => { ... }` body, *before* any continuation + // (the post-recursion `getChildren` call) runs. So by the + // time any node computes its children, the location index is + // already complete. Pacquet runs synchronously, so the + // simplest way to preserve that invariant is to insert + // everything first and resolve children second. + let WalkState { + graph, + pkg_locations_by_dep_path, + hoisted_locations, + injection_targets_by_dep_path, + lockfile, + .. + } = state; + let mut graph = graph; + fill_children(&mut graph, &pkg_locations_by_dep_path, lockfile)?; + + // The hoister produced a children order; the directory keys in + // `root_hierarchy` follow it. `direct_dependencies_by_importer_id["."]` + // mirrors upstream's `directDepsMap` at + // . + let mut direct_deps_root: BTreeMap = BTreeMap::new(); + for child_dir in root_hierarchy.0.keys() { + if let Some(alias) = graph.get(child_dir).and_then(|node| node.alias.as_deref()) { + direct_deps_root.insert(alias.to_string(), child_dir.clone()); + } + } + let mut direct_dependencies_by_importer_id: DirectDependenciesByImporterId = BTreeMap::new(); + direct_dependencies_by_importer_id + .insert(Lockfile::ROOT_IMPORTER_KEY.to_string(), direct_deps_root); + + let mut hierarchy = BTreeMap::new(); + hierarchy.insert(opts.lockfile_dir.clone(), root_hierarchy); + + Ok(LockfileToDepGraphResult { + graph, + direct_dependencies_by_importer_id, + hierarchy, + hoisted_locations, + symlinked_direct_dependencies_by_importer_id: DirectDependenciesByImporterId::new(), + prev_graph: None, + injection_targets_by_dep_path, + }) +} + +/// Second walker pass: with every node's directory already in +/// `pkg_locations`, resolve each graph node's `children: alias → +/// dir` map by looking up the node's snapshot in the lockfile. +fn fill_children( + graph: &mut DependenciesGraph, + pkg_locations: &BTreeMap>, + lockfile: &Lockfile, +) -> Result<(), HoistedDepGraphError> { + let dirs: Vec = graph.keys().cloned().collect(); + for dir in dirs { + let reference = graph[&dir].dep_path.as_str().to_string(); + let pkg_key: PackageKey = match reference.parse() { + Ok(key) => key, + Err(source) => { + return Err(HoistedDepGraphError::BadReference { reference, source }); + } + }; + let snapshot = lockfile.snapshots.as_ref().and_then(|m| m.get(&pkg_key)); + let children = compute_children(snapshot, pkg_locations); + if let Some(node) = graph.get_mut(&dir) { + node.children = children; + } + } + Ok(()) +} + +/// Mutable scratch space the recursive walker threads through +/// every level. Borrowing the lockfile + lockfile_dir + skipped +/// up front avoids passing five separate arguments. +struct WalkState<'a> { + lockfile: &'a Lockfile, + lockfile_dir: &'a Path, + skipped: &'a BTreeSet, + graph: DependenciesGraph, + /// Records every directory each depPath landed in, in visit + /// order. The first entry wins for parent → child wiring (see + /// upstream `getChildren`). + pkg_locations_by_dep_path: BTreeMap>, + hoisted_locations: BTreeMap>, + injection_targets_by_dep_path: BTreeMap>, +} + +/// Recursive walker over `HoisterResult.dependencies`. Mirrors +/// upstream's +/// [`fetchDeps`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L168-L296) +/// minus the store-fetch / installability path; here the walker +/// only computes node identity, location, children, and +/// hoisted-location records. +/// +/// No cycle detection — matches upstream's recursion shape and +/// trusts the hoister to produce a DAG. The hoister's own +/// cyclic-input tests pin that property. +fn walk_deps( + state: &mut WalkState<'_>, + modules: &Path, + deps: &IndexSet>, +) -> Result { + let mut hierarchy: BTreeMap = BTreeMap::new(); + for dep in deps { + // The hoister keeps every absorbed reference; the first + // (alphabetically smallest) is the canonical depPath for + // this node's location. Mirrors upstream's + // `Array.from(dep.references)[0]`. + let reference = match dep.0.references.borrow().iter().next().cloned() { + Some(r) => r, + None => continue, + }; + + if state.skipped.contains(&reference) || reference.starts_with("workspace:") { + continue; + } + + let pkg_key: PackageKey = match reference.parse() { + Ok(key) => key, + Err(source) => { + return Err(HoistedDepGraphError::BadReference { reference, source }); + } + }; + + // `packages[key]` is the metadata source; absent → this is + // a link / external placeholder that the wrapper strips, + // and the walker mirrors upstream's `if (!pkgSnapshot) return` + // by skipping. + let Some(metadata) = lookup_package_metadata(state.lockfile, &pkg_key) else { + continue; + }; + let snapshot = + state.lockfile.snapshots.as_ref().and_then(|snapshots| snapshots.get(&pkg_key)); + + let dir = modules.join(&dep.0.name); + let dep_location = path_relative_to_lockfile_dir(&dir, state.lockfile_dir); + + // Insert *before* recursing — mirrors upstream's + // `fetchDeps` body order (insert + push to pkgLocations, + // then `await fetchDeps(...)`). `children` is filled in + // by `fill_children` after the whole walk is done. + let node = DependenciesGraphNode { + alias: Some(dep.0.name.clone()), + dep_path: DepPath::from(reference.clone()), + pkg_id_with_patch_hash: pkg_key.to_string(), + dir: dir.clone(), + modules: modules.to_path_buf(), + children: BTreeMap::new(), + name: pkg_key.name.to_string(), + version: pkg_key.suffix.version().to_string(), + optional: snapshot.map(|s| s.optional).unwrap_or(false), + optional_dependencies: snapshot + .and_then(|s| s.optional_dependencies.as_ref()) + .map(|m| m.keys().map(|k| k.to_string()).collect()) + .unwrap_or_default(), + has_bin: metadata.has_bin.unwrap_or(false), + has_bundled_dependencies: metadata.bundled_dependencies.is_some(), + patch: None, + resolution: metadata.resolution.clone(), + }; + + state.graph.insert(dir.clone(), node); + state.pkg_locations_by_dep_path.entry(reference.clone()).or_default().push(dir.clone()); + + // Directory resolutions are injected workspace packages. + // Upstream records every dir an injected dep lands in for + // the post-install re-mirror step; mirrored here so a + // future re-mirror pass has the same input shape. + if let LockfileResolution::Directory(_) = &metadata.resolution { + state + .injection_targets_by_dep_path + .entry(reference.clone()) + .or_default() + .push(dir.clone()); + } + + // Recurse into the children (records their pkg_locations + // and produces their `DepHierarchy`). + let inner_modules = dir.join("node_modules"); + let child_deps = dep.0.dependencies.borrow(); + let inner_hierarchy = walk_deps(state, &inner_modules, &child_deps)?; + drop(child_deps); + + // `hoistedLocations` is pushed AFTER the recursion, matching + // upstream. The pre-recursion sites that mutate state are + // for graph/index identity; this one is the user-visible + // location list that the linker consumes. + state.hoisted_locations.entry(reference).or_default().push(dep_location); + hierarchy.insert(dir, inner_hierarchy); + } + Ok(DepHierarchy(hierarchy)) +} + +/// Look up the metadata side of a snapshot. Pacquet stores +/// `packages` and `snapshots` separately; the walker needs the +/// metadata for resolution / has_bin / bundledDependencies (which +/// upstream pulls from `pkgSnapshot`). +fn lookup_package_metadata<'a>( + lockfile: &'a Lockfile, + key: &PackageKey, +) -> Option<&'a pacquet_lockfile::PackageMetadata> { + lockfile.packages.as_ref()?.get(key) +} + +/// Lockfile-relative path string, matching upstream's +/// `path.relative(lockfileDir, dir)`. Returns an empty string when +/// `dir == lockfile_dir`. +/// +/// Backslashes are normalized to forward slashes so the value is +/// portable across platforms — `.modules.yaml.hoistedLocations` +/// is read on whatever OS the next install runs on, and pnpm's +/// `pnpm-lock.yaml` already uses forward slashes for the same +/// reason. Upstream's `path.relative` produces OS-native +/// separators (so `.modules.yaml` written on Windows technically +/// holds backslashes), but pacquet normalizes here for +/// cross-platform consistency with the rest of pnpm's serialised +/// formats. +fn path_relative_to_lockfile_dir(dir: &Path, lockfile_dir: &Path) -> String { + dir.strip_prefix(lockfile_dir) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| dir.to_string_lossy().replace('\\', "/")) +} + +/// Compute the `children: alias → dir` map for a node. Mirrors +/// upstream's +/// [`getChildren`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L320-L334): +/// look up every direct (and optional, with `include` always on +/// here) dep of the snapshot, resolve it to its depPath via +/// `SnapshotDepRef::resolve`, and take the first recorded +/// location. +fn compute_children( + snapshot: Option<&pacquet_lockfile::SnapshotEntry>, + pkg_locations: &BTreeMap>, +) -> BTreeMap { + let mut children: BTreeMap = BTreeMap::new(); + let Some(snapshot) = snapshot else { return children }; + + let dep_iter = snapshot + .dependencies + .iter() + .flatten() + .chain(snapshot.optional_dependencies.iter().flatten()); + for (alias_name, dep_ref) in dep_iter { + let child_key = dep_ref.resolve(alias_name); + let child_dep_path = child_key.to_string(); + if let Some(locations) = pkg_locations.get(&child_dep_path) + && let Some(first) = locations.first() + { + children.insert(alias_name.to_string(), first.clone()); + } + } + children +} + #[cfg(test)] mod tests { use super::{ @@ -280,4 +612,315 @@ mod tests { assert!(!opts.auto_install_peers); assert!(opts.skipped.is_empty()); } + + // --- Walker tests ---------------------------------------------------- + + use super::lockfile_to_hoisted_dep_graph; + use pacquet_lockfile::{ + ComVer, Lockfile, LockfileSettings, LockfileVersion, PackageKey, PackageMetadata, PkgName, + PkgNameVerPeer, PkgVerPeer, ProjectSnapshot, ResolvedDependencyMap, ResolvedDependencySpec, + SnapshotDepRef, SnapshotEntry, + }; + use std::collections::HashMap; + + fn lockfile_version() -> LockfileVersion<9> { + LockfileVersion::<9>::try_from(ComVer::new(9, 0)) + .expect("lockfileVersion 9.0 is compatible") + } + + fn pkg_name(s: &str) -> PkgName { + PkgName::parse(s).expect("parse PkgName") + } + + fn ver_peer(s: &str) -> PkgVerPeer { + s.parse::().expect("parse PkgVerPeer") + } + + fn dep_key(name: &str, version: &str) -> PkgNameVerPeer { + PkgNameVerPeer::new(pkg_name(name), ver_peer(version)) + } + + fn resolved_dep(version: &str) -> ResolvedDependencySpec { + ResolvedDependencySpec { specifier: version.to_string(), version: ver_peer(version).into() } + } + + fn directory_resolution(directory: &str) -> LockfileResolution { + DirectoryResolution { directory: directory.to_string() }.into() + } + + /// Build a metadata stub for a package using a synthetic + /// `directory:` resolution. Walker tests don't exercise + /// resolution semantics — they only need *some* resolution so + /// the graph node has a non-default value to inspect. + fn metadata_stub() -> PackageMetadata { + PackageMetadata { + resolution: directory_resolution("/dev/null/stub"), + engines: None, + cpu: None, + os: None, + libc: None, + deprecated: None, + has_bin: None, + prepare: None, + bundled_dependencies: None, + peer_dependencies: None, + peer_dependencies_meta: None, + } + } + + fn lockfile_with( + importer_deps: ResolvedDependencyMap, + packages: HashMap, + snapshots: HashMap, + ) -> Lockfile { + let mut importers = HashMap::new(); + importers.insert( + Lockfile::ROOT_IMPORTER_KEY.to_string(), + ProjectSnapshot { dependencies: Some(importer_deps), ..ProjectSnapshot::default() }, + ); + Lockfile { + lockfile_version: lockfile_version(), + settings: Some(LockfileSettings::default()), + overrides: None, + importers, + packages: Some(packages), + snapshots: Some(snapshots), + } + } + + /// A lockfile with no importers walks to an empty graph and a + /// hierarchy with no root entry. Mirrors the + /// `empty_lockfile_yields_empty_root` case from the hoister. + #[test] + fn walker_empty_lockfile_produces_empty_result() { + let lockfile = Lockfile { + lockfile_version: lockfile_version(), + settings: Some(LockfileSettings::default()), + overrides: None, + importers: HashMap::new(), + packages: None, + snapshots: None, + }; + let opts = LockfileToHoistedDepGraphOptions { + lockfile_dir: PathBuf::from("/repo"), + ..LockfileToHoistedDepGraphOptions::default() + }; + let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("empty lockfile walks"); + + assert!(result.graph.is_empty(), "graph should be empty"); + assert!(result.hoisted_locations.is_empty(), "no locations recorded"); + // `direct_dependencies_by_importer_id["."]` is always + // present (the root importer is implicit), but its inner + // map is empty when there are no children. + assert_eq!(result.direct_dependencies_by_importer_id.len(), 1); + assert!(result.direct_dependencies_by_importer_id[Lockfile::ROOT_IMPORTER_KEY].is_empty()); + } + + /// `root → a` with `a` having no transitive deps: walker emits + /// a single graph node at `/node_modules/a`, + /// populates `hoisted_locations["a@1.0.0"]`, and records `a` as + /// the root's only direct dep. + #[test] + fn walker_single_root_dep_emits_one_node() { + let mut root_deps = ResolvedDependencyMap::new(); + root_deps.insert(pkg_name("a"), resolved_dep("1.0.0")); + + let mut packages = HashMap::new(); + packages.insert(dep_key("a", "1.0.0"), metadata_stub()); + + let mut snapshots = HashMap::new(); + snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default()); + + let lockfile = lockfile_with(root_deps, packages, snapshots); + let lockfile_dir = PathBuf::from("/repo"); + let opts = LockfileToHoistedDepGraphOptions { + lockfile_dir: lockfile_dir.clone(), + ..LockfileToHoistedDepGraphOptions::default() + }; + let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds"); + + let expected_dir = lockfile_dir.join("node_modules").join("a"); + assert_eq!( + result.graph.len(), + 1, + "one node emitted: {:?}", + result.graph.keys().collect::>(), + ); + let node = result.graph.get(&expected_dir).expect("node keyed by dir"); + assert_eq!(node.alias.as_deref(), Some("a")); + assert_eq!(node.dep_path, DepPath::from("a@1.0.0".to_string())); + assert_eq!(node.name, "a"); + assert_eq!(node.version, "1.0.0"); + + assert_eq!(result.hoisted_locations["a@1.0.0"], vec!["node_modules/a".to_string()]); + assert_eq!( + result.direct_dependencies_by_importer_id[Lockfile::ROOT_IMPORTER_KEY]["a"], + expected_dir, + ); + } + + /// `root → a → b` (no name conflict): the hoister flattens `b` + /// to root, and the walker emits two graph nodes — both under + /// `/node_modules/`. `a`'s `children["b"]` points + /// at `b`'s root-level directory (not `a/node_modules/b`), + /// because the hoister moved it there. + #[test] + fn walker_transitive_dep_flattens_under_root() { + let mut root_deps = ResolvedDependencyMap::new(); + root_deps.insert(pkg_name("a"), resolved_dep("1.0.0")); + + let mut packages = HashMap::new(); + packages.insert(dep_key("a", "1.0.0"), metadata_stub()); + packages.insert(dep_key("b", "1.0.0"), metadata_stub()); + + let mut snapshots = HashMap::new(); + let mut a_deps = HashMap::new(); + a_deps.insert(pkg_name("b"), SnapshotDepRef::Plain(ver_peer("1.0.0"))); + snapshots.insert( + dep_key("a", "1.0.0"), + SnapshotEntry { dependencies: Some(a_deps), ..SnapshotEntry::default() }, + ); + snapshots.insert(dep_key("b", "1.0.0"), SnapshotEntry::default()); + + let lockfile = lockfile_with(root_deps, packages, snapshots); + let lockfile_dir = PathBuf::from("/repo"); + let opts = LockfileToHoistedDepGraphOptions { + lockfile_dir: lockfile_dir.clone(), + ..LockfileToHoistedDepGraphOptions::default() + }; + let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds"); + + let modules = lockfile_dir.join("node_modules"); + assert_eq!( + result.graph.keys().cloned().collect::>(), + vec![modules.join("a"), modules.join("b")], + "both nodes hoisted to root, sorted by dir", + ); + let a_node = result.graph.get(&modules.join("a")).expect("a in graph"); + assert_eq!( + a_node.children.get("b"), + Some(&modules.join("b")), + "a's `children[\"b\"]` points at the hoisted (root-level) dir", + ); + + // Both depPaths recorded at the root level only — no + // nesting needed because there's no version conflict. + assert_eq!(result.hoisted_locations["a@1.0.0"], vec!["node_modules/a".to_string()]); + assert_eq!(result.hoisted_locations["b@1.0.0"], vec!["node_modules/b".to_string()]); + } + + /// Version conflict: `root → {a@1, c}` plus `c → a@2`. `a@1` + /// gets the root slot; `a@2` stays nested under `c`. The + /// walker should record `a@1.0.0` at root and `a@2.0.0` at + /// `node_modules/c/node_modules/a`. + #[test] + fn walker_version_conflict_keeps_loser_nested() { + let mut root_deps = ResolvedDependencyMap::new(); + root_deps.insert(pkg_name("a"), resolved_dep("1.0.0")); + root_deps.insert(pkg_name("c"), resolved_dep("1.0.0")); + + let mut packages = HashMap::new(); + packages.insert(dep_key("a", "1.0.0"), metadata_stub()); + packages.insert(dep_key("a", "2.0.0"), metadata_stub()); + packages.insert(dep_key("c", "1.0.0"), metadata_stub()); + + let mut snapshots = HashMap::new(); + snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default()); + snapshots.insert(dep_key("a", "2.0.0"), SnapshotEntry::default()); + let mut c_deps = HashMap::new(); + c_deps.insert(pkg_name("a"), SnapshotDepRef::Plain(ver_peer("2.0.0"))); + snapshots.insert( + dep_key("c", "1.0.0"), + SnapshotEntry { dependencies: Some(c_deps), ..SnapshotEntry::default() }, + ); + + let lockfile = lockfile_with(root_deps, packages, snapshots); + let lockfile_dir = PathBuf::from("/repo"); + let opts = LockfileToHoistedDepGraphOptions { + lockfile_dir: lockfile_dir.clone(), + ..LockfileToHoistedDepGraphOptions::default() + }; + let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds"); + + let modules = lockfile_dir.join("node_modules"); + let a1_dir = modules.join("a"); + let c_dir = modules.join("c"); + let a2_dir = c_dir.join("node_modules").join("a"); + + assert!(result.graph.contains_key(&a1_dir), "a@1 at root"); + assert!(result.graph.contains_key(&c_dir), "c at root"); + assert!(result.graph.contains_key(&a2_dir), "a@2 nested under c"); + + assert_eq!(result.graph[&a1_dir].dep_path, DepPath::from("a@1.0.0".to_string())); + assert_eq!(result.graph[&a2_dir].dep_path, DepPath::from("a@2.0.0".to_string())); + + assert_eq!(result.hoisted_locations["a@1.0.0"], vec!["node_modules/a".to_string()]); + assert_eq!( + result.hoisted_locations["a@2.0.0"], + vec!["node_modules/c/node_modules/a".to_string()], + ); + + // `c`'s `children["a"]` points at the nested `a@2`, not the + // root's `a@1` — because hoisting kept the nested slot. + assert_eq!(result.graph[&c_dir].children.get("a"), Some(&a2_dir)); + } + + /// Pre-`skipped` packages aren't emitted into the graph at all. + /// Upstream's walker honors the input `skipped` set without + /// re-checking installability; pacquet's walker does the same. + #[test] + fn walker_honors_pre_skipped_dep_path() { + let mut root_deps = ResolvedDependencyMap::new(); + root_deps.insert(pkg_name("a"), resolved_dep("1.0.0")); + + let mut packages = HashMap::new(); + packages.insert(dep_key("a", "1.0.0"), metadata_stub()); + + let mut snapshots = HashMap::new(); + snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default()); + + let lockfile = lockfile_with(root_deps, packages, snapshots); + let mut skipped = BTreeSet::new(); + skipped.insert("a@1.0.0".to_string()); + let opts = LockfileToHoistedDepGraphOptions { + lockfile_dir: PathBuf::from("/repo"), + auto_install_peers: false, + skipped, + }; + let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds"); + + assert!(result.graph.is_empty(), "skipped dep not emitted"); + assert!(result.hoisted_locations.is_empty()); + } + + /// A `directory:` resolution gets recorded in + /// `injection_targets_by_dep_path` so the post-install + /// re-mirror step (a later sub-slice) can find it. + #[test] + fn walker_records_directory_resolution_as_injection_target() { + let mut root_deps = ResolvedDependencyMap::new(); + root_deps.insert(pkg_name("a"), resolved_dep("1.0.0")); + + let mut packages = HashMap::new(); + packages.insert( + dep_key("a", "1.0.0"), + PackageMetadata { resolution: directory_resolution("../local-a"), ..metadata_stub() }, + ); + + let mut snapshots = HashMap::new(); + snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default()); + + let lockfile = lockfile_with(root_deps, packages, snapshots); + let lockfile_dir = PathBuf::from("/repo"); + let opts = LockfileToHoistedDepGraphOptions { + lockfile_dir: lockfile_dir.clone(), + ..LockfileToHoistedDepGraphOptions::default() + }; + let result = lockfile_to_hoisted_dep_graph(&lockfile, &opts).expect("walker succeeds"); + + assert_eq!( + result.injection_targets_by_dep_path["a@1.0.0"], + vec![lockfile_dir.join("node_modules").join("a")], + ); + } }