use anyhow::Context;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
fn project_root(start: &Path) -> PathBuf {
let mut cur = start;
loop {
if cur.join(".task-journal").is_dir() || cur.join(".git").exists() {
return cur.to_path_buf();
}
match cur.parent() {
Some(p) => cur = p,
None => return start.to_path_buf(),
}
}
}
pub fn from_path(p: impl AsRef<Path>) -> anyhow::Result<String> {
let canonical = dunce::canonicalize(p.as_ref())
.with_context(|| format!("canonicalize {:?}", p.as_ref()))?;
let root = project_root(&canonical);
let bytes = root.as_os_str().as_encoded_bytes();
let mut h = Sha256::new();
h.update(bytes);
let digest = h.finalize();
let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
debug_assert_eq!(hex.len(), 16);
Ok(hex)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn same_path_yields_same_hash() {
let d = TempDir::new().unwrap();
let a = from_path(d.path()).unwrap();
let b = from_path(d.path()).unwrap();
assert_eq!(a, b);
assert_eq!(a.len(), 16, "16 hex chars expected, got: {a}");
}
#[test]
fn different_paths_yield_different_hashes() {
let d1 = TempDir::new().unwrap();
let d2 = TempDir::new().unwrap();
let a = from_path(d1.path()).unwrap();
let b = from_path(d2.path()).unwrap();
assert_ne!(a, b);
}
#[test]
fn subdir_under_git_root_hashes_to_root() {
let repo = TempDir::new().unwrap();
std::fs::create_dir(repo.path().join(".git")).unwrap();
let sub = repo.path().join("src").join("foo");
std::fs::create_dir_all(&sub).unwrap();
let root_hash = from_path(repo.path()).unwrap();
let sub_hash = from_path(&sub).unwrap();
assert_eq!(
root_hash, sub_hash,
"subdir of a git repo must hash to the repo root, not the subdir"
);
}
#[test]
fn dot_task_journal_marker_overrides_git_boundary() {
let repo = TempDir::new().unwrap();
std::fs::create_dir(repo.path().join(".git")).unwrap();
let sub = repo.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::create_dir(sub.join(".task-journal")).unwrap();
let root_hash = from_path(repo.path()).unwrap();
let sub_hash = from_path(&sub).unwrap();
assert_ne!(
root_hash, sub_hash,
"subdir with .task-journal/ marker must NOT inherit parent's project hash"
);
}
#[test]
fn dot_git_file_in_worktree_root_is_a_boundary() {
let wt = TempDir::new().unwrap();
std::fs::write(wt.path().join(".git"), "gitdir: /elsewhere\n").unwrap();
let sub = wt.path().join("inner");
std::fs::create_dir(&sub).unwrap();
let wt_hash = from_path(wt.path()).unwrap();
let sub_hash = from_path(&sub).unwrap();
assert_eq!(
wt_hash, sub_hash,
"worktree subdir must normalise to worktree root via .git file"
);
}
}