use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const DEFAULT_STATE_DIR: &str = "node_modules";
const STATE_DIR_NAME: &str = ".aube-state";
const INSTALL_STATE_FILE_NAME: &str = "state.json";
const FRESH_STATE_FILE_NAME: &str = "fresh.json";
const LOCKFILE_SNAPSHOT_FILE_NAME: &str = "lockfile";
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>,
pub package_json_hashes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub package_json_meta: BTreeMap<String, FileMeta>,
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>,
package_json_hashes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
package_json_meta: BTreeMap<String, FileMeta>,
#[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,
}
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(),
package_json_hashes: state.package_json_hashes.clone(),
package_json_meta: state.package_json_meta.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,
}
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 (modules_dir, state_path) = resolve_paths(project_dir);
let state = match read_or_migrate_fresh_state(&state_path) {
Some(s) => s,
None => return Some("install state not found".into()),
};
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 (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 {
lockfile_missing = true;
}
if let Some(reason) = package_jsons_stale(project_dir, &state) {
return Some(reason);
}
if state.section_filtered {
return Some(
"previous install omitted dependency sections; auto-installing full graph".into(),
);
}
if let Some(reason) = verify_install_layout(project_dir, state.layout.as_ref()) {
return Some(reason);
}
if let Some(cli_flags) = cli_flags {
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
&& restore_lockfile_snapshot(project_dir, &state_path, &state, &lockfile_name).is_none()
{
return Some("no lockfile found".into());
}
None
}
pub fn restore_missing_lockfile_if_fresh(
project_dir: &Path,
cli_flags: &[(String, String)],
) -> bool {
let (modules_dir, state_path) = resolve_paths(project_dir);
let (lockfile_name, lockfile_path) = active_lockfile(project_dir);
if lockfile_path.is_some() || !modules_dir.exists() {
return false;
}
let Some(state) = read_or_migrate_fresh_state(&state_path) else {
return false;
};
if package_jsons_stale(project_dir, &state).is_some()
|| state.section_filtered
|| verify_install_layout(project_dir, state.layout.as_ref()).is_some()
|| hash_settings(project_dir, cli_flags) != state.settings_hash
{
return false;
}
restore_lockfile_snapshot(project_dir, &state_path, &state, &lockfile_name).is_some()
}
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
}
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 state = InstallState {
lockfile_hash,
lockfile_snapshot_name,
package_json_hashes,
package_json_meta,
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 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 {
let _ = std::fs::remove_file(lockfile_snapshot_file(state_path));
return Ok((String::new(), None));
};
let Ok(content) = std::fs::read(&path) else {
let _ = std::fs::remove_file(lockfile_snapshot_file(state_path));
return Ok((String::new(), None));
};
aube_util::fs_atomic::atomic_write(&lockfile_snapshot_file(state_path), &content)?;
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 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 != "aube-lock.yaml" {
let base = project_dir.join("aube-lock.yaml");
if base.exists() {
return ("aube-lock.yaml".to_string(), Some(base));
}
}
let pnpm_preferred = preferred.replacen("aube-lock.", "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 lockfile_snapshot_file(state_path: &Path) -> PathBuf {
state_path.join(LOCKFILE_SNAPSHOT_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 restore_lockfile_snapshot(
project_dir: &Path,
state_path: &Path,
state: &FreshnessState,
expected_name: &str,
) -> Option<PathBuf> {
let name = state.lockfile_snapshot_name.as_ref()?;
if !is_restorable_lockfile_name(name) {
return None;
}
if is_branch_lockfile_name(name) && name != expected_name {
return None;
}
let content = std::fs::read(lockfile_snapshot_file(state_path)).ok()?;
if hash_bytes(&content) != state.lockfile_hash {
return None;
}
let path = project_dir.join(name);
aube_util::fs_atomic::atomic_write(&path, &content).ok()?;
Some(path)
}
fn is_restorable_lockfile_name(name: &str) -> bool {
matches!(
name,
"aube-lock.yaml"
| "pnpm-lock.yaml"
| "bun.lock"
| "yarn.lock"
| "npm-shrinkwrap.json"
| "package-lock.json"
) || is_branch_lockfile_name(name)
}
fn is_branch_lockfile_name(name: &str) -> bool {
(name.starts_with("aube-lock.") || name.starts_with("pnpm-lock."))
&& name.ends_with(".yaml")
&& name != "aube-lock.yaml"
&& name != "pnpm-lock.yaml"
}
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();
if let Some(deps) = graph.importers.get(".") {
let mut entries = Vec::with_capacity(deps.len());
for dep in deps {
entries.push(project_dir.join(modules_dir_name).join(&dep.name));
}
direct_entries.insert(
".".to_string(),
entries
.into_iter()
.map(|p| relative_path_or_original(&p, project_dir))
.collect(),
);
}
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 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(),
},
);
}
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.exists() {
return Some(format!("installed entry missing: {rel}"));
}
}
}
for pkg in layout.packages.values() {
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 npmrc = aube_registry::config::load_npmrc_entries(project_dir);
let aube_config = crate::commands::config::load_user_aube_config_entries();
let raw_workspace = aube_manifest::workspace::load_raw(project_dir).unwrap_or_default();
let env = aube_settings::values::capture_env();
let ctx = aube_settings::ResolveCtx {
npmrc: &npmrc,
aube_config: &aube_config,
workspace_yaml: &raw_workspace,
env: &env,
cli: 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 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=");
for name in ["pnpm-workspace.yaml", "aube-workspace.yaml"] {
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"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 = project_dir.join(".aube-patches.json");
if let Ok(bytes) = std::fs::read(&patches_sidecar) {
hasher.update(b".aube-patches.json\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,
install_state_file, 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,
package_json_hashes: BTreeMap::new(),
package_json_meta: 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(),
},
)]),
}),
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()
)
);
}
#[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,
package_json_hashes: BTreeMap::from([(".".to_string(), "blake3:pkg".to_string())]),
package_json_meta: 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,
package_json_hashes: BTreeMap::new(),
package_json_meta: 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,
package_json_hashes: pjh,
package_json_meta: 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"
);
}
}