mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-19 04:02:32 -04:00
feat: .modules.yaml (#332)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
61
pacquet/Cargo.lock
generated
61
pacquet/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
257
pacquet/crates/modules-yaml/tests/fakes.rs
Normal file
257
pacquet/crates/modules-yaml/tests/fakes.rs
Normal 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");
|
||||
}
|
||||
@@ -11,7 +11,7 @@ included:
|
||||
dependencies: true
|
||||
devDependencies: true
|
||||
optionalDependencies: true
|
||||
layoutVersion: 4
|
||||
layoutVersion: 5
|
||||
packageManager: pnpm@5.1.8
|
||||
pendingBuilds: []
|
||||
registries:
|
||||
|
||||
@@ -11,7 +11,7 @@ included:
|
||||
dependencies: true
|
||||
devDependencies: true
|
||||
optionalDependencies: true
|
||||
layoutVersion: 4
|
||||
layoutVersion: 5
|
||||
packageManager: pnpm@5.1.8
|
||||
pendingBuilds: []
|
||||
registries:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
156
pacquet/crates/modules-yaml/tests/real_fs.rs
Normal file
156
pacquet/crates/modules-yaml/tests/real_fs.rs
Normal 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",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user