use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::loader::estimate_tokens;
use crate::prefix::TierFile;
use crate::project_walk::{ANCESTRY_FILENAMES, WalkStop};
#[derive(Debug)]
pub struct AncestryAddendum {
workspace_root: PathBuf,
walk_stop: WalkStop,
loaded: Mutex<BTreeSet<PathBuf>>,
}
impl AncestryAddendum {
#[must_use]
pub fn new(
workspace_root: PathBuf,
walk_stop: WalkStop,
initial_loaded: impl IntoIterator<Item = PathBuf>,
) -> Self {
Self {
workspace_root,
walk_stop,
loaded: Mutex::new(initial_loaded.into_iter().collect()),
}
}
pub fn on_path_touched(&self, path: &Path) -> Option<Vec<TierFile>> {
let mut new_files = Vec::new();
let mut current = path.parent().map(Path::to_path_buf);
let mut loaded = self.loaded.lock().expect("addendum mutex poisoned");
while let Some(dir) = current.clone() {
for name in ANCESTRY_FILENAMES {
let candidate = dir.join(name);
if !candidate.is_file() {
continue;
}
if !loaded.insert(candidate.clone()) {
continue;
}
let body = match std::fs::read(&candidate) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => {
tracing::warn!(
target: caliban_common::tracing_targets::TARGET_MEMORY,
path = %candidate.display(),
error = %e,
"failed to read nested CLAUDE.md",
);
continue;
}
};
let estimated_tokens = estimate_tokens(&body);
new_files.push(TierFile {
path: candidate,
body,
estimated_tokens,
truncated_bytes: 0,
});
}
if dir == self.workspace_root {
break;
}
if matches!(self.walk_stop, WalkStop::GitRoot | WalkStop::Both)
&& dir.join(".git").exists()
{
break;
}
current = dir.parent().map(Path::to_path_buf);
}
if new_files.is_empty() {
None
} else {
Some(new_files)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn nested_on_demand_returns_subtree_claude_md() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
let sub = root.join("backend");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("CLAUDE.md"), "BACKEND-CONVENTIONS").unwrap();
let addendum =
AncestryAddendum::new(root.to_path_buf(), WalkStop::GitRoot, std::iter::empty());
let touched = sub.join("server.rs");
fs::write(&touched, "fn main() {}").unwrap();
let new_files = addendum.on_path_touched(&touched).expect("loaded");
assert_eq!(new_files.len(), 1);
assert!(new_files[0].body.contains("BACKEND-CONVENTIONS"));
}
#[test]
fn nested_on_demand_dedupes_after_first_touch() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
let sub = root.join("backend");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("CLAUDE.md"), "X").unwrap();
let addendum =
AncestryAddendum::new(root.to_path_buf(), WalkStop::GitRoot, std::iter::empty());
let f1 = sub.join("a.rs");
let f2 = sub.join("b.rs");
fs::write(&f1, "").unwrap();
fs::write(&f2, "").unwrap();
let first = addendum.on_path_touched(&f1);
let second = addendum.on_path_touched(&f2);
assert!(first.is_some());
assert!(
second.is_none(),
"second touch should not re-load: {second:?}"
);
}
#[test]
fn nested_on_demand_skips_files_initial_walk_already_loaded() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
let sub = root.join("backend");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("CLAUDE.md"), "BACKEND").unwrap();
let addendum = AncestryAddendum::new(
root.to_path_buf(),
WalkStop::GitRoot,
std::iter::once(sub.join("CLAUDE.md")),
);
let touched = sub.join("server.rs");
fs::write(&touched, "").unwrap();
let new_files = addendum.on_path_touched(&touched);
assert!(new_files.is_none(), "already-loaded file should be skipped");
}
#[test]
fn nested_on_demand_stops_at_workspace_root() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path();
let workspace = outer.join("ws");
let sub = workspace.join("a").join("b");
fs::create_dir_all(&sub).unwrap();
fs::write(outer.join("CLAUDE.md"), "OUTSIDE").unwrap();
fs::write(workspace.join("CLAUDE.md"), "WS").unwrap();
fs::write(sub.join("CLAUDE.md"), "SUB").unwrap();
let addendum =
AncestryAddendum::new(workspace.clone(), WalkStop::FsRoot, std::iter::empty());
let touched = sub.join("file.rs");
fs::write(&touched, "").unwrap();
let new_files = addendum.on_path_touched(&touched).expect("loaded");
let bodies: Vec<_> = new_files.iter().map(|f| f.body.as_str()).collect();
assert!(
bodies.contains(&"WS"),
"workspace CLAUDE.md loaded: {bodies:?}"
);
assert!(bodies.contains(&"SUB"));
assert!(
!bodies.iter().any(|b| b == &"OUTSIDE"),
"must not cross workspace root: {bodies:?}",
);
}
}