From 4ababc0fc6f7d2e4f8a5aa62cbd3bb488666e052 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Fri, 15 May 2026 11:43:54 +0200 Subject: [PATCH] feat(package-manager): write .pnpm-workspace-state-v1.json after install (#11665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(package-manager): write .pnpm-workspace-state-v1.json after install pnpm's verifyDepsBeforeRun gate bails out with "Cannot check whether dependencies are outdated" as soon as the workspace state file is missing, so a node_modules tree materialized by pacquet always tripped the check and forced a reinstall. Port @pnpm/workspace.state to a new pacquet-workspace-state crate and write the file at the end of Install::run so pnpm can fast-path the freshness check after pacquet has done the install. Closes the gap behind the pnpm_config_verify_deps_before_run: false workaround in 7ff112bac6. * chore(workspace-state): apply taplo formatting Re-align the dependency-block padding to taplo's expected width — CI's `taplo format --check` flagged the 13-character padding the manual draft shipped with. * chore(workspace-state): drop trailing comma in single-line assert_eq! Dylint's `perfectionist::macro_trailing_comma` flags single-line macro invocations that end with a trailing comma. Rustfmt's earlier collapse of the multi-line assertion left the comma intact; remove it so the nightly dylint check passes. --- Cargo.lock | 14 ++ Cargo.toml | 1 + pacquet/crates/package-manager/Cargo.toml | 1 + pacquet/crates/package-manager/src/install.rs | 158 ++++++++++++ .../package-manager/src/install/tests.rs | 115 +++++++++ pacquet/crates/workspace-state/Cargo.toml | 23 ++ pacquet/crates/workspace-state/src/lib.rs | 224 ++++++++++++++++++ pacquet/crates/workspace-state/src/tests.rs | 97 ++++++++ 8 files changed, 633 insertions(+) create mode 100644 pacquet/crates/workspace-state/Cargo.toml create mode 100644 pacquet/crates/workspace-state/src/lib.rs create mode 100644 pacquet/crates/workspace-state/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 7998e8a369..e0e00f3b01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2262,6 +2262,7 @@ dependencies = [ "pacquet-tarball", "pacquet-testing-utils", "pacquet-workspace", + "pacquet-workspace-state", "pipe-trait", "pretty_assertions", "rayon", @@ -2445,6 +2446,19 @@ dependencies = [ "wax", ] +[[package]] +name = "pacquet-workspace-state" +version = "0.0.1" +dependencies = [ + "derive_more", + "indexmap", + "pacquet-diagnostics", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "page_size" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 6a4c8b00bc..3c81472fd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ pacquet-reporter = { path = "pacquet/crates/reporter" } pacquet-patching = { path = "pacquet/crates/patching" } pacquet-real-hoist = { path = "pacquet/crates/real-hoist" } pacquet-workspace = { path = "pacquet/crates/workspace" } +pacquet-workspace-state = { path = "pacquet/crates/workspace-state" } # Tasks pacquet-registry-mock = { path = "pacquet/tasks/registry-mock" } diff --git a/pacquet/crates/package-manager/Cargo.toml b/pacquet/crates/package-manager/Cargo.toml index feaa4eddb3..a48cd3ad0f 100644 --- a/pacquet/crates/package-manager/Cargo.toml +++ b/pacquet/crates/package-manager/Cargo.toml @@ -29,6 +29,7 @@ pacquet-reporter = { workspace = true } pacquet-store-dir = { workspace = true } pacquet-tarball = { workspace = true } pacquet-workspace = { workspace = true } +pacquet-workspace-state = { workspace = true } async-recursion = { workspace = true } dashmap = { workspace = true } diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 764ceb5dc5..8ba068247c 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -21,6 +21,10 @@ use pacquet_reporter::{ StageLog, SummaryLog, }; use pacquet_tarball::MemCache; +use pacquet_workspace_state::{ + NodeLinker as WorkspaceStateNodeLinker, ProjectEntry, UpdateWorkspaceStateError, + WorkspaceState, WorkspaceStateSettings, now_millis, update_workspace_state, +}; /// This subroutine does everything `pacquet install` is supposed to do. #[must_use] @@ -140,6 +144,15 @@ pub enum InstallError { #[diagnostic(transparent)] FindWorkspaceDir(#[error(source)] pacquet_workspace::FindWorkspaceDirError), + + /// Surfaces a failure to persist `.pnpm-workspace-state-v1.json`. + /// Missing or unreadable state forces `pnpm run`'s + /// `verifyDepsBeforeRun` check to fall back to "outdated", which + /// is exactly the regression CI hits when pacquet runs the + /// install — fail the install rather than letting a silent write + /// error compound into spurious reinstalls. + #[diagnostic(transparent)] + WriteWorkspaceState(#[error(source)] UpdateWorkspaceStateError), } impl<'a, DependencyGroupList> Install<'a, DependencyGroupList> @@ -504,6 +517,22 @@ where .map_err(InstallError::SaveCurrentLockfile)?; } + // Write `node_modules/.pnpm-workspace-state-v1.json`. Mirrors + // upstream's `updateWorkspaceState` call at + // . + // pnpm's `verifyDepsBeforeRun` gate at + // + // bails to "outdated" the moment this file is missing, + // forcing `pnpm install` to rerun. Writing it after both the + // `.modules.yaml` and the current lockfile succeed mirrors + // pnpm's ordering and keeps the file pointing at a fully + // committed install. + update_workspace_state( + &workspace_root, + &build_workspace_state(config, node_linker, included, manifest, lockfile), + ) + .map_err(InstallError::WriteWorkspaceState)?; + // `pnpm:summary` closes the install and lets the reporter render // the accumulated `pnpm:root` events as a "+N -M" block. Must // come after `importing_done`, matching pnpm's ordering at @@ -603,5 +632,134 @@ fn build_modules_manifest( } } +/// Translate pacquet's `Config::node_linker` into the on-disk variant +/// shared with the workspace-state writer. Same three-way set as +/// [`map_node_linker`] but targeting [`WorkspaceStateNodeLinker`]. +fn map_workspace_state_node_linker(linker: &NodeLinker) -> WorkspaceStateNodeLinker { + match linker { + NodeLinker::Isolated => WorkspaceStateNodeLinker::Isolated, + NodeLinker::Hoisted => WorkspaceStateNodeLinker::Hoisted, + NodeLinker::Pnp => WorkspaceStateNodeLinker::Pnp, + } +} + +/// Read a string field off a project manifest, returning `None` when +/// the field is missing or not a JSON string. Pnpm tolerates either +/// shape — `name`/`version` are advisory metadata in this context, so +/// pacquet matches by silently dropping non-string values. +fn manifest_string_field(manifest: &PackageManifest, key: &str) -> Option { + manifest.value().get(key).and_then(|v| v.as_str()).map(ToString::to_string) +} + +/// Build the `projects` map for [`WorkspaceState`]. Mirrors upstream's +/// `Object.fromEntries(opts.allProjects.map(...))` at +/// . +/// +/// For workspace installs (frozen-lockfile with sub-importers), pacquet +/// reads each sub-importer's `package.json` to capture `name` / `version` +/// the same way pnpm's `find_workspace_projects` does. The root +/// importer (`.`) reuses the already-loaded `manifest` — re-reading it +/// would double the I/O for no behavior change. A missing or unreadable +/// sub-manifest is logged and skipped: pnpm would already correctly +/// re-run install in that case (the project count won't match), so a +/// best-effort entry beats failing the install over a transient read. +fn build_projects_map( + workspace_root: &std::path::Path, + manifest: &PackageManifest, + lockfile: Option<&Lockfile>, +) -> BTreeMap { + let mut projects: BTreeMap = BTreeMap::new(); + let root_entry = ProjectEntry { + name: manifest_string_field(manifest, "name"), + version: manifest_string_field(manifest, "version"), + }; + let importer_ids: Vec = match lockfile { + Some(lf) => lf.importers.keys().cloned().collect(), + None => vec![Lockfile::ROOT_IMPORTER_KEY.to_string()], + }; + for importer_id in importer_ids { + let project_dir = + crate::symlink_direct_dependencies::importer_root_dir(workspace_root, &importer_id); + let entry = if importer_id == Lockfile::ROOT_IMPORTER_KEY { + root_entry.clone() + } else { + match PackageManifest::from_path(project_dir.join("package.json")) { + Ok(sub_manifest) => ProjectEntry { + name: manifest_string_field(&sub_manifest, "name"), + version: manifest_string_field(&sub_manifest, "version"), + }, + Err(error) => { + tracing::warn!( + target: "pacquet::install", + ?error, + importer_id = %importer_id, + "Failed to read sub-importer manifest while recording workspace state", + ); + ProjectEntry::default() + } + } + }; + projects.insert(project_dir.to_string_lossy().into_owned(), entry); + } + projects +} + +/// Assemble the [`WorkspaceState`] payload for [`update_workspace_state`]. +/// +/// Records the projects pacquet just materialized plus the resolved +/// settings the install used. Mirrors upstream's `createWorkspaceState` +/// at . +/// Settings pacquet does not track yet (e.g. `dedupeDirectDeps`, +/// `peersSuffixMaxLength`, `overrides`) are omitted; pnpm's +/// `checkDepsStatus` only iterates fields present in the serialized +/// object, so an absent key is silently skipped rather than treated as +/// a drift. +fn build_workspace_state( + config: &Config, + node_linker: NodeLinker, + included: IncludedDependencies, + manifest: &PackageManifest, + lockfile: Option<&Lockfile>, +) -> WorkspaceState { + let manifest_dir = manifest.path().parent().expect("manifest path always has a parent dir"); + let workspace_root = pacquet_workspace::find_workspace_dir(manifest_dir) + .ok() + .flatten() + .unwrap_or_else(|| manifest_dir.to_path_buf()); + + let allow_builds = (!config.allow_builds.is_empty()).then(|| { + config.allow_builds.iter().map(|(k, v)| (k.clone(), serde_json::Value::Bool(*v))).collect() + }); + + WorkspaceState { + last_validated_timestamp: now_millis(), + projects: build_projects_map(&workspace_root, manifest, lockfile), + // Pacquet doesn't run pnpmfiles yet; record the empty list so + // pnpm's `patchesOrHooksAreModified` doesn't trip on a missing + // field. + pnpmfiles: Vec::new(), + // Pacquet has no `--filter` yet (issue #299 stage 2). Hard-code + // `false` so pnpm doesn't treat the install as partial and + // skip the cache. + filtered_install: false, + config_dependencies: None, + settings: WorkspaceStateSettings { + allow_builds, + auto_install_peers: Some(config.auto_install_peers), + dedupe_peer_dependents: Some(config.dedupe_peer_dependents), + dev: Some(included.dev_dependencies), + hoist_pattern: config.hoist_pattern.clone(), + hoist_workspace_packages: Some(config.hoist_workspace_packages), + ignored_optional_dependencies: config.ignored_optional_dependencies.clone(), + node_linker: Some(map_workspace_state_node_linker(&node_linker)), + optional: Some(included.optional_dependencies), + patched_dependencies: config.patched_dependencies.clone(), + production: Some(included.dependencies), + public_hoist_pattern: config.public_hoist_pattern.clone(), + ..Default::default() + }, + } +} + #[cfg(test)] mod tests; diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index a6605e3cac..0ccca3f882 100644 --- a/pacquet/crates/package-manager/src/install/tests.rs +++ b/pacquet/crates/package-manager/src/install/tests.rs @@ -13,6 +13,9 @@ use pacquet_reporter::{ StageLog, StatsLog, StatsMessage, SummaryLog, }; use pacquet_testing_utils::fs::{get_all_folders, is_symlink_or_junction}; +use pacquet_workspace_state::{ + self as workspace_state, NodeLinker as WorkspaceStateNodeLinker, load_workspace_state, +}; use pipe_trait::Pipe; use std::{path::PathBuf, sync::Mutex}; use tempfile::tempdir; @@ -680,6 +683,118 @@ async fn install_writes_modules_yaml() { drop(dir); } +/// `pnpm run`'s `verifyDepsBeforeRun` gate at +/// +/// bails to "outdated" the moment +/// `/node_modules/.pnpm-workspace-state-v1.json` is +/// missing. Pacquet must write it on every install so pnpm can fast-path +/// the check after pacquet has materialized the modules tree — that's +/// the gap behind the +/// [`pnpm_config_verify_deps_before_run: false`](https://github.com/pnpm/pnpm/commit/7ff112bac6) +/// workaround in pnpm's own CI. +#[tokio::test] +async fn install_writes_workspace_state() { + let dir = tempdir().unwrap(); + let store_dir = dir.path().join("pacquet-store"); + let project_root = dir.path().join("project"); + let modules_dir = project_root.join("node_modules"); + let virtual_store_dir = modules_dir.join(".pacquet"); + + let manifest_path = dir.path().join("package.json"); + let manifest = PackageManifest::create_if_needed(manifest_path).unwrap(); + + let mut config = Config::new(); + config.lockfile = false; + config.store_dir = store_dir.clone().into(); + config.modules_dir = modules_dir.clone(); + config.virtual_store_dir = virtual_store_dir.clone(); + let config = config.leak(); + + let lockfile: Lockfile = serde_saphyr::from_str(text_block! { + "lockfileVersion: '9.0'" + "importers:" + " .:" + " dependencies: {}" + "packages: {}" + "snapshots: {}" + }) + .expect("parse minimal v9 lockfile"); + + Install { + tarball_mem_cache: &Default::default(), + http_client: &Default::default(), + config, + manifest: &manifest, + lockfile: Some(&lockfile), + // Same `included` shape as `install_writes_modules_yaml` so the + // dev/optional/production assertions below line up with the + // dispatched groups. + dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional], + frozen_lockfile: true, + skip_runtimes: false, + supported_architectures: None, + node_linker: pacquet_config::NodeLinker::default(), + resolved_packages: &Default::default(), + } + .run::() + .await + .expect("frozen-lockfile install should succeed"); + + let state = load_workspace_state(dir.path()) + .expect("read workspace state") + .expect("workspace state file exists after install"); + + assert!( + state.last_validated_timestamp > 0, + "lastValidatedTimestamp should be populated, got {}", + state.last_validated_timestamp, + ); + + // The state must record the project that pacquet just installed + // so pnpm's `allProjects.length !== Object.keys(projects).length` + // check passes. Single-project install → exactly one entry, keyed + // on the workspace dir. + assert_eq!(state.projects.len(), 1); + let project_key = dir.path().to_string_lossy().into_owned(); + let project = state + .projects + .get(&project_key) + .unwrap_or_else(|| panic!("project entry for {project_key:?} should exist")); + assert_eq!( + project, + &workspace_state::ProjectEntry { + // `PackageManifest::create_if_needed` seeds `name` from the + // parent dir's basename and `version` from `"1.0.0"`. The + // test pins the round-trip of both fields so a regression + // that loses them (e.g. switching to a non-string serde + // shape) trips here. + name: Some( + dir.path() + .file_name() + .and_then(|n| n.to_str()) + .expect("tmpdir has a UTF-8 basename") + .to_string() + ), + version: Some("1.0.0".to_string()), + }, + ); + + assert!(!state.filtered_install); + assert!(state.pnpmfiles.is_empty()); + + let settings = &state.settings; + assert_eq!(settings.node_linker, Some(WorkspaceStateNodeLinker::Isolated)); + assert_eq!(settings.dev, Some(false)); + assert_eq!(settings.optional, Some(true)); + assert_eq!(settings.production, Some(true)); + assert_eq!(settings.auto_install_peers, Some(true)); + assert_eq!(settings.dedupe_peer_dependents, Some(true)); + assert_eq!(settings.hoist_workspace_packages, Some(true)); + assert_eq!(settings.hoist_pattern.as_deref(), Some(&["*".to_string()][..])); + + drop(dir); +} + /// Ports `'do not fail on an optional dependency that has a non-optional /// dependency with a failing postinstall script'` at /// . diff --git a/pacquet/crates/workspace-state/Cargo.toml b/pacquet/crates/workspace-state/Cargo.toml new file mode 100644 index 0000000000..0722e54753 --- /dev/null +++ b/pacquet/crates/workspace-state/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "pacquet-workspace-state" +version = "0.0.1" +publish = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pacquet-diagnostics = { workspace = true } + +derive_more = { workspace = true } +indexmap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/pacquet/crates/workspace-state/src/lib.rs b/pacquet/crates/workspace-state/src/lib.rs new file mode 100644 index 0000000000..744e0d16e8 --- /dev/null +++ b/pacquet/crates/workspace-state/src/lib.rs @@ -0,0 +1,224 @@ +//! Read and write pnpm's `node_modules/.pnpm-workspace-state-v1.json`. +//! +//! Mirrors pnpm v11's `@pnpm/workspace.state` package. See upstream +//! . +//! +//! The file records what an install actually used (project list, +//! resolved settings, pnpmfiles, …) so the next `pnpm run` invocation +//! can decide whether `node_modules` is still up to date without +//! re-resolving anything. Mirroring the on-disk shape byte-for-byte +//! lets pnpm read state written by pacquet — that's what closes the +//! gap that forced +//! [`verify-deps-before-run=false`](https://github.com/pnpm/pnpm/commit/7ff112bac6). + +use derive_more::{Display, Error}; +use indexmap::IndexMap; +use pacquet_diagnostics::miette::{self, Diagnostic}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, io, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +/// Basename of the workspace-state file, written inside `node_modules/`. +/// +/// Matches upstream's filename at +/// . +pub const WORKSPACE_STATE_FILENAME: &str = ".pnpm-workspace-state-v1.json"; + +/// `/node_modules/.pnpm-workspace-state-v1.json`. Same +/// resolution as upstream's [`getFilePath`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/filePath.ts). +pub fn get_file_path(workspace_dir: &Path) -> PathBuf { + workspace_dir.join("node_modules").join(WORKSPACE_STATE_FILENAME) +} + +/// Per-project entry inside [`WorkspaceState::projects`]. Mirrors +/// upstream's `{ name?, version? }` shape at +/// . +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ProjectEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// Typed view of `.pnpm-workspace-state-v1.json`. +/// +/// Mirrors upstream's [`WorkspaceState`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts). +/// `lastValidatedTimestamp` is JS `Date.now()` — milliseconds since the +/// Unix epoch — so pnpm's freshness checks (`mtime > lastValidated`) +/// stay consistent across the two implementations. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceState { + pub last_validated_timestamp: i64, + pub projects: BTreeMap, + pub pnpmfiles: Vec, + pub filtered_install: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_dependencies: Option>, + pub settings: WorkspaceStateSettings, +} + +/// Subset of pnpm's `Config` keys that `checkDepsStatus` compares to +/// the live config before allowing the fast-path. Listed at +/// . +/// +/// Every field is `Option` so pacquet can omit settings it does not +/// track yet; missing keys round-trip as JSON `undefined`, which pnpm's +/// `Object.entries(workspaceState.settings)` loop simply skips. Match +/// what the install actually used — if pacquet's resolved value differs +/// from pnpm's, pnpm correctly reinstalls. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceStateSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_builds: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_install_peers: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalogs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dedupe_direct_deps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dedupe_injected_deps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dedupe_peer_dependents: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dedupe_peers: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dev: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exclude_links_from_lockfile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hoist_pattern: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hoist_workspace_packages: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ignored_optional_dependencies: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inject_workspace_packages: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_workspace_packages: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_linker: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub optional: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overrides: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_extensions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub patched_dependencies: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub peers_suffix_max_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefer_workspace_packages: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub production: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_hoist_pattern: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_package_patterns: Option>, +} + +/// Mirrors pnpm's `nodeLinker: 'hoisted' | 'isolated' | 'pnp'`. Same +/// wire format as [`pacquet_modules_yaml::NodeLinker`](https://github.com/pnpm/pnpm/blob/7ff112bac6/installing/modules-yaml/src/index.ts); +/// duplicated here rather than depending on `pacquet-modules-yaml` so +/// `workspace-state` stays independent of the install pipeline. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NodeLinker { + Hoisted, + Isolated, + Pnp, +} + +/// Error returned by [`update_workspace_state`]. +#[derive(Debug, Display, Error, Diagnostic)] +#[non_exhaustive] +pub enum UpdateWorkspaceStateError { + #[display("Failed to create directory {path:?}: {source}")] + #[diagnostic(code(pacquet_workspace_state::create_dir))] + CreateDir { path: PathBuf, source: io::Error }, + + #[display("Failed to serialize workspace state: {_0}")] + #[diagnostic(code(pacquet_workspace_state::serialize_json))] + SerializeJson(serde_json::Error), + + #[display("Failed to write {path:?}: {source}")] + #[diagnostic(code(pacquet_workspace_state::write_io))] + WriteFile { path: PathBuf, source: io::Error }, +} + +/// Write `state` to `/node_modules/.pnpm-workspace-state-v1.json`. +/// +/// Mirrors upstream's [`updateWorkspaceState`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/updateWorkspaceState.ts): +/// `JSON.stringify(state, undefined, 2) + '\n'`. `serde_json`'s pretty +/// printer uses the same 2-space indent and `": "` separator as JS, so +/// the on-disk bytes round-trip cleanly between the two writers. +pub fn update_workspace_state( + workspace_dir: &Path, + state: &WorkspaceState, +) -> Result<(), UpdateWorkspaceStateError> { + let file_path = get_file_path(workspace_dir); + let parent = file_path.parent().expect("workspace-state path always has a parent"); + fs::create_dir_all(parent).map_err(|source| UpdateWorkspaceStateError::CreateDir { + path: parent.to_path_buf(), + source, + })?; + let mut serialized = + serde_json::to_string_pretty(state).map_err(UpdateWorkspaceStateError::SerializeJson)?; + serialized.push('\n'); + fs::write(&file_path, serialized.as_bytes()) + .map_err(|source| UpdateWorkspaceStateError::WriteFile { path: file_path, source }) +} + +/// Read the workspace state file at `/node_modules/.pnpm-workspace-state-v1.json`. +/// +/// Returns `Ok(None)` when the file does not exist, matching upstream's +/// [`loadWorkspaceState`](https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/loadWorkspaceState.ts). +pub fn load_workspace_state( + workspace_dir: &Path, +) -> Result, LoadWorkspaceStateError> { + let file_path = get_file_path(workspace_dir); + let text = match fs::read_to_string(&file_path) { + Ok(text) => text, + Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(LoadWorkspaceStateError::ReadFile { path: file_path, source }); + } + }; + serde_json::from_str(&text) + .map(Some) + .map_err(|source| LoadWorkspaceStateError::ParseJson { path: file_path, source }) +} + +/// Error returned by [`load_workspace_state`]. +#[derive(Debug, Display, Error, Diagnostic)] +#[non_exhaustive] +pub enum LoadWorkspaceStateError { + #[display("Failed to read {path:?}: {source}")] + #[diagnostic(code(pacquet_workspace_state::read_io))] + ReadFile { path: PathBuf, source: io::Error }, + + #[display("Failed to parse {path:?}: {source}")] + #[diagnostic(code(pacquet_workspace_state::parse_json))] + ParseJson { path: PathBuf, source: serde_json::Error }, +} + +/// Wall-clock milliseconds since the Unix epoch, matching JS +/// `Date.now()` and the `lastValidatedTimestamp` value pnpm writes at +/// . +/// +/// Truncates to `i64` because the JSON field is signed and the year +/// 2038-pre-292277026596 range is the only one that matters. +pub fn now_millis() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/workspace-state/src/tests.rs b/pacquet/crates/workspace-state/src/tests.rs new file mode 100644 index 0000000000..b3109448a1 --- /dev/null +++ b/pacquet/crates/workspace-state/src/tests.rs @@ -0,0 +1,97 @@ +use super::{ + NodeLinker, ProjectEntry, WorkspaceState, WorkspaceStateSettings, get_file_path, + load_workspace_state, now_millis, update_workspace_state, +}; +use indexmap::IndexMap; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::tempdir; + +#[test] +fn file_path_matches_upstream() { + let dir = std::path::Path::new("/tmp/example"); + assert_eq!(get_file_path(dir), dir.join("node_modules").join(".pnpm-workspace-state-v1.json")); +} + +#[test] +fn write_and_load_round_trip() { + let tmp = tempdir().expect("create temp dir"); + let workspace_dir = tmp.path(); + + let mut projects = BTreeMap::new(); + projects.insert( + workspace_dir.to_string_lossy().into_owned(), + ProjectEntry { name: Some("my-pkg".into()), version: Some("1.2.3".into()) }, + ); + + let mut patched = IndexMap::new(); + patched.insert("some-pkg".to_string(), "patches/some-pkg.patch".to_string()); + + let state = WorkspaceState { + last_validated_timestamp: now_millis(), + projects, + pnpmfiles: vec![], + filtered_install: false, + config_dependencies: None, + settings: WorkspaceStateSettings { + auto_install_peers: Some(true), + dedupe_peer_dependents: Some(true), + dev: Some(true), + hoist_pattern: Some(vec!["*".into()]), + hoist_workspace_packages: Some(true), + node_linker: Some(NodeLinker::Isolated), + optional: Some(true), + patched_dependencies: Some(patched.clone()), + production: Some(true), + public_hoist_pattern: Some(vec![]), + ..Default::default() + }, + }; + + update_workspace_state(workspace_dir, &state).expect("write state"); + + let path = get_file_path(workspace_dir); + assert!(path.is_file(), "state file should exist at {path:?}"); + + let on_disk = std::fs::read_to_string(&path).expect("read state"); + assert!(on_disk.ends_with('\n'), "upstream appends a trailing newline"); + + let loaded = load_workspace_state(workspace_dir).expect("load state").expect("file present"); + assert_eq!(loaded, state); +} + +#[test] +fn load_returns_none_when_missing() { + let tmp = tempdir().expect("create temp dir"); + let loaded = load_workspace_state(tmp.path()).expect("missing state is not an error"); + assert!(loaded.is_none()); +} + +#[test] +fn omits_settings_that_are_none() { + let state = WorkspaceState { + last_validated_timestamp: 0, + projects: BTreeMap::new(), + pnpmfiles: vec![], + filtered_install: false, + config_dependencies: None, + settings: WorkspaceStateSettings { auto_install_peers: Some(true), ..Default::default() }, + }; + let serialized = serde_json::to_string(&state).expect("serialize"); + // Only the populated setting should show up. + assert!(serialized.contains("\"autoInstallPeers\":true"), "got: {serialized}"); + assert!(!serialized.contains("dedupePeerDependents"), "got: {serialized}"); + assert!(!serialized.contains("nodeLinker"), "got: {serialized}"); + // Top-level optional keys should also be omitted. + assert!(!serialized.contains("configDependencies"), "got: {serialized}"); +} + +#[test] +fn node_linker_serializes_lowercase() { + let value = serde_json::to_value(NodeLinker::Isolated).expect("serialize"); + assert_eq!(value, serde_json::Value::from("isolated")); + let value = serde_json::to_value(NodeLinker::Hoisted).expect("serialize"); + assert_eq!(value, serde_json::Value::from("hoisted")); + let value = serde_json::to_value(NodeLinker::Pnp).expect("serialize"); + assert_eq!(value, serde_json::Value::from("pnp")); +}