use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use globset::GlobSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WalkStop {
GitRoot,
FsRoot,
#[default]
Both,
}
impl WalkStop {
#[must_use]
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"git_root" | "gitroot" | "git" => Self::GitRoot,
"fs_root" | "fsroot" | "fs" => Self::FsRoot,
_ => Self::Both,
}
}
}
pub const ANCESTRY_FILENAMES: &[&str] = &[".caliban.md", "CLAUDE.md", "AGENTS.md"];
#[must_use]
pub fn walk_ancestors(cwd: &Path, stop: WalkStop, excludes: &GlobSet) -> Vec<PathBuf> {
let mut per_dir: Vec<Vec<PathBuf>> = Vec::new();
let mut seen: BTreeSet<InodeKey> = BTreeSet::new();
let mut current: Option<PathBuf> = Some(cwd.to_path_buf());
while let Some(dir) = current {
let mut dir_hits = Vec::new();
for name in ANCESTRY_FILENAMES {
let candidate = dir.join(name);
if !candidate.is_file() {
continue;
}
let key = inode_key(&candidate);
if !seen.insert(key) {
continue;
}
let rel = candidate.strip_prefix(cwd).unwrap_or(&candidate);
if excludes.is_match(rel) {
continue;
}
dir_hits.push(candidate);
}
if !dir_hits.is_empty() {
per_dir.push(dir_hits);
}
if reached_stop(&dir, stop) {
break;
}
match dir.parent() {
Some(parent) if parent != dir => current = Some(parent.to_path_buf()),
_ => break,
}
}
per_dir.reverse();
per_dir.into_iter().flatten().collect()
}
fn reached_stop(dir: &Path, stop: WalkStop) -> bool {
match stop {
WalkStop::GitRoot => dir.join(".git").exists(),
WalkStop::FsRoot => dir.parent().is_none(),
WalkStop::Both => dir.join(".git").exists() || dir.parent().is_none(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum InodeKey {
Inode(u64, u64),
Path(PathBuf),
}
fn inode_key(path: &Path) -> InodeKey {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let Ok(md) = std::fs::metadata(path) {
return InodeKey::Inode(md.dev(), md.ino());
}
}
#[cfg(not(unix))]
{
let _ = path;
}
InodeKey::Path(std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn empty_globset() -> GlobSet {
GlobSet::empty()
}
fn excludes(patterns: &[&str]) -> GlobSet {
let mut b = globset::GlobSetBuilder::new();
for p in patterns {
b.add(globset::Glob::new(p).unwrap());
}
b.build().unwrap()
}
#[test]
fn walk_from_subdir_discovers_parent_claude_md() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join("CLAUDE.md"), "ROOT").unwrap();
let sub = root.join("a").join("b");
fs::create_dir_all(&sub).unwrap();
let found = walk_ancestors(&sub, WalkStop::GitRoot, &empty_globset());
assert_eq!(found.len(), 1, "expected one CLAUDE.md");
assert_eq!(
found[0].canonicalize().unwrap(),
root.join("CLAUDE.md").canonicalize().unwrap(),
);
}
#[test]
fn walk_concatenation_order_is_broad_to_narrow() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join("CLAUDE.md"), "ROOT").unwrap();
let mid = root.join("mid");
let leaf = mid.join("leaf");
fs::create_dir_all(&leaf).unwrap();
fs::write(mid.join("CLAUDE.md"), "MID").unwrap();
fs::write(leaf.join("CLAUDE.md"), "LEAF").unwrap();
let found = walk_ancestors(&leaf, WalkStop::GitRoot, &empty_globset());
assert_eq!(found.len(), 3);
let bodies: Vec<_> = found
.iter()
.map(|p| fs::read_to_string(p).unwrap())
.collect();
assert_eq!(bodies, vec!["ROOT", "MID", "LEAF"]);
}
#[cfg(unix)]
#[test]
fn walk_dedupes_by_inode_when_symlink_targets_ancestor() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join("CLAUDE.md"), "ROOT").unwrap();
let sub = root.join("sub");
fs::create_dir_all(&sub).unwrap();
std::os::unix::fs::symlink(root.join("CLAUDE.md"), sub.join("CLAUDE.md")).unwrap();
let found = walk_ancestors(&sub, WalkStop::GitRoot, &empty_globset());
assert_eq!(found.len(), 1, "symlink should be deduped: {found:?}");
}
#[test]
fn walk_loads_both_claude_md_and_agents_md_in_same_dir() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join("CLAUDE.md"), "C").unwrap();
fs::write(root.join("AGENTS.md"), "A").unwrap();
fs::write(root.join(".caliban.md"), "K").unwrap();
let found = walk_ancestors(root, WalkStop::GitRoot, &empty_globset());
let names: Vec<_> = found
.iter()
.map(|p| p.file_name().and_then(|s| s.to_str()).unwrap().to_string())
.collect();
assert_eq!(names, vec![".caliban.md", "CLAUDE.md", "AGENTS.md"]);
}
#[test]
fn walk_honors_excludes_relative_to_cwd() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join("CLAUDE.md"), "ROOT").unwrap();
let vendor = root.join("vendor");
fs::create_dir_all(&vendor).unwrap();
fs::write(vendor.join("CLAUDE.md"), "VENDOR").unwrap();
let g = excludes(&["CLAUDE.md"]);
let found = walk_ancestors(&vendor, WalkStop::GitRoot, &g);
let names: Vec<_> = found.iter().map(|p| p.display().to_string()).collect();
assert!(
!names.iter().any(|n| n.ends_with("vendor/CLAUDE.md")),
"vendor file should be excluded: {names:?}"
);
}
#[test]
fn walk_excludes_via_workspace_relative_pattern() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join("CLAUDE.md"), "ROOT").unwrap();
let g = excludes(&["CLAUDE.md"]); let found = walk_ancestors(root, WalkStop::GitRoot, &g);
assert!(
found.is_empty(),
"excluded file should be skipped: {found:?}"
);
}
#[test]
fn walk_stops_at_git_root() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path();
let inner = outer.join("inner");
let leaf = inner.join("a").join("b");
fs::create_dir_all(&leaf).unwrap();
fs::create_dir_all(inner.join(".git")).unwrap();
fs::write(inner.join("CLAUDE.md"), "INNER").unwrap();
fs::write(outer.join("CLAUDE.md"), "OUTER").unwrap();
let found = walk_ancestors(&leaf, WalkStop::GitRoot, &empty_globset());
assert_eq!(found.len(), 1);
let body = fs::read_to_string(&found[0]).unwrap();
assert_eq!(body, "INNER");
}
}