use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const DEFAULT_STATE_DIR: &str = "node_modules";
const INSTALL_STATE_FILE_NAME: &str = "state.json";
const FRESH_STATE_FILE_NAME: &str = "fresh.json";
fn state_dir_name() -> String {
format!(".{}-state", aube_util::embedder().name)
}
fn resolve_paths(project_dir: &Path) -> (PathBuf, PathBuf) {
crate::commands::with_settings_ctx(project_dir, |ctx| {
let modules_dir = project_dir.join(aube_settings::resolved::modules_dir(ctx));
let raw_state = aube_settings::resolved::state_dir(ctx);
let state_parent = if raw_state == DEFAULT_STATE_DIR {
modules_dir.clone()
} else {
crate::commands::expand_setting_path(&raw_state, project_dir)
.unwrap_or_else(|| modules_dir.clone())
};
let state_dir = state_parent.join(state_dir_name());
(modules_dir, state_dir)
})
}
fn state_dir(project_dir: &Path) -> PathBuf {
resolve_paths(project_dir).1
}
fn relative_path_or_original(path: &Path, base: &Path) -> String {
pathdiff::diff_paths(path, base)
.unwrap_or_else(|| path.to_path_buf())
.to_string_lossy()
.replace('\\', "/")
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InstallState {
pub lockfile_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lockfile_snapshot_name: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub member_lockfile_hashes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub member_lockfile_meta: BTreeMap<String, FileMeta>,
pub package_json_hashes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub package_json_meta: BTreeMap<String, FileMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_directory_hashes: Option<BTreeMap<String, LocalDirectoryFingerprint>>,
pub aube_version: String,
#[serde(default, rename = "prod")]
pub section_filtered: bool,
#[serde(default)]
pub settings_hash: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub package_content_hashes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub graph_lthash: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub package_subtree_hashes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub package_json_shape_digests: BTreeMap<String, String>,
#[serde(default)]
pub layout: Option<InstallLayoutState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unreviewed_builds: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct FreshnessState {
lockfile_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
lockfile_snapshot_name: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
member_lockfile_hashes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
member_lockfile_meta: BTreeMap<String, FileMeta>,
package_json_hashes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
package_json_meta: BTreeMap<String, FileMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
local_directory_hashes: Option<BTreeMap<String, LocalDirectoryFingerprint>>,
#[serde(default, rename = "prod")]
section_filtered: bool,
#[serde(default)]
settings_hash: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
package_json_shape_digests: BTreeMap<String, String>,
#[serde(default)]
layout: Option<InstallLayoutState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
unreviewed_builds: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileMeta {
pub size: u64,
pub mtime_secs: i64,
#[serde(default)]
pub mtime_nanos: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalDirectoryFingerprint {
pub content_hash: String,
pub metadata_hash: String,
}
impl FileMeta {
pub fn capture(path: &Path) -> Option<Self> {
let meta = std::fs::metadata(path).ok()?;
let dur = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok());
let (secs, nanos) = match dur {
Some(d) => (d.as_secs() as i64, d.subsec_nanos()),
None => (0, 0),
};
Some(Self {
size: meta.len(),
mtime_secs: secs,
mtime_nanos: nanos,
})
}
}
impl From<&InstallState> for FreshnessState {
fn from(state: &InstallState) -> Self {
Self {
lockfile_hash: state.lockfile_hash.clone(),
lockfile_snapshot_name: state.lockfile_snapshot_name.clone(),
member_lockfile_hashes: state.member_lockfile_hashes.clone(),
member_lockfile_meta: state.member_lockfile_meta.clone(),
package_json_hashes: state.package_json_hashes.clone(),
package_json_meta: state.package_json_meta.clone(),
local_directory_hashes: state.local_directory_hashes.clone(),
section_filtered: state.section_filtered,
settings_hash: state.settings_hash.clone(),
package_json_shape_digests: state.package_json_shape_digests.clone(),
layout: state.layout.clone(),
unreviewed_builds: state.unreviewed_builds.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallLayoutState {
pub linker: InstallLayoutMode,
pub direct_entries: BTreeMap<String, Vec<String>>,
pub packages: BTreeMap<String, InstalledPackageState>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InstallLayoutMode {
Isolated,
Hoisted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledPackageState {
pub name: String,
pub version: String,
pub package_json_path: String,
#[serde(default)]
pub package_json_hash: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub link: bool,
}
pub fn check_needs_install(project_dir: &Path) -> Option<String> {
check_needs_install_inner(project_dir, None)
}
pub fn check_needs_install_with_flags(
project_dir: &Path,
cli_flags: &[(String, String)],
) -> Option<String> {
check_needs_install_inner(project_dir, Some(cli_flags))
}
fn check_needs_install_inner(
project_dir: &Path,
cli_flags: Option<&[(String, String)]>,
) -> Option<String> {
let reason = check_needs_install_compute(project_dir, cli_flags);
match &reason {
Some(reason) => tracing::debug!(
project_dir = %project_dir.display(),
"install warm path miss: {reason}"
),
None => tracing::trace!(
project_dir = %project_dir.display(),
"install warm path hit: nothing to do"
),
}
reason
}
fn check_needs_install_compute(
project_dir: &Path,
cli_flags: Option<&[(String, String)]>,
) -> Option<String> {
let _diag =
aube_util::diag::Span::new(aube_util::diag::Category::Frozen, "check_needs_install");
let (modules_dir, state_path) = resolve_paths(project_dir);
let _diag_read =
aube_util::diag::Span::new(aube_util::diag::Category::Frozen, "read_state_file");
let mut state = match read_or_migrate_fresh_state(&state_path) {
Some(s) => s,
None => return Some("install state not found".into()),
};
drop(_diag_read);
if !modules_dir.exists() {
let name = modules_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("node_modules");
return Some(format!("{name} is missing"));
}
let _diag_lock = aube_util::diag::Span::new(aube_util::diag::Category::Frozen, "lockfile_hash");
let (lockfile_name, lockfile_path) = active_lockfile(project_dir);
let mut lockfile_missing = false;
if let Some(path) = lockfile_path {
let current_hash = hash_file(&path);
if current_hash != state.lockfile_hash {
return Some(format!("{lockfile_name} has changed"));
}
} else if state.member_lockfile_hashes.is_empty() {
lockfile_missing = true;
}
if !state.member_lockfile_hashes.is_empty()
&& let Some(reason) = member_lockfiles_stale(project_dir, &state)
{
return Some(reason);
}
drop(_diag_lock);
let _diag_pjs =
aube_util::diag::Span::new(aube_util::diag::Category::Frozen, "package_jsons_stale");
if let Some(reason) = package_jsons_stale(project_dir, &state) {
return Some(reason);
}
drop(_diag_pjs);
if state.section_filtered {
return Some(
"previous install omitted dependency sections; auto-installing full graph".into(),
);
}
let _diag_layout =
aube_util::diag::Span::new(aube_util::diag::Category::Frozen, "verify_install_layout");
if let Some(reason) = verify_install_layout(project_dir, state.layout.as_ref()) {
return Some(reason);
}
drop(_diag_layout);
if let Some(cli_flags) = cli_flags {
let _diag_settings =
aube_util::diag::Span::new(aube_util::diag::Category::Frozen, "settings_hash");
let current_settings_hash = hash_settings(project_dir, cli_flags);
if current_settings_hash != state.settings_hash {
return Some(".npmrc or workspace config has changed".into());
}
}
if lockfile_missing {
return Some("no lockfile found".into());
}
let Some(local_directory_hashes) = state.local_directory_hashes.as_mut() else {
return Some("local dependency fingerprints not recorded".to_string());
};
let mut refreshed_metadata = false;
for (rel, stored) in local_directory_hashes {
let path = project_dir.join(rel);
let current_metadata = match aube_store::directory_metadata_fingerprint(&path) {
Ok(current) if current == stored.metadata_hash => continue,
Ok(current) => current,
Err(err) => {
tracing::debug!(
path = %path.display(),
error = %err,
"local dependency metadata fingerprint failed"
);
return Some(format!("local dependency {rel} is unreadable"));
}
};
match aube_store::directory_content_fingerprint(&path) {
Ok(current_hash) if current_hash == stored.content_hash => {
stored.metadata_hash = current_metadata;
refreshed_metadata = true;
}
Ok(_) => return Some(format!("local dependency {rel} has changed")),
Err(err) => {
tracing::debug!(
path = %path.display(),
error = %err,
"local dependency content fingerprint failed"
);
return Some(format!("local dependency {rel} is unreadable"));
}
}
}
if refreshed_metadata && let Err(err) = write_fresh_state(&state_path, &state) {
tracing::debug!(
path = %fresh_state_file(&state_path).display(),
error = %err,
"refresh local dependency metadata state failed"
);
}
None
}
fn package_jsons_stale(project_dir: &Path, state: &FreshnessState) -> Option<String> {
for (rel, stored_hash) in &state.package_json_hashes {
let path = if rel == "." {
project_dir.join("package.json")
} else {
project_dir.join(rel)
};
if !path.exists() {
return Some(format!("{rel} is missing"));
}
if let Some(stored_meta) = state.package_json_meta.get(rel)
&& let Some(current_meta) = FileMeta::capture(&path)
&& current_meta == *stored_meta
{
continue;
}
if hash_file(&path) == *stored_hash {
continue;
}
let stale_reason = || {
if rel == "." {
"package.json has changed".into()
} else {
format!("{rel} has changed")
}
};
let Some(stored_shape) = state.package_json_shape_digests.get(rel) else {
return Some(stale_reason());
};
let Ok(content) = std::fs::read(&path) else {
return Some(stale_reason());
};
let parsed: Result<serde_json::Value, _> = serde_json::from_slice(&content);
let Ok(parsed) = parsed else {
return Some(stale_reason());
};
let current_shape = hex::encode(aube_util::hash::manifest_install_shape_digest(&parsed));
if current_shape != *stored_shape {
return Some(stale_reason());
}
}
None
}
fn collect_member_lockfile_state(
project_dir: &Path,
) -> (BTreeMap<String, String>, BTreeMap<String, FileMeta>) {
let mut hashes = BTreeMap::new();
let mut metas = BTreeMap::new();
let shared = crate::commands::with_settings_ctx(project_dir, |ctx| {
aube_settings::resolved::shared_workspace_lockfile(ctx)
});
if shared {
return (hashes, metas);
}
let Ok(members) = aube_workspace::find_workspace_packages(project_dir) else {
return (hashes, metas);
};
for member_dir in members {
let key = relative_path_or_original(&member_dir, project_dir);
match active_lockfile(&member_dir).1 {
Some(path) => {
hashes.insert(key.clone(), hash_file(&path));
if let Some(meta) = FileMeta::capture(&path) {
metas.insert(key, meta);
}
}
None => {
hashes.insert(key, String::new());
}
}
}
(hashes, metas)
}
fn member_lockfiles_stale(project_dir: &Path, state: &FreshnessState) -> Option<String> {
let members = aube_workspace::find_workspace_packages(project_dir).unwrap_or_default();
let mut seen = std::collections::BTreeSet::new();
for member_dir in &members {
let key = relative_path_or_original(member_dir, project_dir);
let Some(stored_hash) = state.member_lockfile_hashes.get(&key) else {
return Some(format!("{key} is a new workspace member"));
};
seen.insert(key.clone());
let Some(path) = active_lockfile(member_dir).1 else {
if stored_hash.is_empty() {
continue;
}
return Some(format!("{key} lockfile is missing"));
};
if let Some(stored_meta) = state.member_lockfile_meta.get(&key)
&& let Some(current_meta) = FileMeta::capture(&path)
&& current_meta == *stored_meta
{
continue;
}
if hash_file(&path) != *stored_hash {
return Some(format!("{key} lockfile has changed"));
}
}
for key in state.member_lockfile_hashes.keys() {
if !seen.contains(key) {
return Some(format!("{key} was removed from the workspace"));
}
}
None
}
pub struct WriteStateLayout<'a> {
pub graph: &'a aube_lockfile::LockfileGraph,
pub node_linker: aube_linker::NodeLinker,
pub modules_dir_name: &'a str,
pub aube_dir: &'a Path,
pub virtual_store_dir_max_length: usize,
pub placements: Option<&'a aube_linker::HoistedPlacements>,
}
pub struct WriteStateInput<'a> {
pub section_filtered: bool,
pub package_json_hashes: BTreeMap<String, String>,
pub cli_flags: &'a [(String, String)],
pub package_content_hashes: BTreeMap<String, String>,
pub graph_lthash: String,
pub package_subtree_hashes: BTreeMap<String, String>,
pub layout: WriteStateLayout<'a>,
pub unreviewed_builds: Vec<String>,
}
pub fn write_state(project_dir: &Path, input: WriteStateInput<'_>) -> Result<(), std::io::Error> {
let WriteStateInput {
section_filtered,
package_json_hashes,
cli_flags,
package_content_hashes,
graph_lthash,
package_subtree_hashes,
layout,
unreviewed_builds,
} = input;
let state_path = state_dir(project_dir);
remove_legacy_state_file(&state_path)?;
let (lockfile_hash, lockfile_snapshot_name) =
snapshot_active_lockfile(project_dir, &state_path)?;
let settings_hash = hash_settings(project_dir, cli_flags);
let install_layout = InstallLayoutState::from_graph(
project_dir,
layout.graph,
layout.node_linker,
layout.modules_dir_name,
layout.aube_dir,
layout.virtual_store_dir_max_length,
layout.placements,
);
let package_json_shape_digests: BTreeMap<String, String> = package_json_hashes
.keys()
.filter_map(|rel| {
let path = if rel == "." {
project_dir.join("package.json")
} else {
project_dir.join(rel)
};
let bytes = std::fs::read(&path).ok()?;
let parsed: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
Some((
rel.clone(),
hex::encode(aube_util::hash::manifest_install_shape_digest(&parsed)),
))
})
.collect();
let package_json_meta: BTreeMap<String, FileMeta> = package_json_hashes
.keys()
.filter_map(|rel| {
let path = if rel == "." {
project_dir.join("package.json")
} else {
project_dir.join(rel)
};
FileMeta::capture(&path).map(|m| (rel.clone(), m))
})
.collect();
let (member_lockfile_hashes, member_lockfile_meta) = collect_member_lockfile_state(project_dir);
let local_directory_hashes = collect_local_directory_hashes(project_dir, layout.graph)?;
let state = InstallState {
lockfile_hash,
lockfile_snapshot_name,
member_lockfile_hashes,
member_lockfile_meta,
package_json_hashes,
package_json_meta,
local_directory_hashes: Some(local_directory_hashes),
aube_version: env!("CARGO_PKG_VERSION").to_string(),
section_filtered,
settings_hash,
package_content_hashes,
graph_lthash,
package_subtree_hashes,
package_json_shape_digests,
layout: Some(install_layout),
unreviewed_builds,
};
let fresh_state = FreshnessState::from(&state);
let json = serde_json::to_string_pretty(&state)?;
aube_util::fs_atomic::atomic_write(&install_state_file(&state_path), json.as_bytes())?;
write_fresh_state(&state_path, &fresh_state)?;
Ok(())
}
fn collect_local_directory_hashes(
project_dir: &Path,
graph: &aube_lockfile::LockfileGraph,
) -> Result<BTreeMap<String, LocalDirectoryFingerprint>, std::io::Error> {
let mut hashes = BTreeMap::new();
for pkg in graph.packages.values() {
let rel = match pkg.local_source.as_ref() {
Some(aube_lockfile::LocalSource::Directory(rel))
| Some(aube_lockfile::LocalSource::Portal(rel)) => rel,
_ => continue,
};
let key = rel.to_string_lossy().replace('\\', "/");
if hashes.contains_key(&key) {
continue;
}
let (content_hash, metadata_hash) =
aube_store::directory_fingerprints(&project_dir.join(rel))
.map_err(std::io::Error::other)?;
hashes.insert(
key,
LocalDirectoryFingerprint {
content_hash,
metadata_hash,
},
);
}
Ok(hashes)
}
fn snapshot_active_lockfile(
project_dir: &Path,
_state_path: &Path,
) -> Result<(String, Option<String>), std::io::Error> {
let (name, path) = active_lockfile(project_dir);
let Some(path) = path else {
return Ok((String::new(), None));
};
let Ok(content) = std::fs::read(&path) else {
return Ok((String::new(), None));
};
Ok((hash_bytes(&content), Some(name)))
}
pub fn read_state_package_content_hashes(project_dir: &Path) -> Option<BTreeMap<String, String>> {
let state = read_state(&state_dir(project_dir))?;
if state.package_content_hashes.is_empty() {
return None;
}
Some(state.package_content_hashes)
}
pub fn read_state_graph_lthash(project_dir: &Path) -> Option<String> {
let state = read_state(&state_dir(project_dir))?;
if state.graph_lthash.is_empty() {
return None;
}
Some(state.graph_lthash)
}
pub fn read_state_subtree_hashes(project_dir: &Path) -> Option<BTreeMap<String, String>> {
let state = read_state(&state_dir(project_dir))?;
if state.package_subtree_hashes.is_empty() {
return None;
}
Some(state.package_subtree_hashes)
}
pub fn read_state_unreviewed_builds(project_dir: &Path) -> Vec<String> {
read_or_migrate_fresh_state(&state_dir(project_dir))
.map(|s| s.unreviewed_builds)
.unwrap_or_default()
}
pub fn remove_state(project_dir: &Path) -> Result<(), std::io::Error> {
let state_path = state_dir(project_dir);
let result = if state_path.is_dir() {
std::fs::remove_dir_all(state_path)
} else {
std::fs::remove_file(state_path)
};
match result {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn active_lockfile(project_dir: &Path) -> (String, Option<PathBuf>) {
let basename = aube_util::embedder().lockfile_basename;
let stem = basename.rsplit_once('.').map_or(basename, |(s, _)| s);
let preferred = aube_lockfile::aube_lock_filename(project_dir);
let preferred_path = project_dir.join(&preferred);
if preferred_path.exists() {
return (preferred, Some(preferred_path));
}
if preferred != basename {
let base = project_dir.join(basename);
if base.exists() {
return (basename.to_string(), Some(base));
}
}
let pnpm_preferred = preferred.replacen(&format!("{stem}."), "pnpm-lock.", 1);
if pnpm_preferred != preferred {
let pnpm_branch = project_dir.join(&pnpm_preferred);
if pnpm_branch.exists() {
return (pnpm_preferred, Some(pnpm_branch));
}
}
let pnpm_base = project_dir.join("pnpm-lock.yaml");
if pnpm_base.exists() {
return ("pnpm-lock.yaml".to_string(), Some(pnpm_base));
}
for name in [
"bun.lock",
"yarn.lock",
"npm-shrinkwrap.json",
"package-lock.json",
] {
let path = project_dir.join(name);
if path.exists() {
return (name.to_string(), Some(path));
}
}
(preferred, None)
}
fn read_state(state_path: &Path) -> Option<InstallState> {
if state_path.is_file() {
let _ = std::fs::remove_file(state_path);
return None;
}
let content = std::fs::read_to_string(install_state_file(state_path)).ok()?;
serde_json::from_str(&content).ok()
}
fn install_state_file(state_path: &Path) -> PathBuf {
state_path.join(INSTALL_STATE_FILE_NAME)
}
fn fresh_state_file(state_path: &Path) -> PathBuf {
state_path.join(FRESH_STATE_FILE_NAME)
}
fn read_fresh_state(state_path: &Path) -> Option<FreshnessState> {
if state_path.is_file() {
let _ = std::fs::remove_file(state_path);
return None;
}
let content = std::fs::read_to_string(fresh_state_file(state_path)).ok()?;
serde_json::from_str(&content).ok()
}
fn read_or_migrate_fresh_state(state_path: &Path) -> Option<FreshnessState> {
if let Some(state) = read_fresh_state(state_path) {
return Some(state);
}
let state = FreshnessState::from(&read_state(state_path)?);
let _ = write_fresh_state(state_path, &state);
Some(state)
}
fn write_fresh_state(state_path: &Path, state: &FreshnessState) -> Result<(), std::io::Error> {
let json = serde_json::to_string_pretty(state)?;
aube_util::fs_atomic::atomic_write(&fresh_state_file(state_path), json.as_bytes())
}
fn remove_legacy_state_file(state_path: &Path) -> Result<(), std::io::Error> {
if state_path.is_file() {
std::fs::remove_file(state_path)?;
}
Ok(())
}
impl InstallLayoutState {
fn from_graph(
project_dir: &Path,
graph: &aube_lockfile::LockfileGraph,
node_linker: aube_linker::NodeLinker,
modules_dir_name: &str,
aube_dir: &Path,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
) -> Self {
let linker = match node_linker {
aube_linker::NodeLinker::Isolated => InstallLayoutMode::Isolated,
aube_linker::NodeLinker::Hoisted => InstallLayoutMode::Hoisted,
};
let mut direct_entries = BTreeMap::new();
for (importer, deps) in &graph.importers {
let modules_base = if importer == "." {
project_dir.join(modules_dir_name)
} else {
project_dir.join(importer).join(modules_dir_name)
};
let entries = deps
.iter()
.map(|dep| relative_path_or_original(&modules_base.join(&dep.name), project_dir))
.collect();
direct_entries.insert(importer.clone(), entries);
}
let mut packages = BTreeMap::new();
let direct_dep_paths: std::collections::BTreeSet<String> = graph
.importers
.get(".")
.into_iter()
.flat_map(|deps| deps.iter().map(|dep| dep.dep_path.clone()))
.collect();
for dep_path in direct_dep_paths {
let Some(pkg) = graph.packages.get(&dep_path) else {
continue;
};
let is_link = matches!(
pkg.local_source.as_ref(),
Some(aube_lockfile::LocalSource::Link(_))
);
let package_json_path = match pkg.local_source.as_ref() {
Some(aube_lockfile::LocalSource::Link(path)) => {
project_dir.join(path).join("package.json")
}
_ => crate::commands::install::materialized_pkg_dir(
aube_dir,
&dep_path,
&pkg.name,
virtual_store_dir_max_length,
placements,
)
.join("package.json"),
};
packages.insert(
dep_path,
InstalledPackageState {
name: pkg.name.clone(),
version: pkg.version.clone(),
package_json_path: relative_path_or_original(&package_json_path, project_dir),
package_json_hash: hash_file_if_exists(&package_json_path).unwrap_or_default(),
link: is_link,
},
);
}
Self {
linker,
direct_entries,
packages,
}
}
}
fn verify_install_layout(
project_dir: &Path,
layout: Option<&InstallLayoutState>,
) -> Option<String> {
let layout = layout?;
for entries in layout.direct_entries.values() {
for rel in entries {
let path = project_dir.join(rel);
if path.symlink_metadata().is_err() {
return Some(format!("installed entry missing: {rel}"));
}
}
}
for pkg in layout.packages.values() {
if pkg.link {
continue;
}
let pkg_json_path = project_dir.join(&pkg.package_json_path);
let current_hash = hash_file_if_exists(&pkg_json_path);
if let Some(current_hash) = current_hash
&& !pkg.package_json_hash.is_empty()
&& pkg.package_json_hash != empty_blake3_hash()
&& current_hash == pkg.package_json_hash
{
continue;
}
let manifest = match read_installed_package_manifest(&pkg_json_path) {
Ok(Some(manifest)) => manifest,
Ok(None) => {
return Some(format!(
"installed package metadata missing: {}",
pkg.package_json_path
));
}
Err(_) => {
return Some(format!(
"installed package metadata unreadable: {}",
pkg.package_json_path
));
}
};
if manifest.name != pkg.name || manifest.version != pkg.version {
return Some(format!(
"installed package metadata changed: {}",
pkg.package_json_path
));
}
}
None
}
#[derive(Deserialize)]
struct InstalledManifest {
#[serde(default)]
name: String,
#[serde(default)]
version: String,
}
fn read_installed_package_manifest(
path: &Path,
) -> Result<Option<InstalledManifest>, std::io::Error> {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let parsed = serde_json::from_str(&content)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
Ok(Some(parsed))
}
pub fn collect_package_json_hashes_from_manifests(
project_dir: &Path,
manifests: &[(String, aube_manifest::PackageJson)],
) -> BTreeMap<String, String> {
manifests
.par_iter()
.filter_map(|(rel, _)| {
let pkg_json = if rel == "." {
project_dir.join("package.json")
} else {
project_dir.join(rel).join("package.json")
};
if !pkg_json.is_file() {
return None;
}
let key = if rel == "." {
".".to_string()
} else {
relative_path_or_original(&pkg_json, project_dir)
};
Some((key, hash_file(&pkg_json)))
})
.collect()
}
fn hash_settings(project_dir: &Path, cli_flags: &[(String, String)]) -> String {
let files = crate::commands::FileSources::load(project_dir);
let (ws_config, raw_workspace) =
aube_manifest::workspace::load_both(project_dir).unwrap_or_default();
let env = aube_settings::values::capture_env();
let ctx = files.ctx(&raw_workspace, &env, cli_flags);
let mut hasher = blake3::Hasher::new();
let node_linker = aube_settings::resolved::node_linker(&ctx);
hasher.update(b"node_linker=");
hasher.update(format!("{node_linker:?}").as_bytes());
hasher.update(b"\0");
let hoist = aube_settings::resolved::hoist(&ctx);
hasher.update(format!("hoist={hoist}\0").as_bytes());
let shamefully_hoist = aube_settings::resolved::shamefully_hoist(&ctx);
hasher.update(format!("shamefully_hoist={shamefully_hoist}\0").as_bytes());
let hoist_pattern = aube_settings::resolved::hoist_pattern(&ctx);
hasher.update(b"hoist_pattern=");
for p in &hoist_pattern {
hasher.update(p.as_bytes());
hasher.update(b"\x1f");
}
hasher.update(b"\0");
let public_hoist_pattern = aube_settings::resolved::public_hoist_pattern(&ctx);
hasher.update(b"public_hoist_pattern=");
for p in &public_hoist_pattern {
hasher.update(p.as_bytes());
hasher.update(b"\x1f");
}
hasher.update(b"\0");
let modules_dir = aube_settings::resolved::modules_dir(&ctx);
hasher.update(format!("modules_dir={modules_dir}\0").as_bytes());
let package_import_method = aube_settings::resolved::package_import_method(&ctx);
hasher.update(b"package_import_method=");
hasher.update(format!("{package_import_method:?}").as_bytes());
hasher.update(b"\0");
let enable_gvs = aube_settings::resolved::enable_global_virtual_store(&ctx);
hasher.update(b"enable_gvs=");
hasher.update(format!("{enable_gvs:?}").as_bytes());
hasher.update(b"\0");
let lockfile_enabled = aube_settings::resolved::lockfile(&ctx);
hasher.update(format!("lockfile={lockfile_enabled}\0").as_bytes());
let enable_modules_dir = aube_settings::resolved::enable_modules_dir(&ctx);
hasher.update(format!("enable_modules_dir={enable_modules_dir}\0").as_bytes());
let virtual_store_only = aube_settings::resolved::virtual_store_only(&ctx);
hasher.update(format!("virtual_store_only={virtual_store_only}\0").as_bytes());
let hoist_workspace_packages = aube_settings::resolved::hoist_workspace_packages(&ctx);
hasher.update(format!("hoist_workspace_packages={hoist_workspace_packages}\0").as_bytes());
let hoisting_limits = aube_settings::resolved::hoisting_limits(&ctx);
hasher.update(b"hoisting_limits=");
hasher.update(format!("{hoisting_limits:?}").as_bytes());
hasher.update(b"\0");
let dedupe_direct_deps = aube_settings::resolved::dedupe_direct_deps(&ctx);
hasher.update(format!("dedupe_direct_deps={dedupe_direct_deps}\0").as_bytes());
let symlink = aube_settings::resolved::symlink(&ctx);
hasher.update(format!("symlink={symlink}\0").as_bytes());
let disable_gvs_for_packages =
aube_settings::resolved::disable_global_virtual_store_for_packages(&ctx);
hasher.update(b"disable_gvs_for_packages=");
for p in &disable_gvs_for_packages {
hasher.update(p.as_bytes());
hasher.update(b"\x1f");
}
hasher.update(b"\0");
hasher.update(b"workspace_yaml=");
let workspace_yaml_files: Vec<&str> = std::iter::once("pnpm-workspace.yaml")
.chain(aube_util::embedder().workspace_yaml)
.collect();
for name in workspace_yaml_files {
let path = project_dir.join(name);
hasher.update(name.as_bytes());
hasher.update(b"\x1f");
if let Ok(bytes) = std::fs::read(&path) {
hasher.update(&bytes);
}
hasher.update(b"\x1e");
}
hasher.update(b"\0");
hasher.update(b"npmrc=");
{
let mut paths: Vec<PathBuf> = vec![project_dir.join(".npmrc")];
if let Some(home) = aube_util::env::home_dir() {
paths.push(home.join(".npmrc"));
}
for path in &paths {
hasher.update(path.as_os_str().as_encoded_bytes());
hasher.update(b"\x1f");
if let Ok(bytes) = std::fs::read(path) {
hasher.update(&bytes);
}
hasher.update(b"\x1e");
}
}
hasher.update(b"\0");
hasher.update(b"pnpmfile=");
if let Some(path) =
crate::pnpmfile::detect(project_dir, None, ws_config.pnpmfile_path.as_deref())
{
hasher.update(path.as_os_str().as_encoded_bytes());
hasher.update(b"\x1f");
if let Ok(bytes) = std::fs::read(&path) {
hasher.update(&bytes);
}
}
hasher.update(b"\0");
hasher.update(b"host=");
hasher.update(std::env::consts::OS.as_bytes());
hasher.update(b"\x1f");
hasher.update(std::env::consts::ARCH.as_bytes());
hasher.update(b"\x1f");
hasher.update(aube_resolver::platform::host_triple().2.as_bytes());
hasher.update(b"\0");
hasher.update(b"patches=");
let patches_sidecar_name = format!(".{}-patches.json", aube_util::embedder().name);
let patches_sidecar = project_dir.join(&patches_sidecar_name);
if let Ok(bytes) = std::fs::read(&patches_sidecar) {
hasher.update(patches_sidecar_name.as_bytes());
hasher.update(b"\x1f");
hasher.update(&bytes);
hasher.update(b"\x1e");
}
let patches_dir = project_dir.join("patches");
if let Ok(entries) = std::fs::read_dir(&patches_dir) {
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for p in paths {
let Some(name) = p.file_name().and_then(|s| s.to_str()) else {
continue;
};
hasher.update(name.as_bytes());
hasher.update(b"\x1f");
if let Ok(bytes) = std::fs::read(&p) {
hasher.update(&bytes);
}
hasher.update(b"\x1e");
}
}
hasher.update(b"\0");
format!("blake3:{}", hasher.finalize().to_hex())
}
fn hash_file(path: &Path) -> String {
let content = std::fs::read(path).unwrap_or_default();
let hash = blake3::hash(&content);
format!("blake3:{}", hash.to_hex())
}
fn hash_bytes(content: &[u8]) -> String {
let hash = blake3::hash(content);
format!("blake3:{}", hash.to_hex())
}
fn hash_file_if_exists(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|content| {
let hash = blake3::hash(&content);
format!("blake3:{}", hash.to_hex())
})
}
fn empty_blake3_hash() -> &'static str {
"blake3:af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
}
#[cfg(test)]
mod tests {
use super::{
InstallLayoutMode, InstallLayoutState, InstallState, InstalledPackageState,
collect_package_json_hashes_from_manifests, empty_blake3_hash, fresh_state_file, hash_file,
hash_settings, install_state_file, member_lockfiles_stale, read_or_migrate_fresh_state,
relative_path_or_original, remove_state, verify_install_layout,
};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[test]
fn relative_path_helper_keeps_original_path_when_diff_fails() {
let original = Path::new("/tmp/aube-test/package.json");
let base = Path::new("project/../project");
assert_eq!(
relative_path_or_original(original, base),
original.to_string_lossy()
);
}
#[test]
fn verify_install_layout_treats_legacy_empty_hash_as_cache_miss() {
let project_dir = temp_project_dir("legacy-empty-hash");
let state = InstallState {
lockfile_hash: String::new(),
lockfile_snapshot_name: None,
member_lockfile_hashes: BTreeMap::new(),
member_lockfile_meta: BTreeMap::new(),
package_json_hashes: BTreeMap::new(),
package_json_meta: BTreeMap::new(),
local_directory_hashes: Some(BTreeMap::new()),
aube_version: String::new(),
section_filtered: false,
settings_hash: String::new(),
package_content_hashes: BTreeMap::new(),
graph_lthash: String::new(),
package_subtree_hashes: BTreeMap::new(),
package_json_shape_digests: BTreeMap::new(),
layout: Some(InstallLayoutState {
linker: InstallLayoutMode::Isolated,
direct_entries: BTreeMap::new(),
packages: BTreeMap::from([(
"is-odd@3.0.1".to_string(),
InstalledPackageState {
name: "is-odd".to_string(),
version: "3.0.1".to_string(),
package_json_path:
"node_modules/.aube/missing/node_modules/is-odd/package.json"
.to_string(),
package_json_hash: empty_blake3_hash().to_string(),
link: false,
},
)]),
}),
unreviewed_builds: Vec::new(),
};
assert_eq!(
verify_install_layout(&project_dir, state.layout.as_ref()),
Some(
"installed package metadata missing: node_modules/.aube/missing/node_modules/is-odd/package.json"
.to_string()
)
);
}
#[cfg(unix)]
#[test]
fn verify_install_layout_treats_dangling_link_symlink_as_installed() {
let project_dir = temp_project_dir("dangling-link");
let scope_dir = project_dir.join("node_modules/@scope");
std::fs::create_dir_all(&scope_dir).expect("node_modules dir should write");
std::os::unix::fs::symlink("../../../api/dist", scope_dir.join("api"))
.expect("symlink should create");
let state = InstallLayoutState {
linker: InstallLayoutMode::Isolated,
direct_entries: BTreeMap::from([(
".".to_string(),
vec!["node_modules/@scope/api".to_string()],
)]),
packages: BTreeMap::from([(
"@scope/api@link:../api/dist".to_string(),
InstalledPackageState {
name: "@scope/api".to_string(),
version: "0.0.0".to_string(),
package_json_path: "../api/dist/package.json".to_string(),
package_json_hash: String::new(),
link: true,
},
)]),
};
assert_eq!(verify_install_layout(&project_dir, Some(&state)), None);
}
#[test]
fn verify_install_layout_flags_missing_link_symlink() {
let project_dir = temp_project_dir("missing-link");
std::fs::create_dir_all(project_dir.join("node_modules/@scope"))
.expect("node_modules dir should write");
let state = InstallLayoutState {
linker: InstallLayoutMode::Isolated,
direct_entries: BTreeMap::from([(
".".to_string(),
vec!["node_modules/@scope/api".to_string()],
)]),
packages: BTreeMap::new(),
};
assert_eq!(
verify_install_layout(&project_dir, Some(&state)),
Some("installed entry missing: node_modules/@scope/api".to_string())
);
}
#[test]
fn from_graph_records_direct_entries_for_every_importer() {
let project_dir = temp_project_dir("layout-all-importers");
let aube_dir = project_dir.join("node_modules/.aube");
let dep = |name: &str, dep_path: &str| aube_lockfile::DirectDep {
name: name.to_string(),
dep_path: dep_path.to_string(),
dep_type: aube_lockfile::DepType::Production,
specifier: None,
};
let mut importers = BTreeMap::new();
importers.insert(".".to_string(), vec![dep("is-odd", "is-odd@3.0.1")]);
importers.insert("packages/svc".to_string(), vec![dep("zod", "zod@3.23.8")]);
let graph = aube_lockfile::LockfileGraph {
importers,
..Default::default()
};
let layout = InstallLayoutState::from_graph(
&project_dir,
&graph,
aube_linker::NodeLinker::Isolated,
"node_modules",
&aube_dir,
120,
None,
);
assert_eq!(
layout.direct_entries.get("."),
Some(&vec!["node_modules/is-odd".to_string()])
);
assert_eq!(
layout.direct_entries.get("packages/svc"),
Some(&vec!["packages/svc/node_modules/zod".to_string()])
);
}
#[test]
fn collect_package_json_hashes_from_manifests_uses_file_paths_for_workspaces() {
let project_dir = temp_project_dir("manifest-hash-keys");
let root_pkg = project_dir.join("package.json");
let ws_pkg = project_dir.join("packages/foo/package.json");
std::fs::create_dir_all(ws_pkg.parent().expect("workspace dir"))
.expect("workspace dir should be creatable");
std::fs::write(&root_pkg, "{\"name\":\"root\"}").expect("root package.json should write");
std::fs::write(&ws_pkg, "{\"name\":\"foo\"}").expect("workspace package.json should write");
let manifests = vec![
(".".to_string(), aube_manifest::PackageJson::default()),
(
"packages/foo".to_string(),
aube_manifest::PackageJson::default(),
),
];
let hashes = collect_package_json_hashes_from_manifests(&project_dir, &manifests);
assert_eq!(hashes.get("."), Some(&hash_file(&root_pkg)));
assert_eq!(
hashes.get("packages/foo/package.json"),
Some(&hash_file(&ws_pkg))
);
}
#[test]
fn state_json_migrates_fresh_state_without_delta_maps() {
let project_dir = temp_project_dir("fresh-migration");
let state_path = project_dir.join(".aube-state");
std::fs::create_dir_all(&state_path).expect("state dir should write");
let state = InstallState {
lockfile_hash: "blake3:lock".to_string(),
lockfile_snapshot_name: None,
member_lockfile_hashes: BTreeMap::new(),
member_lockfile_meta: BTreeMap::new(),
package_json_hashes: BTreeMap::from([(".".to_string(), "blake3:pkg".to_string())]),
package_json_meta: BTreeMap::new(),
local_directory_hashes: Some(BTreeMap::new()),
aube_version: env!("CARGO_PKG_VERSION").to_string(),
section_filtered: false,
settings_hash: "blake3:settings".to_string(),
package_content_hashes: BTreeMap::from([(
"is-odd@3.0.1".to_string(),
"blake3:content".to_string(),
)]),
graph_lthash: "abcdef".to_string(),
package_subtree_hashes: BTreeMap::from([(
"is-odd@3.0.1".to_string(),
"blake3:subtree".to_string(),
)]),
package_json_shape_digests: BTreeMap::from([(".".to_string(), "shape".to_string())]),
layout: Some(InstallLayoutState {
linker: InstallLayoutMode::Isolated,
direct_entries: BTreeMap::new(),
packages: BTreeMap::new(),
}),
unreviewed_builds: Vec::new(),
};
let json = serde_json::to_string(&state).expect("state should serialize");
std::fs::write(install_state_file(&state_path), json).expect("state should write");
let migrated = read_or_migrate_fresh_state(&state_path).expect("fresh state should load");
assert_eq!(migrated.lockfile_hash, "blake3:lock");
let fresh_json = std::fs::read_to_string(fresh_state_file(&state_path))
.expect("fresh state should write");
assert!(fresh_json.contains("package_json_hashes"));
assert!(!fresh_json.contains("package_content_hashes"));
assert!(!fresh_json.contains("package_subtree_hashes"));
}
#[test]
fn legacy_state_file_is_deleted_instead_of_migrated() {
let project_dir = temp_project_dir("legacy-file-delete");
let state_path = project_dir.join(".aube-state");
std::fs::write(&state_path, "{}").expect("legacy state file should write");
assert!(read_or_migrate_fresh_state(&state_path).is_none());
assert!(!state_path.exists());
}
#[test]
fn unreviewed_builds_roundtrip_persists_into_fresh_state() {
use super::read_state_unreviewed_builds;
let project_dir = temp_project_dir("unreviewed-builds-rt");
let state_path = project_dir.join("node_modules/.aube-state");
std::fs::create_dir_all(&state_path).expect("state dir should write");
let state = InstallState {
lockfile_hash: "blake3:lock".to_string(),
lockfile_snapshot_name: None,
member_lockfile_hashes: BTreeMap::new(),
member_lockfile_meta: BTreeMap::new(),
package_json_hashes: BTreeMap::new(),
package_json_meta: BTreeMap::new(),
local_directory_hashes: Some(BTreeMap::new()),
aube_version: env!("CARGO_PKG_VERSION").to_string(),
section_filtered: false,
settings_hash: String::new(),
package_content_hashes: BTreeMap::new(),
graph_lthash: String::new(),
package_subtree_hashes: BTreeMap::new(),
package_json_shape_digests: BTreeMap::new(),
layout: None,
unreviewed_builds: vec![
"esbuild@0.21.5".to_string(),
"better-sqlite3@11.5.0".to_string(),
],
};
let json = serde_json::to_string(&state).expect("state should serialize");
std::fs::write(install_state_file(&state_path), json).expect("state should write");
let _ = read_state_unreviewed_builds(&project_dir);
let unreviewed = read_state_unreviewed_builds(&project_dir);
assert_eq!(
unreviewed,
vec![
"esbuild@0.21.5".to_string(),
"better-sqlite3@11.5.0".to_string()
]
);
}
#[test]
fn unreviewed_builds_default_when_field_missing_in_state() {
use super::read_state_unreviewed_builds;
let project_dir = temp_project_dir("unreviewed-builds-default");
let state_path = project_dir.join("node_modules/.aube-state");
std::fs::create_dir_all(&state_path).expect("state dir should write");
let legacy_json = r#"{
"lockfile_hash": "blake3:lock",
"package_json_hashes": {},
"aube_version": "0.0.0"
}"#;
std::fs::write(install_state_file(&state_path), legacy_json)
.expect("legacy state should write");
let unreviewed = read_state_unreviewed_builds(&project_dir);
assert!(unreviewed.is_empty());
}
#[test]
fn remove_state_deletes_directory_and_legacy_file() {
let project_dir = temp_project_dir("remove-state");
let state_path = project_dir.join("node_modules/.aube-state");
std::fs::create_dir_all(&state_path).expect("state dir should write");
std::fs::write(install_state_file(&state_path), "{}").expect("state json should write");
remove_state(&project_dir).expect("state directory should remove");
assert!(!state_path.exists());
std::fs::create_dir_all(state_path.parent().expect("state parent"))
.expect("state parent should write");
std::fs::write(&state_path, "{}").expect("legacy state file should write");
remove_state(&project_dir).expect("legacy state file should remove");
assert!(!state_path.exists());
}
fn temp_project_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("aube-state-tests-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir should be creatable");
dir
}
#[test]
fn shape_digest_keeps_fast_path_on_cosmetic_edit() {
use std::collections::BTreeMap;
let dir = temp_project_dir("shape-cosmetic");
let original = r#"{
"name": "x",
"dependencies": { "react": "19.0.0" },
"scripts": { "test": "vitest" }
}"#;
let pkg_path = dir.join("package.json");
std::fs::write(&pkg_path, original).unwrap();
let orig_bytes = std::fs::read(&pkg_path).unwrap();
let orig_parsed: serde_json::Value = serde_json::from_slice(&orig_bytes).unwrap();
let orig_shape = hex::encode(aube_util::hash::manifest_install_shape_digest(&orig_parsed));
let mut pjh = BTreeMap::new();
pjh.insert(".".to_string(), hash_file(&pkg_path));
let mut shapes = BTreeMap::new();
shapes.insert(".".to_string(), orig_shape);
let state = InstallState {
lockfile_hash: String::new(),
lockfile_snapshot_name: None,
member_lockfile_hashes: BTreeMap::new(),
member_lockfile_meta: BTreeMap::new(),
package_json_hashes: pjh,
package_json_meta: BTreeMap::new(),
local_directory_hashes: Some(BTreeMap::new()),
aube_version: env!("CARGO_PKG_VERSION").to_string(),
section_filtered: false,
settings_hash: String::new(),
package_content_hashes: BTreeMap::new(),
graph_lthash: String::new(),
package_subtree_hashes: BTreeMap::new(),
package_json_shape_digests: shapes,
layout: None,
unreviewed_builds: Vec::new(),
};
let reformatted = r#"{
"name": "x",
"dependencies": { "react": "19.0.0" },
"scripts": { "test": "jest" }
}
"#;
std::fs::write(&pkg_path, reformatted).unwrap();
let new_bytes = std::fs::read(&pkg_path).unwrap();
let new_parsed: serde_json::Value = serde_json::from_slice(&new_bytes).unwrap();
let new_shape = hex::encode(aube_util::hash::manifest_install_shape_digest(&new_parsed));
assert_eq!(
new_shape, state.package_json_shape_digests["."],
"shape digest should ignore scripts + whitespace"
);
}
#[test]
fn member_lockfiles_stale_detects_edit_add_and_remove() {
let dir = temp_project_dir("member-lockfiles-stale");
std::fs::write(
dir.join("pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n",
)
.unwrap();
let write_member = |name: &str, lock: &str| -> PathBuf {
let d = dir.join("packages").join(name);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("package.json"),
format!("{{\"name\":\"@ws/{name}\"}}"),
)
.unwrap();
let lockfile = d.join("aube-lock.yaml");
std::fs::write(&lockfile, lock).unwrap();
lockfile
};
let a_lock = write_member("a", "lockfileVersion: '9.0'\n# a\n");
let b_lock = write_member("b", "lockfileVersion: '9.0'\n# b\n");
let mut hashes = BTreeMap::new();
hashes.insert("packages/a".to_string(), hash_file(&a_lock));
hashes.insert("packages/b".to_string(), hash_file(&b_lock));
let state = super::FreshnessState {
lockfile_hash: String::new(),
lockfile_snapshot_name: None,
member_lockfile_hashes: hashes,
member_lockfile_meta: BTreeMap::new(),
package_json_hashes: BTreeMap::new(),
package_json_meta: BTreeMap::new(),
local_directory_hashes: Some(BTreeMap::new()),
section_filtered: false,
settings_hash: String::new(),
package_json_shape_digests: BTreeMap::new(),
layout: None,
unreviewed_builds: Vec::new(),
};
assert_eq!(member_lockfiles_stale(&dir, &state), None);
std::fs::write(&a_lock, "lockfileVersion: '9.0'\n# a edited\n").unwrap();
assert_eq!(
member_lockfiles_stale(&dir, &state),
Some("packages/a lockfile has changed".to_string())
);
std::fs::write(&a_lock, "lockfileVersion: '9.0'\n# a\n").unwrap();
assert_eq!(member_lockfiles_stale(&dir, &state), None);
let c_dir = dir.join("packages/c");
write_member("c", "lockfileVersion: '9.0'\n# c\n");
assert_eq!(
member_lockfiles_stale(&dir, &state),
Some("packages/c is a new workspace member".to_string())
);
std::fs::remove_dir_all(&c_dir).unwrap();
std::fs::remove_dir_all(dir.join("packages/b")).unwrap();
assert_eq!(
member_lockfiles_stale(&dir, &state),
Some("packages/b was removed from the workspace".to_string())
);
}
#[test]
fn settings_hash_busts_warm_path_on_pnpmfile_change() {
let dir = temp_project_dir("settings-hash-pnpmfile");
std::fs::write(dir.join("package.json"), r#"{"name":"x"}"#).unwrap();
let baseline = hash_settings(&dir, &[]);
let pnpmfile = dir.join(".pnpmfile.mjs");
std::fs::write(&pnpmfile, "export function readPackage(p){return p}\n").unwrap();
let with_file = hash_settings(&dir, &[]);
assert_ne!(baseline, with_file, "adding a pnpmfile must bust the hash");
std::fs::write(
&pnpmfile,
"export function readPackage(p){p.dependencies={};return p}\n",
)
.unwrap();
let edited = hash_settings(&dir, &[]);
assert_ne!(with_file, edited, "editing a pnpmfile must bust the hash");
std::fs::remove_file(&pnpmfile).unwrap();
assert_eq!(
baseline,
hash_settings(&dir, &[]),
"removing the pnpmfile must restore the baseline hash"
);
}
}