use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const DEFAULT_STATE_DIR: &str = ".aube/.state";
const STATE_FILE_NAME: &str = "install-state.json";
const LEGACY_STATE_FILES: &[&str] = &[
"node_modules/.aube-state",
"node_modules/.aube/.state/install-state.json",
];
fn state_dir(project_dir: &Path) -> PathBuf {
let default = || project_dir.join(DEFAULT_STATE_DIR);
crate::commands::with_settings_ctx(project_dir, |ctx| {
let raw = aube_settings::resolved::state_dir(ctx);
if raw == DEFAULT_STATE_DIR {
default()
} else {
crate::commands::expand_setting_path(&raw, project_dir).unwrap_or_else(default)
}
})
}
fn state_file(project_dir: &Path) -> PathBuf {
state_dir(project_dir).join(STATE_FILE_NAME)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InstallState {
pub lockfile_hash: String,
pub package_json_hashes: BTreeMap<String, String>,
pub aube_version: String,
#[serde(default, rename = "prod")]
pub section_filtered: bool,
}
pub fn check_needs_install(project_dir: &Path) -> Option<String> {
let state_path = state_file(project_dir);
let state = match read_state(&state_path) {
Some(s) => s,
None => return Some("node_modules not found or never installed by aube".into()),
};
let (lockfile_name, lockfile_path) = active_lockfile(project_dir);
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 {
return Some("no lockfile found".into());
}
let pkg_path = project_dir.join("package.json");
if pkg_path.exists() {
let current_hash = hash_file(&pkg_path);
let stored_hash = state.package_json_hashes.get(".");
if stored_hash != Some(¤t_hash) {
return Some("package.json has changed".into());
}
}
if state.section_filtered {
return Some(
"previous install omitted dependency sections; auto-installing full graph".into(),
);
}
None
}
pub fn write_state(project_dir: &Path, section_filtered: bool) -> Result<(), std::io::Error> {
let mut package_json_hashes = BTreeMap::new();
let pkg_path = project_dir.join("package.json");
if pkg_path.exists() {
package_json_hashes.insert(".".to_string(), hash_file(&pkg_path));
}
let lockfile_hash = match active_lockfile(project_dir).1 {
Some(path) => hash_file(&path),
None => String::new(),
};
let state = InstallState {
lockfile_hash,
package_json_hashes,
aube_version: env!("CARGO_PKG_VERSION").to_string(),
section_filtered,
};
let state_path = state_file(project_dir);
if let Some(parent) = state_path.parent() {
std::fs::create_dir_all(parent)?;
}
cleanup_legacy_state(project_dir);
let json = serde_json::to_string_pretty(&state)?;
std::fs::write(state_path, json)?;
Ok(())
}
pub fn remove_state(project_dir: &Path) -> Result<(), std::io::Error> {
remove_state_file(&state_file(project_dir))?;
for legacy in LEGACY_STATE_FILES {
remove_state_file(&project_dir.join(legacy))?;
}
cleanup_empty_legacy_state_dirs(project_dir);
Ok(())
}
fn cleanup_legacy_state(project_dir: &Path) {
for legacy in LEGACY_STATE_FILES {
let _ = std::fs::remove_file(project_dir.join(legacy));
}
cleanup_empty_legacy_state_dirs(project_dir);
}
fn remove_state_file(path: &Path) -> Result<(), std::io::Error> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn cleanup_empty_legacy_state_dirs(project_dir: &Path) {
let _ = std::fs::remove_dir(project_dir.join("node_modules/.aube/.state"));
let _ = std::fs::remove_dir(project_dir.join("node_modules/.aube"));
}
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(path: &PathBuf) -> Option<InstallState> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn hash_file(path: &Path) -> String {
let content = std::fs::read(path).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(&content);
let hash = hasher.finalize();
format!("sha256:{}", hex::encode(hash))
}