capo-agent 0.5.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
//! AGENTS.md / CLAUDE.md walk-up (matches pi's
//! `resource-loader.ts::loadProjectContextFiles`).
//!
//! Algorithm: load `agent_dir/AGENTS.md` (or case/CLAUDE variants) as a
//! global layer if present; then walk from `cwd` upward to FS root,
//! taking the first matching file at each level. Outer ancestors render
//! first in the assembled system prompt; cwd-innermost renders last.

use std::path::{Path, PathBuf};

/// One context file loaded from disk.
#[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;

/// Walk `cwd` upward to FS root, returning context files in outer→inner order
/// (global first if present, then outermost ancestor, then cwd-innermost last).
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();

    // 1. Global agent dir.
    if let DirLoad::Found(cf) = load_from_dir(agent_dir, true) {
        seen.insert(context_file_key(&cf));
        out.push(cf);
    }

    // 2. Walk cwd → root, collecting (outer first via unshift-style reverse).
    let mut ancestors: Vec<ContextFile> = Vec::new();
    let mut current = cwd.to_path_buf();
    loop {
        match load_from_dir(&current, 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, // FS root reached
        }
    }
    // ancestors collected in inner→outer order; reverse for outer→inner.
    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
}

/// Assemble the final system prompt from the base + loaded context files.
///
/// `cwd` is used to label whether a file is the innermost ("Module")
/// or an outer ancestor ("Project").
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());
        // Global (agent_dir) first; then walked from outermost-ancestor → cwd-innermost.
        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"); // outer ancestor
        assert_eq!(ctx[2].body, "inner-body"); // cwd-innermost (CLAUDE.md picked because no AGENTS.md here)
        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"));
    }
}