Skip to main content

rab/agent/
context_files.rs

1/// Context file discovery — AGENTS.md / CLAUDE.md
2///
3/// Mirrors pi's `loadProjectContextFiles()`:
4/// 1. Global AGENTS.md from agent config dir (~/.rab/agent/AGENTS.md)
5/// 2. Walk up from cwd → /, collecting AGENTS.md / CLAUDE.md from each directory
6/// 3. Current directory (included in the ancestor walk above)
7///
8/// All files are deduplicated by resolved absolute path.
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// A discovered AGENTS.md or CLAUDE.md file with its content.
13#[derive(Debug, Clone)]
14pub struct ContextFile {
15    pub path: PathBuf,
16    pub content: String,
17}
18
19/// Candidate filenames checked in each directory.
20const CANDIDATES: &[&str] = &["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
21
22/// Try to load a context file from a directory. Returns `None` if no candidate exists.
23fn load_context_file_from_dir(dir: &Path) -> Option<ContextFile> {
24    for filename in CANDIDATES {
25        let file_path = dir.join(filename);
26        if file_path.exists() {
27            match fs::read_to_string(&file_path) {
28                Ok(content) => {
29                    return Some(ContextFile {
30                        path: fs::canonicalize(&file_path).unwrap_or(file_path),
31                        content,
32                    });
33                }
34                Err(_) => {
35                    // Skip unreadable files, try next candidate
36                    continue;
37                }
38            }
39        }
40    }
41    None
42}
43
44/// Discover context files in the standard locations.
45///
46/// Order: global → ancestors (rootward) → cwd
47/// The returned vec has global first, then ancestors in root-to-leaf order,
48/// so later entries take precedence when concatenated.
49pub fn load_context_files(cwd: &Path, agent_dir: &Path) -> Vec<ContextFile> {
50    let resolved_cwd = if cwd.is_absolute() {
51        cwd.to_path_buf()
52    } else {
53        match fs::canonicalize(cwd) {
54            Ok(p) => p,
55            Err(_) => cwd.to_path_buf(),
56        }
57    };
58    let resolved_agent = if agent_dir.is_absolute() {
59        agent_dir.to_path_buf()
60    } else {
61        match fs::canonicalize(agent_dir) {
62            Ok(p) => p,
63            Err(_) => agent_dir.to_path_buf(),
64        }
65    };
66
67    let mut context_files: Vec<ContextFile> = Vec::new();
68    let mut seen_paths = std::collections::HashSet::new();
69
70    // 1. Global context file from agent config dir
71    if let Some(cf) = load_context_file_from_dir(&resolved_agent) {
72        let canon = cf.path.clone();
73        if seen_paths.insert(canon) {
74            context_files.push(cf);
75        }
76    }
77
78    // 2. Walk ancestors from cwd up to root
79    let root = Path::new("/");
80    let mut current = Some(resolved_cwd.as_path());
81
82    // Collect ancestors in a vec first (cwd first, root last)
83    let mut ancestors: Vec<&Path> = Vec::new();
84    while let Some(dir) = current {
85        ancestors.push(dir);
86        if dir == root {
87            break;
88        }
89        let parent = dir.parent().unwrap_or(root);
90        if parent == dir {
91            break;
92        }
93        current = Some(parent);
94    }
95
96    // Iterate root-to-leaf so global comes first, then closest-to-root files,
97    // then cwd file last (pi does this so later entries are more specific)
98    for dir in ancestors.into_iter().rev() {
99        if let Some(cf) = load_context_file_from_dir(dir) {
100            let canon = cf.path.clone();
101            if seen_paths.insert(canon) {
102                context_files.push(cf);
103            }
104        }
105    }
106
107    context_files
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::fs;
114    use tempfile::TempDir;
115
116    fn create_file(dir: &Path, name: &str, content: &str) -> PathBuf {
117        let path = dir.join(name);
118        fs::write(&path, content).unwrap();
119        path
120    }
121
122    #[test]
123    fn test_load_from_agent_dir() {
124        let tmp = TempDir::new().unwrap();
125        let agent_dir = tmp.path().join("agent");
126        fs::create_dir_all(&agent_dir).unwrap();
127        create_file(&agent_dir, "AGENTS.md", "# Agent rules\n- be careful");
128
129        let cwd = tmp.path().join("project");
130        fs::create_dir_all(&cwd).unwrap();
131
132        let files = load_context_files(&cwd, &agent_dir);
133        assert_eq!(files.len(), 1);
134        assert!(files[0].content.contains("Agent rules"));
135    }
136
137    #[test]
138    fn test_load_from_cwd_preferred() {
139        let tmp = TempDir::new().unwrap();
140        let agent_dir = tmp.path().join("agent");
141        fs::create_dir_all(&agent_dir).unwrap();
142
143        let project = tmp.path().join("project");
144        fs::create_dir_all(&project).unwrap();
145        create_file(&project, "AGENTS.md", "# Project rules");
146
147        let files = load_context_files(&project, &agent_dir);
148        // No global file, just project one
149        assert_eq!(files.len(), 1);
150        assert!(files[0].content.contains("Project rules"));
151    }
152
153    #[test]
154    fn test_both_global_and_project() {
155        let tmp = TempDir::new().unwrap();
156        let agent_dir = tmp.path().join("agent");
157        fs::create_dir_all(&agent_dir).unwrap();
158        create_file(&agent_dir, "AGENTS.md", "# Global rules");
159
160        let project = tmp.path().join("project");
161        fs::create_dir_all(&project).unwrap();
162        create_file(&project, "AGENTS.md", "# Project rules");
163
164        let files = load_context_files(&project, &agent_dir);
165        assert_eq!(files.len(), 2);
166        assert!(files[0].content.contains("Global rules"));
167        assert!(files[1].content.contains("Project rules"));
168    }
169
170    #[test]
171    fn test_claude_md_alternative() {
172        let tmp = TempDir::new().unwrap();
173        let agent_dir = tmp.path().join("agent");
174        fs::create_dir_all(&agent_dir).unwrap();
175
176        let project = tmp.path().join("project");
177        fs::create_dir_all(&project).unwrap();
178        create_file(&project, "CLAUDE.md", "# Claude instructions");
179
180        let files = load_context_files(&project, &agent_dir);
181        assert_eq!(files.len(), 1);
182        assert!(files[0].content.contains("Claude instructions"));
183    }
184
185    #[test]
186    fn test_agents_md_preferred_over_claude_md() {
187        let tmp = TempDir::new().unwrap();
188        let project = tmp.path().join("project");
189        fs::create_dir_all(&project).unwrap();
190        create_file(&project, "AGENTS.md", "# Agents first");
191        create_file(&project, "CLAUDE.md", "# Claude second");
192
193        let agent_dir = tmp.path().join("agent");
194        fs::create_dir_all(&agent_dir).unwrap();
195
196        let files = load_context_files(&project, &agent_dir);
197        // Only AGENTS.md should be loaded (candidates checked in order)
198        assert_eq!(files.len(), 1);
199        assert!(files[0].content.contains("Agents first"));
200    }
201
202    #[test]
203    fn test_deduplicate_by_path() {
204        let tmp = TempDir::new().unwrap();
205        let agent_dir = tmp.path().join("agent");
206        fs::create_dir_all(&agent_dir).unwrap();
207
208        // Same file path appears in both global and cwd if cwd == agent dir
209        create_file(&agent_dir, "AGENTS.md", "# Shared file");
210
211        let files = load_context_files(&agent_dir, &agent_dir);
212        // Should only appear once
213        assert_eq!(files.len(), 1);
214    }
215
216    #[test]
217    fn test_no_context_files_returns_empty() {
218        let tmp = TempDir::new().unwrap();
219        let agent_dir = tmp.path().join("agent");
220        fs::create_dir_all(&agent_dir).unwrap();
221
222        let project = tmp.path().join("project");
223        fs::create_dir_all(&project).unwrap();
224
225        let files = load_context_files(&project, &agent_dir);
226        assert!(files.is_empty());
227    }
228
229    #[test]
230    fn test_ancestor_directories() {
231        let tmp = TempDir::new().unwrap();
232        let agent_dir = tmp.path().join("agent");
233        fs::create_dir_all(&agent_dir).unwrap();
234
235        // Create a nested project structure
236        let parent = tmp.path().join("parent");
237        fs::create_dir_all(&parent).unwrap();
238        create_file(&parent, "AGENTS.md", "# Parent rules");
239
240        let child = parent.join("child");
241        fs::create_dir_all(&child).unwrap();
242        create_file(&child, "AGENTS.md", "# Child rules");
243
244        let files = load_context_files(&child, &agent_dir);
245        assert_eq!(files.len(), 2);
246        // Parent first (closer to root), child second (cwd)
247        assert!(files[0].content.contains("Parent rules"));
248        assert!(files[1].content.contains("Child rules"));
249    }
250
251    #[test]
252    fn test_ignores_non_context_files() {
253        let tmp = TempDir::new().unwrap();
254        let agent_dir = tmp.path().join("agent");
255        fs::create_dir_all(&agent_dir).unwrap();
256
257        let project = tmp.path().join("project");
258        fs::create_dir_all(&project).unwrap();
259        create_file(&project, "README.md", "# Not a context file");
260
261        let files = load_context_files(&project, &agent_dir);
262        assert!(files.is_empty());
263    }
264}