use std::path::{Path, PathBuf};
pub use cortexkit_paths::{IdentityError, ProjectRootId};
pub fn project_scope_key(project_root: &Path) -> String {
let canonical = ProjectRootId::from_path(project_root)
.map(ProjectRootId::into_path_buf)
.unwrap_or_else(|_| lexical_normalize(project_root));
hash16(&canonical)
}
fn hash16(path: &Path) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(path.to_string_lossy().as_bytes());
let digest = format!("{:x}", hasher.finalize());
digest[..16].to_string()
}
fn lexical_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
if !result.pop() {
result.push(component);
}
}
Component::CurDir => {}
other => result.push(other),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_root(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"aft-path-identity-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).expect("create temp root");
dir
}
#[test]
fn scope_key_is_stable_and_16_hex() {
let root = temp_root("stable");
let a = project_scope_key(&root);
let b = project_scope_key(&root);
assert_eq!(a, b);
assert_eq!(a.len(), 16);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn scope_key_distinguishes_distinct_roots() {
let a = temp_root("root-a");
let b = temp_root("root-b");
assert_ne!(project_scope_key(&a), project_scope_key(&b));
}
#[test]
fn scope_key_canonicalizes_equivalent_spellings() {
let root = temp_root("spelling");
let nested = root.join("nested");
fs::create_dir_all(&nested).expect("create nested");
assert_eq!(
project_scope_key(&root),
project_scope_key(&nested.join("..")),
);
assert_eq!(project_scope_key(&root), project_scope_key(&root.join(".")));
}
#[test]
fn scope_key_total_on_non_existent_root() {
let missing = std::env::temp_dir().join("aft-path-identity-definitely-missing-xyz/sub/..");
let key = project_scope_key(&missing);
assert_eq!(key.len(), 16);
}
#[test]
fn worktree_shares_artifact_key_but_has_distinct_scope_key() {
use std::process::Command;
let git_ok = Command::new("git").arg("--version").output().is_ok();
if !git_ok {
eprintln!("skipping: git not available");
return;
}
let tmp = std::env::temp_dir().join(format!(
"aft-worktree-iso-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let main = tmp.join("main");
fs::create_dir_all(&main).expect("create main checkout");
let run = |args: &[&str], cwd: &std::path::Path| {
assert!(
Command::new("git")
.current_dir(cwd)
.args(args)
.status()
.expect("run git")
.success(),
"git {args:?} failed"
);
};
run(&["init"], &main);
fs::write(main.join("f.txt"), "x\n").expect("write file");
run(&["add", "."], &main);
run(
&[
"-c",
"user.name=T",
"-c",
"user.email=t@e.x",
"commit",
"-m",
"init",
],
&main,
);
let worktree = tmp.join("wt");
run(
&[
"worktree",
"add",
worktree.to_str().unwrap(),
"-b",
"feature",
],
&main,
);
let main_artifact = crate::search_index::artifact_cache_key(&main);
let wt_artifact = crate::search_index::artifact_cache_key(&worktree);
let main_scope = project_scope_key(&main);
let wt_scope = project_scope_key(&worktree);
assert_eq!(
main_artifact, wt_artifact,
"worktree must share the main checkout's artifact cache key"
);
assert_ne!(
main_scope, wt_scope,
"worktree must get its own per-checkout scope key"
);
let _ = fs::remove_dir_all(&tmp);
}
}