feat: .modules.yaml (#332)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Khải
2026-05-08 03:38:27 +07:00
committed by GitHub
parent 27e3e16810
commit 2af4cf89a4
9 changed files with 1145 additions and 160 deletions

61
pacquet/Cargo.lock generated
View File

@@ -48,6 +48,15 @@ dependencies = [
"cc",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
@@ -288,6 +297,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@@ -1115,6 +1135,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.2.0"
@@ -1863,11 +1907,20 @@ dependencies = [
name = "pacquet-modules-yaml"
version = "0.0.1"
dependencies = [
"chrono",
"derive_more",
"httpdate",
"indexmap",
"pacquet-diagnostics",
"pacquet-testing-utils",
"pathdiff",
"pipe-trait",
"pretty_assertions",
"serde",
"serde-saphyr",
"serde_json",
"tempfile",
"text-block-macros",
]
[[package]]
@@ -2087,6 +2140,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2601,7 +2660,7 @@ checksum = "75e214449d107a81daf1453eb46c9314457660509534883e82db6faca2034a8a"
dependencies = [
"ahash",
"annotate-snippets",
"base64 0.21.7",
"base64 0.22.1",
"encoding_rs_io",
"getrandom 0.3.4",
"nohash-hasher",

View File

@@ -41,6 +41,8 @@ dashmap = { version = "6.1.0" }
derive_more = { version = "2.1.1", features = ["full"] }
dunce = { version = "1.0.5" }
home = { version = "0.5.12" }
httpdate = { version = "1.0.3" }
indexmap = { version = "2.14.0", features = ["serde"] }
insta = { version = "1.47.2", features = ["yaml", "glob", "walkdir"] }
itertools = { version = "0.14.0" }
futures-util = { version = "0.3.32" }
@@ -57,6 +59,7 @@ reqwest = { version = "0.13", default-features = false, features = [
"stream",
] }
node-semver = { version = "2.2.0" }
pathdiff = { version = "0.2.3" }
pipe-trait = { version = "0.4.0" }
rayon = { version = "1.12.0" }
rmp-serde = { version = "1.3.0" }
@@ -83,6 +86,7 @@ zune-inflate = { version = "0.2.54" }
# Dev dependencies
assert_cmd = { version = "2.2.1" }
chrono = { version = "0.4.44", default-features = false, features = ["clock"] }
criterion = { version = "0.8.2", features = ["async_tokio"] }
pretty_assertions = { version = "1.4.1" }
project-root = { version = "0.2.2" }

View File

@@ -11,11 +11,21 @@ license.workspace = true
repository.workspace = true
[dependencies]
pacquet-diagnostics = { workspace = true }
derive_more = { workspace = true }
httpdate = { workspace = true }
indexmap = { workspace = true }
pathdiff = { workspace = true }
pipe-trait = { workspace = true }
serde = { workspace = true }
serde-saphyr = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
pacquet-testing-utils = { workspace = true }
pipe-trait = { workspace = true }
chrono = { workspace = true }
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
text-block-macros = { workspace = true }

View File

@@ -1,5 +1,491 @@
//! Read and write pnpm's `node_modules/.modules.yaml` manifest.
//!
//! The implementation is intentionally pending; the test suite currently holds
//! known-failure ports from pnpm's TypeScript `@pnpm/installing.modules-yaml`
//! package.
//! Mirrors pnpm v11's `installing/modules-yaml` package. See upstream
//! <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts>.
//!
//! The manifest is stored at `<modules_dir>/.modules.yaml`, where
//! `modules_dir` is the path of a `node_modules` directory. The on-disk
//! format is JSON (which YAML accepts), so reads use a YAML parser and
//! writes emit [`serde_json::to_string_pretty`] output to match pnpm exactly.
use derive_more::{Display, Error, From, Into};
use indexmap::IndexSet;
use pacquet_diagnostics::miette::{self, Diagnostic};
use pipe_trait::Pipe;
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs, io, iter,
path::{Path, PathBuf},
time::SystemTime,
};
/// Filename of the modules manifest inside `node_modules/`.
///
/// The leading dot is required because `npm shrinkwrap` would otherwise
/// treat the file as an extraneous package. See upstream comment at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L15-L17>.
pub const MODULES_FILENAME: &str = ".modules.yaml";
/// Default value for the `virtualStoreDirMaxLength` field.
///
/// Matches pnpm's fallback at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L101-L103>.
pub const DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH: u64 = 120;
/// Capability trait: read a file's contents into a [`String`].
///
/// One trait per filesystem capability so each function declares only what
/// it actually uses, and so test fakes only implement the methods that
/// will be exercised. Pattern follows the per-capability typeclass style
/// rather than `parallel-disk-usage`'s lumped `FsApi` at
/// <https://github.com/KSXGitHub/parallel-disk-usage/blob/2aa39917f9/src/app/hdd.rs#L29-L35>.
pub trait FsReadToString {
fn read_to_string(path: &Path) -> io::Result<String>;
}
/// Capability trait: create a directory and any missing parents.
pub trait FsCreateDirAll {
fn create_dir_all(path: &Path) -> io::Result<()>;
}
/// Capability trait: write bytes to a file, replacing existing contents.
pub trait FsWrite {
fn write(path: &Path, contents: &[u8]) -> io::Result<()>;
}
/// Capability trait: read the current wall-clock time as a [`SystemTime`].
///
/// Mirrors upstream's `new Date()` call at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L98-L99>.
/// Decoupled from [`SystemTime::now`] so tests can fake the clock and
/// assert deterministic `prunedAt` values.
pub trait Clock {
fn now() -> SystemTime;
}
/// Production implementation, backed by [`std::fs`] and [`SystemTime::now`].
pub struct RealApi;
impl FsReadToString for RealApi {
#[inline]
fn read_to_string(path: &Path) -> io::Result<String> {
fs::read_to_string(path)
}
}
impl FsCreateDirAll for RealApi {
#[inline]
fn create_dir_all(path: &Path) -> io::Result<()> {
fs::create_dir_all(path)
}
}
impl FsWrite for RealApi {
#[inline]
fn write(path: &Path, contents: &[u8]) -> io::Result<()> {
fs::write(path, contents)
}
}
impl Clock for RealApi {
#[inline]
fn now() -> SystemTime {
SystemTime::now()
}
}
/// Newtype wrapper around a dependency-path string. Mirrors upstream's
/// `DepPath` branded type at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/core/types/src/misc.ts#L65>.
///
/// Upstream's `DepPath` is `string & { __brand: 'DepPath' }`, a branded
/// string. Every construction site uses an `as DepPath` cast — there are
/// no validating constructors anywhere in pnpm. The brand exists purely
/// to stop a plain `string` from being assigned where a `DepPath` is
/// expected at compile time. This Rust wrapper mirrors that contract: no
/// validation runs at construction, and `#[serde(transparent)]` makes the
/// wire format identical to `String` so a `DepPath` round-trips through
/// JSON / YAML the same way upstream's branded string does.
#[derive(
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, From, Into,
)]
#[serde(transparent)]
pub struct DepPath(String);
impl DepPath {
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Typed view of a `node_modules/.modules.yaml` manifest.
///
/// Mirrors upstream's normalized [`Modules`](https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L46-L48)
/// type, which is [`ModulesRaw`](https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L23-L44)
/// with `ignoredBuilds` widened from `DepPath[]` (the on-disk shape)
/// to `Set<DepPath>` (the in-memory shape). Pacquet collapses the two
/// upstream types into a single struct: serde handles the
/// array↔[`IndexSet`] conversion at the [`Self::ignored_builds`]
/// field via [`IndexSet`]'s deduplicating `Deserialize` impl, so a
/// separate raw-shape type is not needed. `IndexSet` (insertion-ordered)
/// is chosen over `HashSet` / `BTreeSet` to match JavaScript `Set`'s
/// iteration semantics — the on-disk array order round-trips
/// byte-for-byte.
///
/// Every required-by-upstream field carries a `#[serde(default)]` so
/// legacy manifests written by older pnpm versions still deserialize;
/// the read path then fills in the modern shape from the legacy fields.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Modules {
/// Legacy: the v5-era flat alias map, kept for read-side
/// compatibility. Replaced by [`Self::hoisted_dependencies`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hoisted_aliases: Option<BTreeMap<DepPath, Vec<String>>>,
/// Upstream's [`HoistedDependencies`](https://github.com/pnpm/pnpm/blob/1819226b51/core/types/src/misc.ts#L57)
/// is `Record<DepPath | ProjectId, ...>`. Pacquet keeps the key as
/// [`String`] because [`DepPath`] and `ProjectId` share the same
/// underlying type with no validation, so the union cannot be
/// disambiguated statically; the [`String`] type faithfully ports
/// upstream's union.
#[serde(default)]
pub hoisted_dependencies: BTreeMap<String, BTreeMap<String, HoistKind>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hoist_pattern: Option<Vec<String>>,
#[serde(default)]
pub included: IncludedDependencies,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout_version: Option<LayoutVersion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_linker: Option<NodeLinker>,
#[serde(default)]
pub package_manager: String,
#[serde(default)]
pub pending_builds: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignored_builds: Option<IndexSet<DepPath>>,
#[serde(default)]
pub pruned_at: String,
// TODO: upstream's `StrictModules` (which `writeModulesManifest`
// takes) tightens this to a required `Registries`. Revisit when
// the install-pipeline port supplies a producer that always
// populates `default`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registries: Option<BTreeMap<String, String>>,
/// Legacy: the v5-era flag used to mean "hoist everything publicly."
/// Replaced by [`Self::public_hoist_pattern`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shamefully_hoist: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_hoist_pattern: Option<Vec<String>>,
#[serde(default)]
pub skipped: Vec<String>,
#[serde(default)]
pub store_dir: String,
#[serde(default)]
pub virtual_store_dir: String,
#[serde(default)]
pub virtual_store_dir_max_length: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub injected_deps: Option<BTreeMap<String, Vec<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hoisted_locations: Option<BTreeMap<String, Vec<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_builds: Option<BTreeMap<String, AllowBuildValue>>,
}
/// Which dependency groups the install pipeline included. Mirrors
/// upstream's `IncludedDependencies` at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L19-L21>.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IncludedDependencies {
#[serde(default)]
pub dependencies: bool,
#[serde(default)]
pub dev_dependencies: bool,
#[serde(default)]
pub optional_dependencies: bool,
}
/// Linker variant the install pipeline used. The string variants match
/// pnpm's runtime values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeLinker {
Hoisted,
Isolated,
Pnp,
}
/// Pinned identifier for the `node_modules` layout pacquet emits, mirroring
/// upstream's `LAYOUT_VERSION` constant at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/core/constants/src/index.ts#L8>.
///
/// The unit type carries no data: its existence is the value. It serializes
/// as the integer `5` and deserializes only when the on-disk value is
/// exactly `5`. Any other version causes a deserialization error, mirroring
/// upstream's `checkCompatibility` reaction at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/deps-installer/src/install/checkCompatibility/index.ts#L18-L22>,
/// which throws `ModulesBreakingChangeError` for a missing or mismatched
/// `layoutVersion`. Wrapping this in [`Option`] on [`Modules`]
/// distinguishes "missing" (legacy, breaking change) from "present and
/// matching".
///
/// The `#[serde(try_from = "u32", into = "u32")]` proxy lets us reuse
/// serde's number deserializer, while the [`TryFrom`] impl owns the
/// "is this version supported" decision and returns
/// [`UnsupportedLayoutVersionError`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(try_from = "u32", into = "u32")]
pub struct LayoutVersion;
impl LayoutVersion {
/// The single layout version pacquet supports.
const VALUE: u32 = 5;
}
impl From<LayoutVersion> for u32 {
fn from(_: LayoutVersion) -> u32 {
LayoutVersion::VALUE
}
}
impl TryFrom<u32> for LayoutVersion {
type Error = UnsupportedLayoutVersionError;
fn try_from(value: u32) -> Result<Self, Self::Error> {
if value == LayoutVersion::VALUE {
Ok(Self)
} else {
Err(UnsupportedLayoutVersionError { found: value })
}
}
}
/// Returned by [`LayoutVersion::try_from`] when the on-disk `layoutVersion`
/// is not the one pacquet supports.
#[derive(Debug, Display, Error)]
#[display(
"Unsupported layout version {found}; this build of pacquet only supports layout version {}",
LayoutVersion::VALUE
)]
pub struct UnsupportedLayoutVersionError {
pub found: u32,
}
/// Per-alias visibility selected by the legacy `shamefullyHoist` flag.
/// Serializes as `"public"` or `"private"` to match the JSON shape pnpm
/// stores in `hoistedDependencies`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HoistKind {
Public,
Private,
}
/// Value stored under an [`Modules::allow_builds`] entry. pnpm
/// allows either a boolean toggle or a string allowlist label.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AllowBuildValue {
Bool(bool),
String(String),
}
/// Error returned by [`read_modules_manifest`].
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum ReadModulesError {
#[display("Failed to read {path:?}: {source}")]
#[diagnostic(code(pacquet_modules_yaml::read_io))]
ReadFile { path: PathBuf, source: io::Error },
#[display("Failed to parse {path:?}: {source}")]
#[diagnostic(code(pacquet_modules_yaml::parse_yaml))]
ParseYaml { path: PathBuf, source: Box<serde_saphyr::Error> },
}
/// Error returned by [`write_modules_manifest`].
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum WriteModulesError {
#[display("Failed to create directory {path:?}: {source}")]
#[diagnostic(code(pacquet_modules_yaml::create_dir))]
CreateDir { path: PathBuf, source: io::Error },
#[display("Failed to serialize manifest: {_0}")]
#[diagnostic(code(pacquet_modules_yaml::serialize_json))]
SerializeJson(serde_json::Error),
#[display("Failed to write {path:?}: {source}")]
#[diagnostic(code(pacquet_modules_yaml::write_io))]
WriteFile { path: PathBuf, source: io::Error },
}
/// Read `<modules_dir>/.modules.yaml` and return the normalized manifest.
///
/// Returns `Ok(None)` when the file does not exist or contains a YAML
/// `null` document, matching upstream `readModules` at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L50-L105>.
///
/// Production callers turbofish [`RealApi`]: `read_modules_manifest::<RealApi>(dir)`.
/// The bounds list the minimal capabilities ([`FsReadToString`] +
/// [`Clock`]) so test fakes only need to implement the methods that are
/// actually called.
pub fn read_modules_manifest<Api>(modules_dir: &Path) -> Result<Option<Modules>, ReadModulesError>
where
Api: FsReadToString + Clock,
{
let manifest_path = modules_dir.join(MODULES_FILENAME);
let content = match Api::read_to_string(&manifest_path) {
Ok(content) => content,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(ReadModulesError::ReadFile { path: manifest_path, source });
}
};
let parsed: Option<Modules> =
content.pipe_as_ref(serde_saphyr::from_str).map_err(|source| {
ReadModulesError::ParseYaml { path: manifest_path.clone(), source: Box::new(source) }
})?;
let Some(mut manifest) = parsed else { return Ok(None) };
apply_legacy_shamefully_hoist(&mut manifest);
resolve_virtual_store_dir(&mut manifest, modules_dir);
if manifest.pruned_at.is_empty() {
manifest.pruned_at = httpdate::fmt_http_date(Api::now());
}
if manifest.virtual_store_dir_max_length == 0 {
manifest.virtual_store_dir_max_length = DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH;
}
Ok(Some(manifest))
}
/// Write `manifest` to `<modules_dir>/.modules.yaml`, creating `modules_dir`
/// if it does not already exist.
///
/// Mirrors upstream `writeModules` at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L111-L138>.
///
/// Takes `manifest` by value because the body unconditionally rewrites
/// fields (sort `skipped`, drop legacy `hoistedAliases`, relativize
/// `virtualStoreDir`); making the caller hand over ownership keeps the
/// in-place mutation visible at the call site instead of forcing a hidden
/// `clone()` inside the function. Per the CODE_STYLE_GUIDE rule that
/// owned-vs-borrowed parameter choice should minimize copies.
///
/// Production callers turbofish [`RealApi`]: `write_modules_manifest::<RealApi>(dir, m)`.
/// Bounds are minimal: only [`FsCreateDirAll`] and [`FsWrite`] are required.
pub fn write_modules_manifest<Api>(
modules_dir: &Path,
mut manifest: Modules,
) -> Result<(), WriteModulesError>
where
Api: FsCreateDirAll + FsWrite,
{
manifest.skipped.sort();
drop_legacy_hoisted_aliases_when_unreferenced(&mut manifest);
// Junctions on Windows break when the project moves, so the absolute
// path is intentionally preserved there. See upstream
// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L129-L135>.
if !cfg!(windows) {
rewrite_virtual_store_dir_relative(&mut manifest, modules_dir);
}
let serialized =
serde_json::to_string_pretty(&manifest).map_err(WriteModulesError::SerializeJson)?;
Api::create_dir_all(modules_dir).map_err(|source| WriteModulesError::CreateDir {
path: modules_dir.to_path_buf(),
source,
})?;
let manifest_path = modules_dir.join(MODULES_FILENAME);
Api::write(&manifest_path, serialized.as_bytes())
.map_err(|source| WriteModulesError::WriteFile { path: manifest_path, source })
}
/// When `virtualStoreDir` is missing, default to `modules_dir/.pnpm`. When
/// it is relative, resolve it against `modules_dir`. Mirrors upstream's
/// resolution at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L66-L70>.
fn resolve_virtual_store_dir(manifest: &mut Modules, modules_dir: &Path) {
let stored_path = Path::new(&manifest.virtual_store_dir);
let resolved = match (manifest.virtual_store_dir.is_empty(), stored_path.is_absolute()) {
(true, _) => modules_dir.join(".pnpm"),
(false, true) => stored_path.to_path_buf(),
(false, false) => modules_dir.join(stored_path),
};
manifest.virtual_store_dir = resolved.to_string_lossy().into_owned();
}
/// Store `virtualStoreDir` relative to `modules_dir`, falling back to the
/// original value when no relative form exists. Mirrors upstream's
/// `path.relative(modulesDir, saveModules.virtualStoreDir)` at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L132-L135>.
///
/// `pathdiff::diff_paths` is the Rust-side equivalent of Node's
/// `path.relative` (i.e. it produces `..` segments for non-descendant
/// targets); plain `Path::strip_prefix` would only handle descendants
/// and leave sibling/parent absolute paths untouched, which would
/// diverge from upstream's `path.relative` output.
fn rewrite_virtual_store_dir_relative(manifest: &mut Modules, modules_dir: &Path) {
let stored_path = Path::new(&manifest.virtual_store_dir);
let relative =
pathdiff::diff_paths(stored_path, modules_dir).unwrap_or_else(|| stored_path.to_path_buf());
manifest.virtual_store_dir = relative.to_string_lossy().into_owned();
}
/// Translate the legacy `shamefullyHoist` and `hoistedAliases` fields into
/// the modern `publicHoistPattern` and `hoistedDependencies` shapes. Mirrors
/// upstream's translation block at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L71-L97>.
fn apply_legacy_shamefully_hoist(manifest: &mut Modules) {
let Some(shamefully_hoist) = manifest.shamefully_hoist else {
return;
};
let kind = if shamefully_hoist { HoistKind::Public } else { HoistKind::Private };
match (&manifest.public_hoist_pattern, shamefully_hoist) {
(None, false) => manifest.public_hoist_pattern = Some(Vec::new()),
(None, true) => manifest.public_hoist_pattern = Some(vec!["*".to_string()]),
(Some(_), _) => {}
}
if manifest.hoisted_dependencies.is_empty()
&& let Some(aliases_by_path) = &manifest.hoisted_aliases
{
manifest.hoisted_dependencies = aliases_by_path
.iter()
.map(|(dep_path, alias_names)| {
let entry = alias_names.iter().cloned().zip(iter::repeat(kind)).collect();
(dep_path.clone().into(), entry)
})
.collect();
}
}
/// Drop the legacy `hoistedAliases` field on write when neither hoist
/// pattern is present, mirroring upstream's cleanup at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L126-L128>.
fn drop_legacy_hoisted_aliases_when_unreferenced(manifest: &mut Modules) {
if manifest.hoist_pattern.is_none() && manifest.public_hoist_pattern.is_none() {
manifest.hoisted_aliases = None;
}
}

View File

@@ -0,0 +1,257 @@
//! Pacquet-side tests that use dependency injection to drive I/O outcomes
//! and time-dependent branches that are awkward or impossible to provoke
//! with the real filesystem. Each fake implements only the capability
//! trait the function under test consumes, so a read fake never has to
//! declare `write`. This is the interface-segregation refinement of the
//! lumped `FsApi` pattern at
//! <https://github.com/KSXGitHub/parallel-disk-usage/blob/2aa39917f9/src/app/hdd.rs#L25-L35>.
use chrono::{TimeZone, Utc};
use pacquet_modules_yaml::{
Clock, DepPath, FsCreateDirAll, FsReadToString, FsWrite, Modules, read_modules_manifest,
write_modules_manifest,
};
use pipe_trait::Pipe;
use pretty_assertions::assert_eq;
use std::{path::Path, time::SystemTime};
use text_block_macros::text_block;
/// `read_modules_manifest` should map a non-`NotFound` I/O error from
/// `read_to_string` to `ReadModulesError::ReadFile`.
#[test]
fn read_propagates_non_not_found_io_error() {
use std::io;
struct FailingRead;
impl FsReadToString for FailingRead {
fn read_to_string(_: &Path) -> io::Result<String> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "mocked"))
}
}
impl Clock for FailingRead {
fn now() -> SystemTime {
unreachable!("clock must not be called when read_to_string fails");
}
}
let err = "/dev/null/unused"
.pipe(Path::new)
.pipe(read_modules_manifest::<FailingRead>)
.expect_err("expected error");
eprintln!("error: {err}");
assert!(matches!(err, pacquet_modules_yaml::ReadModulesError::ReadFile { .. }));
}
/// `read_modules_manifest` should surface a YAML parse failure as
/// `ReadModulesError::ParseYaml`.
#[test]
fn read_propagates_parse_error() {
use std::io;
struct BadYamlContent;
impl FsReadToString for BadYamlContent {
fn read_to_string(_: &Path) -> io::Result<String> {
Ok("{ this is not valid yaml or json".to_string())
}
}
impl Clock for BadYamlContent {
fn now() -> SystemTime {
unreachable!("clock must not be called when YAML parsing fails");
}
}
let err = "/dev/null/unused"
.pipe(Path::new)
.pipe(read_modules_manifest::<BadYamlContent>)
.expect_err("expected error");
eprintln!("error: {err}");
assert!(matches!(err, pacquet_modules_yaml::ReadModulesError::ParseYaml { .. }));
}
/// A YAML document that parses to `null` should yield `Ok(None)`, matching
/// upstream's `if (!modulesRaw) return modulesRaw;` at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L55>.
#[test]
fn read_returns_none_for_null_document() {
use std::io;
struct NullDocContent;
impl FsReadToString for NullDocContent {
fn read_to_string(_: &Path) -> io::Result<String> {
Ok("null\n".to_string())
}
}
impl Clock for NullDocContent {
fn now() -> SystemTime {
unreachable!("clock must not be called when document is null");
}
}
let result = "/dev/null/unused"
.pipe(Path::new)
.pipe(read_modules_manifest::<NullDocContent>)
.expect("read manifest");
assert_eq!(result, None);
}
/// `write_modules_manifest` should map a `create_dir_all` failure to
/// `WriteModulesError::CreateDir`. The fake still has to implement
/// `FsWrite` because the function bound includes it, but the body asserts
/// that `write` is never reached on this code path.
#[test]
fn write_propagates_create_dir_error() {
use std::io;
struct FailingMkdir;
impl FsCreateDirAll for FailingMkdir {
fn create_dir_all(_: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "mocked"))
}
}
impl FsWrite for FailingMkdir {
fn write(_: &Path, _: &[u8]) -> io::Result<()> {
unreachable!("write must not be called when create_dir_all fails");
}
}
let modules_dir = Path::new("/dev/null/unused");
let err = write_modules_manifest::<FailingMkdir>(modules_dir, Modules::default())
.expect_err("expected error");
eprintln!("error: {err}");
assert!(matches!(err, pacquet_modules_yaml::WriteModulesError::CreateDir { .. }));
}
/// `write_modules_manifest` should map a `write` failure to
/// `WriteModulesError::WriteFile` after `create_dir_all` succeeds.
#[test]
fn write_propagates_write_error() {
use std::io;
struct FailingWrite;
impl FsCreateDirAll for FailingWrite {
fn create_dir_all(_: &Path) -> io::Result<()> {
Ok(())
}
}
impl FsWrite for FailingWrite {
fn write(_: &Path, _: &[u8]) -> io::Result<()> {
Err(io::Error::other("mocked write failure"))
}
}
let modules_dir = Path::new("/dev/null/unused");
let err = write_modules_manifest::<FailingWrite>(modules_dir, Modules::default())
.expect_err("expected error");
eprintln!("error: {err}");
assert!(matches!(err, pacquet_modules_yaml::WriteModulesError::WriteFile { .. }));
}
/// `LayoutVersion` is a unit type pinned to `5`. A manifest whose
/// `layoutVersion` is any other number must fail at parse time. This is
/// stricter than upstream's `readModules`, which accepts any number and
/// defers the decision to `checkCompatibility` at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/deps-installer/src/install/checkCompatibility/index.ts#L18-L22>;
/// the end-to-end behavior matches because both code paths reject
/// incompatible manifests.
#[test]
fn read_rejects_incompatible_layout_version() {
use std::io;
struct LegacyVersion;
impl FsReadToString for LegacyVersion {
fn read_to_string(_: &Path) -> io::Result<String> {
Ok("layoutVersion: 4\n".to_string())
}
}
impl Clock for LegacyVersion {
fn now() -> SystemTime {
unreachable!("clock must not be called when layout version is rejected");
}
}
let err = "/dev/null/unused"
.pipe(Path::new)
.pipe(read_modules_manifest::<LegacyVersion>)
.expect_err("expected error");
eprintln!("error: {err}");
assert!(matches!(err, pacquet_modules_yaml::ReadModulesError::ParseYaml { .. }));
}
/// `ignoredBuilds` deserializes into an [`IndexSet`], mirroring upstream's
/// `new Set<DepPath>(modulesRaw.ignoredBuilds)` normalization at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L64>.
/// Duplicates are dropped, and insertion order is preserved so a
/// write-after-read round-trip leaves the on-disk array byte-stable
/// against an upstream-written manifest.
///
/// [`IndexSet`]: indexmap::IndexSet
#[test]
fn ignored_builds_dedups_and_preserves_insertion_order() {
use std::io;
struct DupIgnored;
impl FsReadToString for DupIgnored {
fn read_to_string(_: &Path) -> io::Result<String> {
Ok(text_block! {
"layoutVersion: 5"
"ignoredBuilds:"
" - /b@1"
" - /a@1"
" - /b@1"
" - /c@1"
" - /a@1"
}
.to_string())
}
}
impl Clock for DupIgnored {
fn now() -> SystemTime {
SystemTime::UNIX_EPOCH
}
}
let manifest = "/dev/null/unused"
.pipe(Path::new)
.pipe(read_modules_manifest::<DupIgnored>)
.expect("read manifest")
.expect("manifest exists");
let ignored: Vec<&str> = manifest
.ignored_builds
.as_ref()
.expect("ignored_builds present")
.iter()
.map(DepPath::as_str)
.collect();
assert_eq!(ignored, ["/b@1", "/a@1", "/c@1"]);
}
/// `read_modules_manifest` fills in a missing `prunedAt` from the
/// injected [`Clock`] capability, formatting it as an HTTP date.
/// Mirrors upstream's `if (!modules.prunedAt) modules.prunedAt = new
/// Date().toUTCString()` at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L98-L99>.
/// This test pins the formatted output by faking the clock to a known
/// instant, since `SystemTime::now()` is otherwise non-deterministic.
#[test]
fn read_fills_pruned_at_from_clock_when_missing() {
use std::io;
struct FakeClock;
impl FsReadToString for FakeClock {
fn read_to_string(_: &Path) -> io::Result<String> {
Ok("layoutVersion: 5\n".to_string())
}
}
impl Clock for FakeClock {
fn now() -> SystemTime {
Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap().into()
}
}
let manifest = "/dev/null/unused"
.pipe(Path::new)
.pipe(read_modules_manifest::<FakeClock>)
.expect("read manifest")
.expect("manifest exists");
assert_eq!(manifest.pruned_at, "Thu, 01 Jan 2026 00:00:00 GMT");
}

View File

@@ -11,7 +11,7 @@ included:
dependencies: true
devDependencies: true
optionalDependencies: true
layoutVersion: 4
layoutVersion: 5
packageManager: pnpm@5.1.8
pendingBuilds: []
registries:

View File

@@ -11,7 +11,7 @@ included:
dependencies: true
devDependencies: true
optionalDependencies: true
layoutVersion: 4
layoutVersion: 5
packageManager: pnpm@5.1.8
pendingBuilds: []
registries:

View File

@@ -1,155 +1,168 @@
mod known_failures {
use pacquet_testing_utils::{
allow_known_failure,
known_failure::{KnownFailure, KnownResult},
};
use pipe_trait::Pipe;
use pretty_assertions::assert_eq;
use serde_json::{Value, json};
use std::{fs, path::Path};
//! Direct ports of upstream's
//! [`installing/modules-yaml/test/index.ts`](https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts).
//!
//! Pacquet-side tests that have no upstream counterpart live in
//! sibling files (`real_fs.rs`, `fakes.rs`).
use modules_manifest_stub::{read_modules_manifest, write_modules_manifest};
use pacquet_modules_yaml::{
HoistKind, Modules, RealApi, read_modules_manifest, write_modules_manifest,
};
use pipe_trait::Pipe;
use pretty_assertions::assert_eq;
use serde_json::{Value, json};
use std::{collections::BTreeMap, fs, path::Path};
// Test double for the not-yet-implemented crate API. The ported assertions below
// should stay unchanged when this is replaced by the real implementation.
mod modules_manifest_stub {
use super::{KnownFailure, KnownResult, Path, Value};
pub type ModulesManifest = Value;
pub fn read_modules_manifest(_modules_dir: &Path) -> KnownResult<Option<ModulesManifest>> {
Err(KnownFailure::new(".modules.yaml manifest support is not implemented"))
}
pub fn write_modules_manifest(
_modules_dir: &Path,
_manifest: &ModulesManifest,
) -> KnownResult<()> {
Err(KnownFailure::new(".modules.yaml manifest support is not implemented"))
}
}
// Ported from https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L10-L40
#[test]
fn write_modules_manifest_and_read_modules_manifest() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path();
let modules_yaml = json!({
"hoistedDependencies": {},
"included": {
"dependencies": true,
"devDependencies": true,
"optionalDependencies": true,
},
"ignoredBuilds": [],
"layoutVersion": 1,
"packageManager": "pnpm@2",
"pendingBuilds": [],
"publicHoistPattern": [],
"prunedAt": "Thu, 01 Jan 1970 00:00:00 GMT",
"registries": {
"default": "https://registry.npmjs.org/",
},
"shamefullyHoist": false,
"skipped": [],
"storeDir": "/.pnpm-store",
"virtualStoreDir": modules_dir.join(".pnpm"),
"virtualStoreDirMaxLength": 120,
});
allow_known_failure!(write_modules_manifest(modules_dir, &modules_yaml));
let actual = allow_known_failure!(read_modules_manifest(modules_dir));
assert_eq!(actual, Some(modules_yaml));
let raw =
fs::read_to_string(modules_dir.join(".modules.yaml")).expect("read raw .modules.yaml");
let raw: Value = serde_json::from_str(&raw).expect("parse raw .modules.yaml");
let virtual_store_dir = raw
.get("virtualStoreDir")
.expect("virtualStoreDir is present")
.as_str()
.expect("virtualStoreDir is a string");
assert_eq!(Path::new(virtual_store_dir).is_absolute(), cfg!(windows));
}
// Ported from https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L42-L53
#[test]
fn read_legacy_shamefully_hoist_true_manifest() {
let modules_dir =
env!("CARGO_MANIFEST_DIR").pipe(Path::new).join("tests/fixtures/old-shamefully-hoist");
let modules_yaml = allow_known_failure!(read_modules_manifest(&modules_dir))
.expect("modules manifest exists");
assert_eq!(modules_yaml["publicHoistPattern"], json!(["*"]));
assert_eq!(
modules_yaml["hoistedDependencies"],
json!({
"/accepts/1.3.7": { "accepts": "public" },
"/array-flatten/1.1.1": { "array-flatten": "public" },
"/body-parser/1.19.0": { "body-parser": "public" },
}),
);
}
// Ported from https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L55-L66
#[test]
fn read_legacy_shamefully_hoist_false_manifest() {
let modules_dir = env!("CARGO_MANIFEST_DIR")
.pipe(Path::new)
.join("tests/fixtures/old-no-shamefully-hoist");
let modules_yaml = allow_known_failure!(read_modules_manifest(&modules_dir))
.expect("modules manifest exists");
assert_eq!(modules_yaml["publicHoistPattern"], json!([]));
assert_eq!(
modules_yaml["hoistedDependencies"],
json!({
"/accepts/1.3.7": { "accepts": "private" },
"/array-flatten/1.1.1": { "array-flatten": "private" },
"/body-parser/1.19.0": { "body-parser": "private" },
}),
);
}
// Ported from https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L68-L94
#[test]
fn write_modules_manifest_creates_node_modules_directory() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path().join("node_modules");
let modules_yaml = json!({
"hoistedDependencies": {},
"included": {
"dependencies": true,
"devDependencies": true,
"optionalDependencies": true,
},
"ignoredBuilds": [],
"layoutVersion": 1,
"packageManager": "pnpm@2",
"pendingBuilds": [],
"publicHoistPattern": [],
"prunedAt": "Thu, 01 Jan 1970 00:00:00 GMT",
"registries": {
"default": "https://registry.npmjs.org/",
},
"shamefullyHoist": false,
"skipped": [],
"storeDir": "/.pnpm-store",
"virtualStoreDir": modules_dir.join(".pnpm"),
"virtualStoreDirMaxLength": 120,
});
allow_known_failure!(write_modules_manifest(&modules_dir, &modules_yaml));
let actual = allow_known_failure!(read_modules_manifest(&modules_dir));
assert_eq!(actual, Some(modules_yaml));
}
// Ported from https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L96-L99
#[test]
fn read_empty_modules_manifest_returns_none() {
let modules_dir =
env!("CARGO_MANIFEST_DIR").pipe(Path::new).join("tests/fixtures/empty-modules-yaml");
let modules_yaml = allow_known_failure!(read_modules_manifest(&modules_dir));
assert_eq!(modules_yaml, None);
}
fn manifest_from_json(value: Value) -> Modules {
serde_json::from_value(value).expect("deserialize Modules fixture")
}
// Ported from <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L10-L40>
#[test]
fn write_modules_manifest_and_read_modules_manifest() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path();
let modules_yaml = manifest_from_json(json!({
"hoistedDependencies": {},
"included": {
"dependencies": true,
"devDependencies": true,
"optionalDependencies": true,
},
"ignoredBuilds": [],
"layoutVersion": 5,
"packageManager": "pnpm@2",
"pendingBuilds": [],
"publicHoistPattern": [],
"prunedAt": "Thu, 01 Jan 1970 00:00:00 GMT",
"registries": {
"default": "https://registry.npmjs.org/",
},
"shamefullyHoist": false,
"skipped": [],
"storeDir": "/.pnpm-store",
"virtualStoreDir": modules_dir.join(".pnpm"),
"virtualStoreDirMaxLength": 120,
}));
write_modules_manifest::<RealApi>(modules_dir, modules_yaml.clone()).expect("write manifest");
let actual = read_modules_manifest::<RealApi>(modules_dir).expect("read manifest");
assert_eq!(actual, Some(modules_yaml));
let raw: Value = modules_dir
.join(".modules.yaml")
.pipe(fs::read_to_string)
.expect("read raw .modules.yaml")
.pipe_as_ref(serde_json::from_str)
.expect("parse raw .modules.yaml");
let virtual_store_dir = raw
.get("virtualStoreDir")
.expect("virtualStoreDir is present")
.as_str()
.expect("virtualStoreDir is a string")
.pipe(Path::new);
assert_eq!(virtual_store_dir.is_absolute(), cfg!(windows));
}
// Ported from <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L42-L53>
#[test]
fn read_legacy_shamefully_hoist_true_manifest() {
let manifest = env!("CARGO_MANIFEST_DIR")
.pipe(Path::new)
.join("tests/fixtures/old-shamefully-hoist")
.pipe_as_ref(read_modules_manifest::<RealApi>)
.expect("read manifest")
.expect("modules manifest exists");
assert_eq!(manifest.public_hoist_pattern.as_deref(), Some(&["*".to_string()][..]));
assert_eq!(
manifest.hoisted_dependencies,
BTreeMap::from([
(
"/accepts/1.3.7".to_string(),
BTreeMap::from([("accepts".to_string(), HoistKind::Public)]),
),
(
"/array-flatten/1.1.1".to_string(),
BTreeMap::from([("array-flatten".to_string(), HoistKind::Public)]),
),
(
"/body-parser/1.19.0".to_string(),
BTreeMap::from([("body-parser".to_string(), HoistKind::Public)]),
),
]),
);
}
// Ported from <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L55-L66>
#[test]
fn read_legacy_shamefully_hoist_false_manifest() {
let manifest = env!("CARGO_MANIFEST_DIR")
.pipe(Path::new)
.join("tests/fixtures/old-no-shamefully-hoist")
.pipe_as_ref(read_modules_manifest::<RealApi>)
.expect("read manifest")
.expect("modules manifest exists");
assert_eq!(manifest.public_hoist_pattern.as_deref(), Some(&[][..]));
assert_eq!(
manifest.hoisted_dependencies,
BTreeMap::from([
(
"/accepts/1.3.7".to_string(),
BTreeMap::from([("accepts".to_string(), HoistKind::Private)]),
),
(
"/array-flatten/1.1.1".to_string(),
BTreeMap::from([("array-flatten".to_string(), HoistKind::Private)]),
),
(
"/body-parser/1.19.0".to_string(),
BTreeMap::from([("body-parser".to_string(), HoistKind::Private)]),
),
]),
);
}
// Ported from <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L68-L94>
#[test]
fn write_modules_manifest_creates_node_modules_directory() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path().join("node_modules");
let modules_yaml = manifest_from_json(json!({
"hoistedDependencies": {},
"included": {
"dependencies": true,
"devDependencies": true,
"optionalDependencies": true,
},
"ignoredBuilds": [],
"layoutVersion": 5,
"packageManager": "pnpm@2",
"pendingBuilds": [],
"publicHoistPattern": [],
"prunedAt": "Thu, 01 Jan 1970 00:00:00 GMT",
"registries": {
"default": "https://registry.npmjs.org/",
},
"shamefullyHoist": false,
"skipped": [],
"storeDir": "/.pnpm-store",
"virtualStoreDir": modules_dir.join(".pnpm"),
"virtualStoreDirMaxLength": 120,
}));
write_modules_manifest::<RealApi>(&modules_dir, modules_yaml.clone()).expect("write manifest");
let actual = read_modules_manifest::<RealApi>(&modules_dir).expect("read manifest");
assert_eq!(actual, Some(modules_yaml));
}
// Ported from <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/test/index.ts#L96-L99>
#[test]
fn read_empty_modules_manifest_returns_none() {
let modules_yaml = env!("CARGO_MANIFEST_DIR")
.pipe(Path::new)
.join("tests/fixtures/empty-modules-yaml")
.pipe_as_ref(read_modules_manifest::<RealApi>)
.expect("read manifest");
assert_eq!(modules_yaml, None);
}

View File

@@ -0,0 +1,156 @@
//! Pacquet-side tests that exercise behavior branches by writing
//! `.modules.yaml` files to a real `tempfile::tempdir()` and reading them
//! back. These cover branches that pnpm only exercises transitively
//! through install-level integration tests in `pnpm/test/`
//! (e.g. custom `virtualStoreDir` at
//! <https://github.com/pnpm/pnpm/blob/1819226b51/pnpm/test/monorepo/index.ts#L1467-L1545>);
//! the install integration tests are gated on the install pipeline being
//! ported, so these direct unit tests guard the behavior in the meantime.
use indexmap::IndexSet;
use pacquet_modules_yaml::{
DepPath, Modules, RealApi, read_modules_manifest, write_modules_manifest,
};
use pipe_trait::Pipe;
use pretty_assertions::assert_eq;
use serde_json::{Value, json};
use std::{fs, path::Path};
fn manifest_from_json(value: Value) -> Modules {
serde_json::from_value(value).expect("deserialize Modules fixture")
}
/// Reading a manifest whose `virtualStoreDir` is already absolute must
/// preserve it verbatim, matching upstream
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L66-L70>.
#[test]
fn read_preserves_absolute_virtual_store_dir() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path().join("node_modules");
fs::create_dir_all(&modules_dir).expect("create modules dir");
let custom_store = temp_dir.path().join("custom-store");
let raw = json!({ "virtualStoreDir": &custom_store, "layoutVersion": 5 }).to_string();
fs::write(modules_dir.join(".modules.yaml"), raw).expect("write fixture");
let manifest = modules_dir
.pipe_as_ref(read_modules_manifest::<RealApi>)
.expect("read manifest")
.expect("manifest exists");
assert_eq!(Path::new(&manifest.virtual_store_dir), custom_store);
}
/// On non-Windows, `write_modules_manifest` rewrites a non-descendant
/// `virtualStoreDir` (sibling, parent, etc.) to a relative path with
/// `..` segments — matching upstream's `path.relative()` output at
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L132-L135>,
/// not just the descendant case that `Path::strip_prefix` covers.
#[cfg(not(windows))]
#[test]
fn write_relativizes_non_descendant_virtual_store_dir() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path().join("project").join("node_modules");
let sibling_store = temp_dir.path().join(".pnpm-store");
let manifest = manifest_from_json(json!({
"layoutVersion": 5,
"virtualStoreDir": &sibling_store,
}));
write_modules_manifest::<RealApi>(&modules_dir, manifest).expect("write manifest");
let raw: Value = modules_dir
.join(".modules.yaml")
.pipe(fs::read_to_string)
.expect("read raw .modules.yaml")
.pipe_as_ref(serde_json::from_str)
.expect("parse raw .modules.yaml");
assert_eq!(raw["virtualStoreDir"], json!("../../.pnpm-store"));
}
/// `writeModules` sorts `skipped` in place before serializing, matching
/// upstream
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L117>.
#[test]
fn write_sorts_skipped_array() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path();
let manifest = manifest_from_json(json!({
"layoutVersion": 5,
"skipped": ["zeta", "alpha", "mu"],
}));
write_modules_manifest::<RealApi>(modules_dir, manifest).expect("write manifest");
let raw: Value = modules_dir
.join(".modules.yaml")
.pipe(fs::read_to_string)
.expect("read raw .modules.yaml")
.pipe_as_ref(serde_json::from_str)
.expect("parse raw .modules.yaml");
assert_eq!(raw["skipped"], json!(["alpha", "mu", "zeta"]));
}
/// A null `publicHoistPattern` is removed before serializing because the
/// YAML writer fails on undefined fields upstream. The behavior matches
/// <https://github.com/pnpm/pnpm/blob/1819226b51/installing/modules-yaml/src/index.ts#L123-L125>.
#[test]
fn write_removes_null_public_hoist_pattern() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path();
let manifest = manifest_from_json(json!({
"layoutVersion": 5,
"publicHoistPattern": null,
}));
write_modules_manifest::<RealApi>(modules_dir, manifest).expect("write manifest");
let raw: Value = modules_dir
.join(".modules.yaml")
.pipe(fs::read_to_string)
.expect("read raw .modules.yaml")
.pipe_as_ref(serde_json::from_str)
.expect("parse raw .modules.yaml");
assert!(
raw.get("publicHoistPattern").is_none(),
"publicHoistPattern was kept after write: {raw}",
);
}
/// `DepPath` is a transparent newtype around `String`: on the wire it is
/// indistinguishable from a plain string, so `hoistedAliases` keys and
/// `ignoredBuilds` elements round-trip through JSON (and YAML) the same
/// way upstream's `as DepPath`-cast values do.
#[test]
fn dep_path_serializes_transparently() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let modules_dir = temp_dir.path();
let manifest = manifest_from_json(json!({
"layoutVersion": 5,
"hoistedAliases": {
"/accepts/1.3.7": ["accepts"],
},
"ignoredBuilds": ["/sharp/0.32.0"],
"publicHoistPattern": [],
}));
assert_eq!(
manifest.hoisted_aliases.as_ref().and_then(|m| m.keys().next()),
Some(&DepPath::from("/accepts/1.3.7".to_string())),
);
let expected_ignored: IndexSet<DepPath> =
[DepPath::from("/sharp/0.32.0".to_string())].into_iter().collect();
assert_eq!(manifest.ignored_builds.as_ref(), Some(&expected_ignored));
write_modules_manifest::<RealApi>(modules_dir, manifest).expect("write manifest");
let raw: Value = modules_dir
.join(".modules.yaml")
.pipe(fs::read_to_string)
.expect("read raw .modules.yaml")
.pipe_as_ref(serde_json::from_str)
.expect("parse raw .modules.yaml");
assert_eq!(
raw["hoistedAliases"]["/accepts/1.3.7"],
json!(["accepts"]),
"DepPath key did not serialize as a plain string",
);
assert_eq!(
raw["ignoredBuilds"],
json!(["/sharp/0.32.0"]),
"DepPath element did not serialize as a plain string",
);
}