caliban_memory/
ancestry_addendum.rs1use std::collections::BTreeSet;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use crate::loader::estimate_tokens;
15use crate::prefix::TierFile;
16use crate::project_walk::{ANCESTRY_FILENAMES, WalkStop};
17
18#[derive(Debug)]
21pub struct AncestryAddendum {
22 workspace_root: PathBuf,
23 walk_stop: WalkStop,
24 loaded: Mutex<BTreeSet<PathBuf>>,
26}
27
28impl AncestryAddendum {
29 #[must_use]
33 pub fn new(
34 workspace_root: PathBuf,
35 walk_stop: WalkStop,
36 initial_loaded: impl IntoIterator<Item = PathBuf>,
37 ) -> Self {
38 Self {
39 workspace_root,
40 walk_stop,
41 loaded: Mutex::new(initial_loaded.into_iter().collect()),
42 }
43 }
44
45 pub fn on_path_touched(&self, path: &Path) -> Option<Vec<TierFile>> {
58 let mut new_files = Vec::new();
59 let mut current = path.parent().map(Path::to_path_buf);
60
61 let mut loaded = self.loaded.lock().expect("addendum mutex poisoned");
62 while let Some(dir) = current.clone() {
63 for name in ANCESTRY_FILENAMES {
64 let candidate = dir.join(name);
65 if !candidate.is_file() {
66 continue;
67 }
68 if !loaded.insert(candidate.clone()) {
69 continue;
70 }
71 let body = match std::fs::read(&candidate) {
72 Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
73 Err(e) => {
74 tracing::warn!(
75 target: caliban_common::tracing_targets::TARGET_MEMORY,
76 path = %candidate.display(),
77 error = %e,
78 "failed to read nested CLAUDE.md",
79 );
80 continue;
81 }
82 };
83 let estimated_tokens = estimate_tokens(&body);
84 new_files.push(TierFile {
85 path: candidate,
86 body,
87 estimated_tokens,
88 truncated_bytes: 0,
89 });
90 }
91 if dir == self.workspace_root {
93 break;
94 }
95 if matches!(self.walk_stop, WalkStop::GitRoot | WalkStop::Both)
97 && dir.join(".git").exists()
98 {
99 break;
100 }
101 current = dir.parent().map(Path::to_path_buf);
102 }
103
104 if new_files.is_empty() {
105 None
106 } else {
107 Some(new_files)
108 }
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use std::fs;
116 use tempfile::TempDir;
117
118 #[test]
119 fn nested_on_demand_returns_subtree_claude_md() {
120 let tmp = TempDir::new().unwrap();
121 let root = tmp.path();
122 fs::create_dir_all(root.join(".git")).unwrap();
123 let sub = root.join("backend");
124 fs::create_dir_all(&sub).unwrap();
125 fs::write(sub.join("CLAUDE.md"), "BACKEND-CONVENTIONS").unwrap();
126
127 let addendum =
128 AncestryAddendum::new(root.to_path_buf(), WalkStop::GitRoot, std::iter::empty());
129 let touched = sub.join("server.rs");
130 fs::write(&touched, "fn main() {}").unwrap();
131 let new_files = addendum.on_path_touched(&touched).expect("loaded");
132 assert_eq!(new_files.len(), 1);
133 assert!(new_files[0].body.contains("BACKEND-CONVENTIONS"));
134 }
135
136 #[test]
137 fn nested_on_demand_dedupes_after_first_touch() {
138 let tmp = TempDir::new().unwrap();
139 let root = tmp.path();
140 fs::create_dir_all(root.join(".git")).unwrap();
141 let sub = root.join("backend");
142 fs::create_dir_all(&sub).unwrap();
143 fs::write(sub.join("CLAUDE.md"), "X").unwrap();
144
145 let addendum =
146 AncestryAddendum::new(root.to_path_buf(), WalkStop::GitRoot, std::iter::empty());
147 let f1 = sub.join("a.rs");
148 let f2 = sub.join("b.rs");
149 fs::write(&f1, "").unwrap();
150 fs::write(&f2, "").unwrap();
151 let first = addendum.on_path_touched(&f1);
152 let second = addendum.on_path_touched(&f2);
153 assert!(first.is_some());
154 assert!(
155 second.is_none(),
156 "second touch should not re-load: {second:?}"
157 );
158 }
159
160 #[test]
161 fn nested_on_demand_skips_files_initial_walk_already_loaded() {
162 let tmp = TempDir::new().unwrap();
163 let root = tmp.path();
164 fs::create_dir_all(root.join(".git")).unwrap();
165 let sub = root.join("backend");
166 fs::create_dir_all(&sub).unwrap();
167 fs::write(sub.join("CLAUDE.md"), "BACKEND").unwrap();
168
169 let addendum = AncestryAddendum::new(
171 root.to_path_buf(),
172 WalkStop::GitRoot,
173 std::iter::once(sub.join("CLAUDE.md")),
174 );
175 let touched = sub.join("server.rs");
176 fs::write(&touched, "").unwrap();
177 let new_files = addendum.on_path_touched(&touched);
178 assert!(new_files.is_none(), "already-loaded file should be skipped");
179 }
180
181 #[test]
182 fn nested_on_demand_stops_at_workspace_root() {
183 let tmp = TempDir::new().unwrap();
184 let outer = tmp.path();
185 let workspace = outer.join("ws");
186 let sub = workspace.join("a").join("b");
187 fs::create_dir_all(&sub).unwrap();
188 fs::write(outer.join("CLAUDE.md"), "OUTSIDE").unwrap();
190 fs::write(workspace.join("CLAUDE.md"), "WS").unwrap();
191 fs::write(sub.join("CLAUDE.md"), "SUB").unwrap();
192
193 let addendum =
194 AncestryAddendum::new(workspace.clone(), WalkStop::FsRoot, std::iter::empty());
195 let touched = sub.join("file.rs");
196 fs::write(&touched, "").unwrap();
197 let new_files = addendum.on_path_touched(&touched).expect("loaded");
198 let bodies: Vec<_> = new_files.iter().map(|f| f.body.as_str()).collect();
199 assert!(
200 bodies.contains(&"WS"),
201 "workspace CLAUDE.md loaded: {bodies:?}"
202 );
203 assert!(bodies.contains(&"SUB"));
204 assert!(
205 !bodies.iter().any(|b| b == &"OUTSIDE"),
206 "must not cross workspace root: {bodies:?}",
207 );
208 }
209}