use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub struct ContextFile {
pub dir: PathBuf,
pub source_name: &'static str,
pub body: String,
pub is_global: bool,
}
const CANDIDATES: &[&str] = &["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
const MAX_BYTES: usize = 64 * 1024;
pub fn load_project_context_files(cwd: &Path, agent_dir: &Path) -> Vec<ContextFile> {
let mut out: Vec<ContextFile> = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
if let DirLoad::Found(cf) = load_from_dir(agent_dir, true) {
seen.insert(context_file_key(&cf));
out.push(cf);
}
let mut ancestors: Vec<ContextFile> = Vec::new();
let mut current = cwd.to_path_buf();
loop {
match load_from_dir(¤t, false) {
DirLoad::Found(cf) => {
let key = context_file_key(&cf);
if !seen.contains(&key) {
seen.insert(key);
ancestors.push(cf);
}
}
DirLoad::NotFound => {}
DirLoad::PermissionDenied => break,
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => break, }
}
ancestors.reverse();
out.extend(ancestors);
out
}
fn context_file_key(cf: &ContextFile) -> PathBuf {
let path = cf.dir.join(cf.source_name);
match path.canonicalize() {
Ok(canonical) => canonical,
Err(_) => path,
}
}
enum DirLoad {
Found(ContextFile),
NotFound,
PermissionDenied,
}
fn load_from_dir(dir: &Path, is_global: bool) -> DirLoad {
for &name in CANDIDATES {
let path = dir.join(name);
let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return DirLoad::PermissionDenied;
}
Err(_) => continue,
};
if !metadata.is_file() {
continue;
}
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
return DirLoad::PermissionDenied;
}
Err(_) => {
tracing::warn!(path = %path.display(), "skipping unreadable context file");
return DirLoad::NotFound;
}
};
let body = truncate_to_cap(raw, metadata.len() as usize);
return DirLoad::Found(ContextFile {
dir: dir.to_path_buf(),
source_name: name,
body,
is_global,
});
}
DirLoad::NotFound
}
fn truncate_to_cap(raw: String, original_bytes: usize) -> String {
if raw.len() <= MAX_BYTES {
return raw;
}
let marker = format!("\n\n[truncated: file was {original_bytes} bytes]\n");
let mut cap = MAX_BYTES.saturating_sub(marker.len());
while !raw.is_char_boundary(cap) {
cap -= 1;
}
let mut out = raw[..cap].to_string();
out.push_str(&marker);
out
}
pub fn assemble_system_prompt(base: &str, context: &[ContextFile], cwd: &Path) -> String {
let mut out = String::from(base);
for cf in context {
let scope = if cf.is_global {
"Global"
} else if cf.dir == cwd {
"Module"
} else {
"Project"
};
out.push_str(&format!(
"\n\n## {scope} context: {}\n\n{}",
cf.dir.display(),
cf.body
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_dir() -> TempDir {
match tempfile::tempdir() {
Ok(dir) => dir,
Err(err) => panic!("tempdir failed: {err}"),
}
}
fn write(path: &Path, body: &str) {
if let Err(err) = std::fs::write(path, body) {
panic!("write {} failed: {err}", path.display());
}
}
#[test]
fn discovers_global_outer_and_inner_in_order() {
let agent = temp_dir();
write(&agent.path().join("AGENTS.md"), "global-body");
let proj = temp_dir();
let outer = proj.path();
let inner = outer.join("inner");
if let Err(err) = std::fs::create_dir_all(&inner) {
panic!("mkdir failed: {err}");
}
write(&outer.join("AGENTS.md"), "outer-body");
write(&inner.join("CLAUDE.md"), "inner-body");
let ctx = load_project_context_files(&inner, agent.path());
assert_eq!(ctx.len(), 3);
assert!(ctx[0].is_global, "first must be global: {:?}", ctx[0]);
assert_eq!(ctx[0].body, "global-body");
assert_eq!(ctx[1].body, "outer-body"); assert_eq!(ctx[2].body, "inner-body"); assert_eq!(ctx[2].source_name, "CLAUDE.md");
}
#[test]
fn agents_md_wins_over_claude_md_at_same_level() {
let agent = temp_dir();
let proj = temp_dir();
write(&proj.path().join("AGENTS.md"), "agents");
write(&proj.path().join("CLAUDE.md"), "claude");
let ctx = load_project_context_files(proj.path(), agent.path());
assert_eq!(ctx.len(), 1);
assert_eq!(ctx[0].source_name, "AGENTS.md");
assert_eq!(ctx[0].body, "agents");
}
#[test]
fn returns_empty_when_no_files_anywhere() {
let agent = temp_dir();
let proj = temp_dir();
let ctx = load_project_context_files(proj.path(), agent.path());
assert!(ctx.is_empty());
}
#[test]
#[cfg(unix)]
fn dedups_global_and_project_context_by_canonical_path() {
let root = temp_dir();
let real = root.path().join("real");
let link = root.path().join("link");
if let Err(err) = std::fs::create_dir_all(&real) {
panic!("mkdir failed: {err}");
}
if let Err(err) = std::os::unix::fs::symlink(&real, &link) {
panic!("symlink failed: {err}");
}
write(&real.join("AGENTS.md"), "shared");
let ctx = load_project_context_files(&real, &link);
assert_eq!(ctx.len(), 1, "same file should not load twice: {ctx:?}");
assert_eq!(ctx[0].body, "shared");
}
#[test]
#[cfg(unix)]
fn stops_walk_on_permission_denied() {
use std::os::unix::fs::PermissionsExt;
let agent = temp_dir();
let root = temp_dir();
write(&root.path().join("AGENTS.md"), "outer");
let denied = root.path().join("denied");
let inner = denied.join("inner");
if let Err(err) = std::fs::create_dir_all(&inner) {
panic!("mkdir failed: {err}");
}
if let Err(err) = std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o000))
{
panic!("chmod denied failed: {err}");
}
let ctx = load_project_context_files(&inner, agent.path());
if let Err(err) = std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o700))
{
panic!("restore permissions failed: {err}");
}
assert!(
ctx.is_empty(),
"must stop before loading outer context: {ctx:?}"
);
}
#[test]
fn truncates_files_above_cap_and_marks_footer() {
let agent = temp_dir();
let proj = temp_dir();
let body = "x".repeat(70 * 1024);
write(&proj.path().join("AGENTS.md"), &body);
let ctx = load_project_context_files(proj.path(), agent.path());
assert_eq!(ctx.len(), 1);
assert!(ctx[0].body.len() < body.len(), "should be truncated");
assert!(ctx[0].body.len() <= MAX_BYTES, "should fit cap");
assert!(
ctx[0].body.contains("[truncated: file was"),
"missing marker; body ends: {}",
&ctx[0].body[ctx[0].body.len().saturating_sub(120)..]
);
}
#[test]
fn assemble_labels_global_module_and_project() {
let cwd = PathBuf::from("/tmp/proj/inner");
let context = vec![
ContextFile {
dir: PathBuf::from("/Users/x/.capo/agent"),
source_name: "AGENTS.md",
body: "G".into(),
is_global: true,
},
ContextFile {
dir: PathBuf::from("/tmp/proj"),
source_name: "AGENTS.md",
body: "P".into(),
is_global: false,
},
ContextFile {
dir: PathBuf::from("/tmp/proj/inner"),
source_name: "CLAUDE.md",
body: "M".into(),
is_global: false,
},
];
let out = assemble_system_prompt("BASE", &context, &cwd);
assert!(out.starts_with("BASE"));
assert!(out.contains("## Global context: /Users/x/.capo/agent\n\nG"));
assert!(out.contains("## Project context: /tmp/proj\n\nP"));
assert!(out.contains("## Module context: /tmp/proj/inner\n\nM"));
}
}