1use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct ContextFile {
14 pub dir: PathBuf,
15 pub source_name: &'static str,
16 pub body: String,
17 pub is_global: bool,
18}
19
20const CANDIDATES: &[&str] = &["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
21const MAX_BYTES: usize = 64 * 1024;
22
23pub fn load_project_context_files(cwd: &Path, agent_dir: &Path) -> Vec<ContextFile> {
26 let mut out: Vec<ContextFile> = Vec::new();
27 let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
28
29 if let DirLoad::Found(cf) = load_from_dir(agent_dir, true) {
31 seen.insert(context_file_key(&cf));
32 out.push(cf);
33 }
34
35 let mut ancestors: Vec<ContextFile> = Vec::new();
37 let mut current = cwd.to_path_buf();
38 loop {
39 match load_from_dir(¤t, false) {
40 DirLoad::Found(cf) => {
41 let key = context_file_key(&cf);
42 if !seen.contains(&key) {
43 seen.insert(key);
44 ancestors.push(cf);
45 }
46 }
47 DirLoad::NotFound => {}
48 DirLoad::PermissionDenied => break,
49 }
50 match current.parent() {
51 Some(parent) if parent != current => current = parent.to_path_buf(),
52 _ => break, }
54 }
55 ancestors.reverse();
57 out.extend(ancestors);
58 out
59}
60
61fn context_file_key(cf: &ContextFile) -> PathBuf {
62 let path = cf.dir.join(cf.source_name);
63 match path.canonicalize() {
64 Ok(canonical) => canonical,
65 Err(_) => path,
66 }
67}
68
69enum DirLoad {
70 Found(ContextFile),
71 NotFound,
72 PermissionDenied,
73}
74
75fn load_from_dir(dir: &Path, is_global: bool) -> DirLoad {
76 for &name in CANDIDATES {
77 let path = dir.join(name);
78 let metadata = match std::fs::metadata(&path) {
79 Ok(metadata) => metadata,
80 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
81 return DirLoad::PermissionDenied;
82 }
83 Err(_) => continue,
84 };
85 if !metadata.is_file() {
86 continue;
87 }
88 let raw = match std::fs::read_to_string(&path) {
89 Ok(raw) => raw,
90 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
91 return DirLoad::PermissionDenied;
92 }
93 Err(_) => {
94 tracing::warn!(path = %path.display(), "skipping unreadable context file");
95 return DirLoad::NotFound;
96 }
97 };
98 let body = truncate_to_cap(raw, metadata.len() as usize);
99 return DirLoad::Found(ContextFile {
100 dir: dir.to_path_buf(),
101 source_name: name,
102 body,
103 is_global,
104 });
105 }
106 DirLoad::NotFound
107}
108
109fn truncate_to_cap(raw: String, original_bytes: usize) -> String {
110 if raw.len() <= MAX_BYTES {
111 return raw;
112 }
113 let marker = format!("\n\n[truncated: file was {original_bytes} bytes]\n");
114 let mut cap = MAX_BYTES.saturating_sub(marker.len());
115 while !raw.is_char_boundary(cap) {
116 cap -= 1;
117 }
118 let mut out = raw[..cap].to_string();
119 out.push_str(&marker);
120 out
121}
122
123pub fn assemble_system_prompt(base: &str, context: &[ContextFile], cwd: &Path) -> String {
128 let mut out = String::from(base);
129 for cf in context {
130 let scope = if cf.is_global {
131 "Global"
132 } else if cf.dir == cwd {
133 "Module"
134 } else {
135 "Project"
136 };
137 out.push_str(&format!(
138 "\n\n## {scope} context: {}\n\n{}",
139 cf.dir.display(),
140 cf.body
141 ));
142 }
143 out
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use tempfile::TempDir;
150
151 fn temp_dir() -> TempDir {
152 match tempfile::tempdir() {
153 Ok(dir) => dir,
154 Err(err) => panic!("tempdir failed: {err}"),
155 }
156 }
157
158 fn write(path: &Path, body: &str) {
159 if let Err(err) = std::fs::write(path, body) {
160 panic!("write {} failed: {err}", path.display());
161 }
162 }
163
164 #[test]
165 fn discovers_global_outer_and_inner_in_order() {
166 let agent = temp_dir();
167 write(&agent.path().join("AGENTS.md"), "global-body");
168
169 let proj = temp_dir();
170 let outer = proj.path();
171 let inner = outer.join("inner");
172 if let Err(err) = std::fs::create_dir_all(&inner) {
173 panic!("mkdir failed: {err}");
174 }
175 write(&outer.join("AGENTS.md"), "outer-body");
176 write(&inner.join("CLAUDE.md"), "inner-body");
177
178 let ctx = load_project_context_files(&inner, agent.path());
179 assert_eq!(ctx.len(), 3);
181 assert!(ctx[0].is_global, "first must be global: {:?}", ctx[0]);
182 assert_eq!(ctx[0].body, "global-body");
183 assert_eq!(ctx[1].body, "outer-body"); assert_eq!(ctx[2].body, "inner-body"); assert_eq!(ctx[2].source_name, "CLAUDE.md");
186 }
187
188 #[test]
189 fn agents_md_wins_over_claude_md_at_same_level() {
190 let agent = temp_dir();
191 let proj = temp_dir();
192 write(&proj.path().join("AGENTS.md"), "agents");
193 write(&proj.path().join("CLAUDE.md"), "claude");
194
195 let ctx = load_project_context_files(proj.path(), agent.path());
196 assert_eq!(ctx.len(), 1);
197 assert_eq!(ctx[0].source_name, "AGENTS.md");
198 assert_eq!(ctx[0].body, "agents");
199 }
200
201 #[test]
202 fn returns_empty_when_no_files_anywhere() {
203 let agent = temp_dir();
204 let proj = temp_dir();
205 let ctx = load_project_context_files(proj.path(), agent.path());
206 assert!(ctx.is_empty());
207 }
208
209 #[test]
210 #[cfg(unix)]
211 fn dedups_global_and_project_context_by_canonical_path() {
212 let root = temp_dir();
213 let real = root.path().join("real");
214 let link = root.path().join("link");
215 if let Err(err) = std::fs::create_dir_all(&real) {
216 panic!("mkdir failed: {err}");
217 }
218 if let Err(err) = std::os::unix::fs::symlink(&real, &link) {
219 panic!("symlink failed: {err}");
220 }
221 write(&real.join("AGENTS.md"), "shared");
222
223 let ctx = load_project_context_files(&real, &link);
224 assert_eq!(ctx.len(), 1, "same file should not load twice: {ctx:?}");
225 assert_eq!(ctx[0].body, "shared");
226 }
227
228 #[test]
229 #[cfg(unix)]
230 fn stops_walk_on_permission_denied() {
231 use std::os::unix::fs::PermissionsExt;
232
233 let agent = temp_dir();
234 let root = temp_dir();
235 write(&root.path().join("AGENTS.md"), "outer");
236 let denied = root.path().join("denied");
237 let inner = denied.join("inner");
238 if let Err(err) = std::fs::create_dir_all(&inner) {
239 panic!("mkdir failed: {err}");
240 }
241 if let Err(err) = std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o000))
242 {
243 panic!("chmod denied failed: {err}");
244 }
245
246 let ctx = load_project_context_files(&inner, agent.path());
247
248 if let Err(err) = std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o700))
249 {
250 panic!("restore permissions failed: {err}");
251 }
252 assert!(
253 ctx.is_empty(),
254 "must stop before loading outer context: {ctx:?}"
255 );
256 }
257
258 #[test]
259 fn truncates_files_above_cap_and_marks_footer() {
260 let agent = temp_dir();
261 let proj = temp_dir();
262 let body = "x".repeat(70 * 1024);
263 write(&proj.path().join("AGENTS.md"), &body);
264
265 let ctx = load_project_context_files(proj.path(), agent.path());
266 assert_eq!(ctx.len(), 1);
267 assert!(ctx[0].body.len() < body.len(), "should be truncated");
268 assert!(ctx[0].body.len() <= MAX_BYTES, "should fit cap");
269 assert!(
270 ctx[0].body.contains("[truncated: file was"),
271 "missing marker; body ends: {}",
272 &ctx[0].body[ctx[0].body.len().saturating_sub(120)..]
273 );
274 }
275
276 #[test]
277 fn assemble_labels_global_module_and_project() {
278 let cwd = PathBuf::from("/tmp/proj/inner");
279 let context = vec![
280 ContextFile {
281 dir: PathBuf::from("/Users/x/.capo/agent"),
282 source_name: "AGENTS.md",
283 body: "G".into(),
284 is_global: true,
285 },
286 ContextFile {
287 dir: PathBuf::from("/tmp/proj"),
288 source_name: "AGENTS.md",
289 body: "P".into(),
290 is_global: false,
291 },
292 ContextFile {
293 dir: PathBuf::from("/tmp/proj/inner"),
294 source_name: "CLAUDE.md",
295 body: "M".into(),
296 is_global: false,
297 },
298 ];
299 let out = assemble_system_prompt("BASE", &context, &cwd);
300 assert!(out.starts_with("BASE"));
301 assert!(out.contains("## Global context: /Users/x/.capo/agent\n\nG"));
302 assert!(out.contains("## Project context: /tmp/proj\n\nP"));
303 assert!(out.contains("## Module context: /tmp/proj/inner\n\nM"));
304 }
305}