use crate::policy::Policy;
use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
pub struct Paths {
pub project_dir: PathBuf,
pub state_dir: PathBuf,
}
impl Paths {
pub fn policy_file(&self) -> PathBuf {
self.project_dir.join("policy.yaml")
}
pub fn log_file(&self) -> PathBuf {
self.state_dir.join("logs").join("audit.jsonl")
}
}
pub fn resolve() -> Result<Paths> {
let cwd = std::env::current_dir()?;
resolve_from(&cwd)
}
pub fn resolve_from(start: &std::path::Path) -> Result<Paths> {
let paths = resolve_readonly(start)?;
fs::create_dir_all(paths.state_dir.join("logs"))?;
fs::create_dir_all(paths.state_dir.join("backups"))?;
migrate_legacy_state(&paths.project_dir, &paths.state_dir)?;
Ok(paths)
}
pub fn resolve_readonly(start: &std::path::Path) -> Result<Paths> {
let Some(policy_file) = Policy::find_policy_file(start) else {
bail!("no .termaxa/policy.yaml found in this directory or any parent — run `termaxa init` first");
};
let project_dir = policy_file.parent().unwrap().to_path_buf();
let project_root = project_dir.parent().unwrap_or(&project_dir).to_path_buf();
let state_dir = state_dir_for(&project_root)?;
Ok(Paths {
project_dir,
state_dir,
})
}
pub fn home_base() -> Result<PathBuf> {
if let Ok(custom) = std::env::var("TERMAXA_HOME") {
if !custom.trim().is_empty() {
return Ok(PathBuf::from(custom));
}
}
let home = std::env::var("USERPROFILE") .or_else(|_| std::env::var("HOME")) .context("cannot locate home directory (USERPROFILE/HOME unset)")?;
Ok(PathBuf::from(home).join(".termaxa"))
}
pub fn demo_state_dir() -> Result<PathBuf> {
let dir = home_base()?.join("demo");
fs::create_dir_all(dir.join("logs"))?;
fs::create_dir_all(dir.join("backups"))?;
Ok(dir)
}
fn state_dir_for(project_root: &Path) -> Result<PathBuf> {
let canonical = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
let name = canonical
.file_name()
.map(|n| sanitize(&n.to_string_lossy()))
.unwrap_or_else(|| "project".into());
let key = format!(
"{}-{}",
name,
fnv1a_hex8(&hash_key(&canonical.to_string_lossy()))
);
Ok(home_base()?.join("projects").join(key))
}
fn hash_key(s: &str) -> String {
let mut out = s.replace('\\', "/");
let bytes = out.as_bytes();
if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
let mut c = out.into_bytes();
c[0] = c[0].to_ascii_lowercase();
out = String::from_utf8(c).unwrap();
}
while out.ends_with('/') && out.len() > 1 {
out.pop();
}
out
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn fnv1a_hex8(s: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{:08x}", (h >> 32) as u32 ^ h as u32)
}
fn migrate_legacy_state(project_dir: &Path, state_dir: &Path) -> Result<()> {
let mut migrated = false;
let old_log = project_dir.join("logs").join("audit.jsonl");
if old_log.is_file() {
let content = fs::read_to_string(&old_log)?;
let new_log = state_dir.join("logs").join("audit.jsonl");
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&new_log)?;
use std::io::Write;
f.write_all(content.as_bytes())?;
fs::remove_file(&old_log)?;
let _ = fs::remove_dir(project_dir.join("logs")); migrated = true;
}
let old_backups = project_dir.join("backups");
if old_backups.is_dir() {
let new_backups = state_dir.join("backups");
let old_prefix = old_backups.to_string_lossy().to_string();
let new_prefix = new_backups.to_string_lossy().to_string();
for entry in fs::read_dir(&old_backups)? {
let entry = entry?;
let name = entry.file_name();
if name == "manifest.jsonl" {
continue; }
move_path(&entry.path(), &new_backups.join(&name))?;
}
let old_manifest = old_backups.join("manifest.jsonl");
if old_manifest.is_file() {
let new_manifest = new_backups.join("manifest.jsonl");
let mut out = String::new();
for line in fs::read_to_string(&old_manifest)?.lines() {
match serde_json::from_str::<serde_json::Value>(line) {
Ok(mut v) => {
rewrite_strings(&mut v, &old_prefix, &new_prefix);
out.push_str(&serde_json::to_string(&v)?);
out.push('\n');
}
Err(_) => {
out.push_str(line);
out.push('\n');
}
}
}
use std::io::Write;
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&new_manifest)?;
f.write_all(out.as_bytes())?;
fs::remove_file(&old_manifest)?;
}
let _ = fs::remove_dir(&old_backups);
migrated = true;
}
if migrated {
eprintln!(
"termaxa: migrated legacy in-repo state to {}",
state_dir.display()
);
}
Ok(())
}
fn rewrite_strings(v: &mut serde_json::Value, old: &str, new: &str) {
match v {
serde_json::Value::String(s) => {
if s.starts_with(old) {
*s = format!("{}{}", new, &s[old.len()..]);
}
}
serde_json::Value::Array(a) => a.iter_mut().for_each(|x| rewrite_strings(x, old, new)),
serde_json::Value::Object(o) => o.values_mut().for_each(|x| rewrite_strings(x, old, new)),
_ => {}
}
}
fn move_path(src: &Path, dst: &Path) -> Result<()> {
if fs::rename(src, dst).is_ok() {
return Ok(());
}
copy_recursive(src, dst)?;
if src.is_dir() {
fs::remove_dir_all(src)?;
} else {
fs::remove_file(src)?;
}
Ok(())
}
fn copy_recursive(src: &Path, dst: &Path) -> Result<()> {
if src.is_dir() {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
copy_recursive(&entry.path(), &dst.join(entry.file_name()))?;
}
} else {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dst)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{TempTree, TestEnv};
#[test]
fn hash_key_stable_across_path_representations() {
assert_eq!(hash_key("C:\\Users\\x\\proj"), hash_key("c:/Users/x/proj"));
assert_eq!(hash_key("C:/Users/x/proj/"), hash_key("c:/Users/x/proj"));
assert_eq!(hash_key("/home/u/proj"), "/home/u/proj");
}
#[test]
fn resolve_from_uses_given_root_not_process_cwd() {
let env = TestEnv::new("cwdtest");
let proj = env.project("proj");
let paths = resolve_from(&proj).expect("resolve_from should find the project policy");
assert!(
paths.policy_file().starts_with(&proj),
"policy must resolve under the given root, got {}",
paths.policy_file().display()
);
assert!(
paths.policy_file().is_file(),
"policy file should exist at resolved path"
);
assert!(
paths.state_dir.starts_with(env.home()),
"state must resolve under this test's own TERMAXA_HOME, got {}",
paths.state_dir.display()
);
}
#[test]
fn only_a_drive_letter_is_case_folded() {
assert_eq!(hash_key("Users/x/proj"), "Users/x/proj");
assert_ne!(hash_key("Users/x/proj"), hash_key("users/x/proj"));
}
#[test]
fn the_root_path_survives_both_guards() {
assert_eq!(hash_key("/"), "/");
assert_eq!(hash_key("/a/"), "/a");
assert_eq!(hash_key("/a///"), "/a");
}
#[test]
fn the_hash_is_the_published_algorithm_not_merely_a_stable_one() {
assert_eq!(fnv1a_hex8("abc"), "e25ef552");
assert_eq!(fnv1a_hex8("/home/u/proj"), "430add1b");
}
#[test]
fn demo_state_dir_sits_under_the_home_and_is_ready_to_write() {
let env = TestEnv::new("demo-state");
let dir = demo_state_dir().expect("demo state must resolve");
assert!(
dir.starts_with(env.home()),
"demo state belongs under TERMAXA_HOME, got {}",
dir.display()
);
assert!(dir.ends_with("demo"));
assert!(dir.join("logs").is_dir());
assert!(dir.join("backups").is_dir());
}
#[test]
fn legacy_in_repo_state_is_moved_and_its_recorded_paths_rewritten() {
let env = TestEnv::new("migrate");
let proj = env.project("legacy");
let in_repo = proj.join(".termaxa");
std::fs::create_dir_all(in_repo.join("logs")).expect("legacy logs must be creatable");
std::fs::write(
in_repo.join("logs").join("audit.jsonl"),
"{\"command\":\"legacy entry\"}\n",
)
.expect("legacy log must be writable");
let old_backups = in_repo.join("backups");
std::fs::create_dir_all(&old_backups).expect("legacy backups must be creatable");
let payload = old_backups.join("payload.sql");
std::fs::write(&payload, "-- dump\n").expect("payload must be writable");
let record = serde_json::json!({
"id": "b1",
"data": { "file": payload.display().to_string() },
});
std::fs::write(old_backups.join("manifest.jsonl"), format!("{}\n", record))
.expect("legacy manifest must be writable");
let paths = resolve_from(&proj).expect("resolve must migrate on the way past");
assert!(!in_repo.join("logs").join("audit.jsonl").exists());
assert!(!payload.exists());
let new_backups = paths.state_dir.join("backups");
assert!(
new_backups.join("payload.sql").is_file(),
"the payload must arrive at {}",
new_backups.display()
);
let log = std::fs::read_to_string(paths.log_file()).expect("the log must be readable");
assert!(log.contains("legacy entry"), "{log:?}");
let manifest = std::fs::read_to_string(new_backups.join("manifest.jsonl"))
.expect("the manifest must be readable");
let record: serde_json::Value =
serde_json::from_str(manifest.trim()).expect("the record must still be JSON");
let recorded = record["data"]["file"]
.as_str()
.expect("the record names its payload");
assert_eq!(
Path::new(recorded),
new_backups.join("payload.sql"),
"the record must name the payload's new home"
);
assert!(
!Path::new(recorded).starts_with(&old_backups),
"no record may still point into the repository: {recorded}"
);
}
#[test]
fn copy_recursive_reproduces_a_whole_tree() {
let tmp = TempTree::new("copy-tree");
let src = tmp.dir("src");
std::fs::create_dir_all(src.join("nested").join("deeper")).expect("tree must be creatable");
std::fs::write(src.join("top.txt"), "top").expect("file must be writable");
std::fs::write(src.join("nested").join("deeper").join("leaf.txt"), "leaf")
.expect("file must be writable");
let dst = tmp.absent("dst");
copy_recursive(&src, &dst).expect("the tree must copy");
assert_eq!(
std::fs::read_to_string(dst.join("top.txt")).expect("top must arrive"),
"top"
);
assert_eq!(
std::fs::read_to_string(dst.join("nested").join("deeper").join("leaf.txt"))
.expect("the nested leaf must arrive"),
"leaf"
);
}
#[test]
fn hash_is_stable_and_distinguishes() {
assert_eq!(fnv1a_hex8("/a/b/project"), fnv1a_hex8("/a/b/project"));
assert_ne!(fnv1a_hex8("/a/b/project"), fnv1a_hex8("/c/d/project"));
}
#[test]
fn sanitize_keeps_names_readable() {
assert_eq!(sanitize("termaxa-demo"), "termaxa-demo");
assert_eq!(sanitize("my proj (v2)"), "my_proj__v2_");
}
#[test]
fn resolve_from_uses_explicit_dir_not_process_cwd() {
let mut env = TestEnv::new("cwd-test");
let proj = env.project("proj");
let aegis = proj.join(".termaxa");
let elsewhere = env.root().join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
env.chdir(&elsewhere);
assert!(
resolve().is_err(),
"process-cwd resolve should not find the policy"
);
let r = resolve_from(&proj);
assert!(
r.is_ok(),
"explicit resolve_from(project cwd) must find the policy"
);
assert_eq!(r.unwrap().policy_file(), aegis.join("policy.yaml"));
}
#[test]
fn resolve_readonly_creates_nothing() {
let env = TestEnv::new("ro");
let proj = env.project("proj");
let p = resolve_readonly(&proj).expect("must resolve without creating anything");
assert!(
!p.state_dir.join("logs").exists(),
"resolve_readonly must not create logs/"
);
assert!(
!p.state_dir.join("backups").exists(),
"resolve_readonly must not create backups/"
);
let p2 = resolve_from(&proj).expect("resolve_from must succeed");
assert!(p2.state_dir.join("logs").is_dir());
assert!(p2.state_dir.join("backups").is_dir());
}
#[test]
fn rewrite_walks_nested_json() {
let mut v: serde_json::Value = serde_json::json!({
"file": "/old/backups/b-1-pg.sql",
"items": [{"saved_as": "/old/backups/b-1/x.txt"}],
"count": 3
});
rewrite_strings(&mut v, "/old/backups", "/new/backups");
assert_eq!(v["file"], "/new/backups/b-1-pg.sql");
assert_eq!(v["items"][0]["saved_as"], "/new/backups/b-1/x.txt");
}
}