Skip to main content

capo_agent/
context_files.rs

1//! AGENTS.md / CLAUDE.md walk-up (matches pi's
2//! `resource-loader.ts::loadProjectContextFiles`).
3//!
4//! Algorithm: load `agent_dir/AGENTS.md` (or case/CLAUDE variants) as a
5//! global layer if present; then walk from `cwd` upward to FS root,
6//! taking the first matching file at each level. Outer ancestors render
7//! first in the assembled system prompt; cwd-innermost renders last.
8
9use std::path::{Path, PathBuf};
10
11/// One context file loaded from disk.
12#[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
23/// Walk `cwd` upward to FS root, returning context files in outer→inner order
24/// (global first if present, then outermost ancestor, then cwd-innermost last).
25pub 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    // 1. Global agent dir.
30    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    // 2. Walk cwd → root, collecting (outer first via unshift-style reverse).
36    let mut ancestors: Vec<ContextFile> = Vec::new();
37    let mut current = cwd.to_path_buf();
38    loop {
39        match load_from_dir(&current, 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, // FS root reached
53        }
54    }
55    // ancestors collected in inner→outer order; reverse for outer→inner.
56    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
123/// Assemble the final system prompt from the base + loaded context files.
124///
125/// `cwd` is used to label whether a file is the innermost ("Module")
126/// or an outer ancestor ("Project").
127pub 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        // Global (agent_dir) first; then walked from outermost-ancestor → cwd-innermost.
180        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"); // outer ancestor
184        assert_eq!(ctx[2].body, "inner-body"); // cwd-innermost (CLAUDE.md picked because no AGENTS.md here)
185        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}