feat(package-manager): write .pnpm-workspace-state-v1.json after install (#11665)

* 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.
This commit is contained in:
Zoltan Kochan
2026-05-15 11:43:54 +02:00
committed by GitHub
parent 6e93f350a9
commit 4ababc0fc6
8 changed files with 633 additions and 0 deletions

14
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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 }

View File

@@ -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
// <https://github.com/pnpm/pnpm/blob/7ff112bac6/installing/commands/src/installDeps.ts#L447-L454>.
// pnpm's `verifyDepsBeforeRun` gate at
// <https://github.com/pnpm/pnpm/blob/7ff112bac6/deps/status/src/checkDepsStatus.ts#L80-L86>
// 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<String> {
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
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/createWorkspaceState.ts>.
///
/// 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<String, ProjectEntry> {
let mut projects: BTreeMap<String, ProjectEntry> = BTreeMap::new();
let root_entry = ProjectEntry {
name: manifest_string_field(manifest, "name"),
version: manifest_string_field(manifest, "version"),
};
let importer_ids: Vec<String> = 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 <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/createWorkspaceState.ts>.
/// 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;

View File

@@ -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
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/deps/status/src/checkDepsStatus.ts#L80-L86>
/// bails to "outdated" the moment
/// `<workspaceDir>/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::<SilentReporter>()
.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
/// <https://github.com/pnpm/pnpm/blob/b4f8f47ac2/installing/deps-installer/test/install/optionalDependencies.ts#L563-L572>.

View File

@@ -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 }

View File

@@ -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
//! <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/index.ts>.
//!
//! 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
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/filePath.ts>.
pub const WORKSPACE_STATE_FILENAME: &str = ".pnpm-workspace-state-v1.json";
/// `<workspace_dir>/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
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts>.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ProjectEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
/// 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<String, ProjectEntry>,
pub pnpmfiles: Vec<String>,
pub filtered_install: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_dependencies: Option<BTreeMap<String, String>>,
pub settings: WorkspaceStateSettings,
}
/// Subset of pnpm's `Config` keys that `checkDepsStatus` compares to
/// the live config before allowing the fast-path. Listed at
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/types.ts>.
///
/// 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<BTreeMap<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_install_peers: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalogs: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dedupe_direct_deps: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dedupe_injected_deps: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dedupe_peer_dependents: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dedupe_peers: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dev: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_links_from_lockfile: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hoist_pattern: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hoist_workspace_packages: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignored_optional_dependencies: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inject_workspace_packages: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link_workspace_packages: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_linker: Option<NodeLinker>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub optional: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overrides: Option<BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package_extensions: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patched_dependencies: Option<IndexMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peers_suffix_max_length: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefer_workspace_packages: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub production: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_hoist_pattern: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_package_patterns: Option<Vec<String>>,
}
/// 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 `<workspace_dir>/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 `<workspace_dir>/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<Option<WorkspaceState>, 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
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/createWorkspaceState.ts>.
///
/// 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;

View File

@@ -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"));
}