Skip to main content

opi_coding_agent/
context_files.rs

1//! AGENTS.md / CLAUDE.md context file discovery (task 3.7).
2//!
3//! Discovers context files by walking from the current working directory up
4//! to the git root, then optionally checking a global config directory.
5//! Deterministic precedence: nearest directory first, then ancestors, then
6//! global. Per-directory order: AGENTS.md before CLAUDE.md. OPI.md is NOT
7//! loaded (ADR-020).
8
9use std::path::{Path, PathBuf};
10
11/// Maximum size for a single context file (128 KB).
12const MAX_CONTEXT_FILE_SIZE: u64 = 128 * 1024;
13
14/// Context file names to look for, in per-directory order.
15const CONTEXT_FILE_NAMES: &[&str] = &["AGENTS.md", "CLAUDE.md"];
16
17/// Result of context file discovery.
18pub struct ContextFiles {
19    /// Concatenated content with directory headings and separators.
20    pub content: String,
21    /// Number of files successfully loaded.
22    pub files_loaded: usize,
23}
24
25/// Discover and concatenate AGENTS.md and CLAUDE.md context files.
26///
27/// Walks from `cwd` upward to the git root (detected by `.git` presence) or
28/// filesystem root, then checks `global_config_dir` if provided. Returns
29/// concatenated content with per-file headings.
30pub fn discover_context_files(cwd: &Path, global_config_dir: Option<&Path>) -> ContextFiles {
31    let stop_at = find_git_root(cwd);
32    let mut parts: Vec<String> = Vec::new();
33
34    // Walk from cwd upward to git root (inclusive) or filesystem root
35    let mut current: Option<&Path> = Some(cwd);
36    while let Some(dir) = current {
37        load_dir_context(dir, &mut parts);
38        if stop_at.as_deref() == Some(dir) {
39            break;
40        }
41        current = dir.parent();
42    }
43
44    // Check global config dir last
45    if let Some(global_dir) = global_config_dir {
46        load_dir_context(global_dir, &mut parts);
47    }
48
49    if parts.is_empty() {
50        return ContextFiles {
51            content: String::new(),
52            files_loaded: 0,
53        };
54    }
55
56    ContextFiles {
57        content: parts.join("\n\n"),
58        files_loaded: parts.len(),
59    }
60}
61
62fn load_dir_context(dir: &Path, parts: &mut Vec<String>) {
63    for name in CONTEXT_FILE_NAMES {
64        let path = dir.join(name);
65        if let Some(content) = read_context_file(&path) {
66            parts.push(format!("--- {name} ---\n{content}"));
67        }
68    }
69}
70
71fn read_context_file(path: &Path) -> Option<String> {
72    let metadata = std::fs::metadata(path).ok()?;
73
74    // Skip files exceeding the size limit
75    if metadata.len() > MAX_CONTEXT_FILE_SIZE {
76        return None;
77    }
78
79    let content = std::fs::read_to_string(path).ok()?;
80
81    // Skip empty files
82    if content.trim().is_empty() {
83        return None;
84    }
85
86    Some(content)
87}
88
89/// Find the git root by walking upward looking for `.git`.
90fn find_git_root(start: &Path) -> Option<PathBuf> {
91    let mut current = Some(start);
92    while let Some(dir) = current {
93        if dir.join(".git").exists() {
94            return Some(dir.to_path_buf());
95        }
96        current = dir.parent();
97    }
98    None
99}