use std::path::{Path, PathBuf};
pub fn resolve_project_root(start: &Path) -> PathBuf {
find_git_root(start).unwrap_or_else(|| start.to_path_buf())
}
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join(".git").exists() {
return Some(current);
}
if !current.pop() {
break;
}
}
None
}
pub fn derive_index_id(project_root: &Path) -> String {
project_root
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
}
const CHECKOUT_SCHEME_VERSION: &str = "checkout-v1";
const CHECKOUT_DIGEST_HEX: usize = 8;
const CHECKOUT_FALLBACK_LABEL: &str = "checkout";
#[must_use]
pub fn derive_checkout_index_id(checkout: &Path) -> Option<String> {
let canonical = std::fs::canonicalize(checkout)
.unwrap_or_else(|_| checkout.components().collect::<PathBuf>());
let name = canonical.file_name()?.to_string_lossy().into_owned();
let slug = crate::slug::slugify_string(&name);
let label = if slug.is_empty() {
CHECKOUT_FALLBACK_LABEL
} else {
slug.as_str()
};
let mut preimage: Vec<u8> = Vec::with_capacity(128);
crate::project_index_id::push_field(&mut preimage, CHECKOUT_SCHEME_VERSION.as_bytes());
crate::project_index_id::push_field(
&mut preimage,
&crate::project_index_id::path_bytes(&canonical),
);
let digest = format!("{:016x}", crate::project_index_id::fnv1a_64(&preimage));
Some(format!("{label}-{}", &digest[..CHECKOUT_DIGEST_HEX]))
}
pub fn identifies_same_path(a: &Path, b: &Path) -> bool {
match same_filesystem_entry(a, b) {
Some(same) => same,
None => a == b,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IndexRootRefusal {
HomeDirectory,
FilesystemRoot,
}
impl std::fmt::Display for IndexRootRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::HomeDirectory => "the operator's home directory",
Self::FilesystemRoot => "the filesystem root",
})
}
}
#[must_use]
pub fn refuse_unindexable_root(root: &Path) -> Option<IndexRootRefusal> {
refuse_unindexable_root_against(root, dirs::home_dir().as_deref())
}
#[must_use]
pub fn refuse_unindexable_root_against(
root: &Path,
home: Option<&Path>,
) -> Option<IndexRootRefusal> {
if root.parent().is_none() {
return Some(IndexRootRefusal::FilesystemRoot);
}
if home.is_some_and(|home| identifies_same_path(root, home)) {
return Some(IndexRootRefusal::HomeDirectory);
}
None
}
#[cfg(unix)]
fn same_filesystem_entry(a: &Path, b: &Path) -> Option<bool> {
use std::os::unix::fs::MetadataExt;
let meta_a = std::fs::metadata(a).ok()?;
let meta_b = std::fs::metadata(b).ok()?;
Some(meta_a.dev() == meta_b.dev() && meta_a.ino() == meta_b.ino())
}
#[cfg(not(unix))]
fn same_filesystem_entry(_a: &Path, _b: &Path) -> Option<bool> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn scratch_dir(tag: &str) -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let p = std::env::temp_dir().join(format!("trusty-index-id-{tag}-{pid}-{nanos}"));
let _ = fs::remove_dir_all(&p);
p
}
#[test]
fn derive_index_id_uses_basename() {
assert_eq!(
derive_index_id(Path::new("/Users/me/code/trusty-tools")),
"trusty-tools"
);
assert_eq!(
derive_index_id(Path::new("/Users/me/code/MyProject")),
"MyProject"
);
assert_eq!(
derive_index_id(Path::new("/srv/Repo_With_Underscores")),
"Repo_With_Underscores"
);
}
#[test]
fn derive_index_id_empty_for_root() {
assert_eq!(derive_index_id(Path::new("/")), "");
}
#[test]
fn derive_checkout_index_id_distinguishes_same_named_checkouts() {
let engagement = Path::new("/w/dogfood-audit-final/repos/local/trusty-tools");
let working = Path::new("/Users/masa/Projects/trusty-tools");
let a = derive_checkout_index_id(engagement).expect("has a basename");
let b = derive_checkout_index_id(working).expect("has a basename");
assert_eq!(
derive_index_id(engagement),
derive_index_id(working),
"the basename rule is what collides"
);
assert_ne!(a, b, "the checkout rule must not: {a} vs {b}");
assert!(a.starts_with("trusty-tools-"), "still readable: {a}");
assert!(b.starts_with("trusty-tools-"), "still readable: {b}");
}
#[test]
fn derive_checkout_index_id_is_stable_across_calls() {
let tmp = scratch_dir("checkout-stable");
let real = tmp.join("repos/acme-api");
fs::create_dir_all(&real).unwrap();
let first = derive_checkout_index_id(&real).expect("id");
assert_eq!(derive_checkout_index_id(&real), Some(first.clone()));
assert_eq!(
derive_checkout_index_id(&tmp.join("repos/../repos/acme-api")),
Some(first.clone()),
"a `..`-laden spelling names the same tree and must derive one id"
);
assert!(first.starts_with("acme-api-"), "{first}");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn derive_checkout_index_id_normalises_an_uncanonicalisable_path() {
let plain = derive_checkout_index_id(Path::new("/w/repos/acme-api")).expect("id");
for spelling in [
"/w/repos/acme-api/",
"/w/repos/acme-api/.",
"/w//repos/acme-api",
] {
assert_eq!(
derive_checkout_index_id(Path::new(spelling)).as_deref(),
Some(plain.as_str()),
"{spelling}"
);
}
}
#[test]
fn derive_checkout_index_id_is_a_single_url_safe_segment() {
for path in [
"/w/repos/My Project!",
"/w/repos/Repo_With_Underscores",
"/w/repos/…",
] {
let id = derive_checkout_index_id(Path::new(path)).expect("id");
assert!(
id.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"{path} → {id}"
);
assert!(!id.starts_with('-') && !id.ends_with('-'), "{path} → {id}");
}
assert!(
derive_checkout_index_id(Path::new("/w/repos/…"))
.expect("id")
.starts_with("checkout-"),
"a basename with no slug-able character falls back to a label"
);
assert_eq!(derive_checkout_index_id(Path::new("/")), None);
}
#[test]
fn derive_checkout_index_id_is_pinned_for_a_known_input() {
assert_eq!(
derive_checkout_index_id(Path::new("/w/repos/acme-api")).as_deref(),
Some("acme-api-05d116f5")
);
}
#[test]
fn resolve_project_root_finds_git_root() {
let tmp = scratch_dir("git");
fs::create_dir_all(tmp.join(".git")).unwrap();
let nested = tmp.join("a/b/c");
fs::create_dir_all(&nested).unwrap();
let root = resolve_project_root(&nested);
assert_eq!(root, tmp);
assert_eq!(derive_index_id(&root), derive_index_id(&tmp));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn resolve_project_root_falls_back_to_start() {
let tmp = scratch_dir("no-git");
fs::create_dir_all(&tmp).unwrap();
let root = resolve_project_root(&tmp);
assert_eq!(root, tmp);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn find_git_root_some_when_repo() {
let tmp = scratch_dir("fgr-git");
fs::create_dir_all(tmp.join(".git")).unwrap();
let nested = tmp.join("a/b/c");
fs::create_dir_all(&nested).unwrap();
assert_eq!(find_git_root(&nested), Some(tmp.clone()));
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(target_os = "macos")]
#[test]
fn same_path_spelled_two_ways_is_the_same_tree() {
let tmp = scratch_dir("Case-Variant");
fs::create_dir_all(&tmp).unwrap();
let name = tmp.file_name().unwrap().to_str().unwrap();
let flipped: String = name
.chars()
.map(|c| {
if c.is_ascii_uppercase() {
c.to_ascii_lowercase()
} else {
c.to_ascii_uppercase()
}
})
.collect();
let variant = tmp.with_file_name(flipped);
assert_ne!(variant, tmp, "the two spellings must differ as strings");
assert!(
identifies_same_path(&tmp, &variant),
"{} and {} are one inode and must compare equal",
tmp.display(),
variant.display()
);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn distinct_trees_are_not_the_same() {
let a = scratch_dir("distinct-a");
let b = scratch_dir("distinct-b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
assert!(!identifies_same_path(&a, &b));
let _ = fs::remove_dir_all(&a);
let _ = fs::remove_dir_all(&b);
}
#[test]
fn missing_paths_fall_back_to_equality() {
let gone = scratch_dir("never-created");
let other = scratch_dir("also-never-created");
assert!(identifies_same_path(&gone, &gone));
assert!(!identifies_same_path(&gone, &other));
}
#[test]
fn refuse_unindexable_root_refuses_the_home_directory() {
let home = scratch_dir("home");
fs::create_dir_all(&home).unwrap();
assert_eq!(
derive_index_id(&home),
home.file_name().unwrap().to_string_lossy(),
"the basename rule is what produces the wrong id"
);
assert_eq!(
refuse_unindexable_root_against(&home, Some(&home)),
Some(IndexRootRefusal::HomeDirectory)
);
let _ = fs::remove_dir_all(&home);
}
#[test]
fn refuse_unindexable_root_refuses_the_filesystem_root() {
assert_eq!(
refuse_unindexable_root_against(Path::new("/"), None),
Some(IndexRootRefusal::FilesystemRoot)
);
assert_eq!(
refuse_unindexable_root(Path::new("/")),
Some(IndexRootRefusal::FilesystemRoot)
);
}
#[test]
fn refuse_unindexable_root_permits_an_ordinary_project_root() {
let home = scratch_dir("home-with-project");
let project = home.join("code/acme-api");
fs::create_dir_all(&project).unwrap();
assert_eq!(refuse_unindexable_root_against(&project, Some(&home)), None);
assert_eq!(refuse_unindexable_root(&project), None);
let _ = fs::remove_dir_all(&home);
}
#[test]
fn refuse_unindexable_root_without_a_home_still_refuses_the_root() {
assert_eq!(
refuse_unindexable_root_against(Path::new(""), None),
Some(IndexRootRefusal::FilesystemRoot)
);
assert_eq!(
refuse_unindexable_root_against(Path::new("/w/repos/acme-api"), None),
None
);
}
#[test]
fn find_git_root_none_when_no_repo() {
let tmp = scratch_dir("fgr-no-git");
fs::create_dir_all(&tmp).unwrap();
assert_eq!(find_git_root(&tmp), None);
let _ = fs::remove_dir_all(&tmp);
}
}