Skip to main content

xcodeai/agent/
agents_md.rs

1// src/agent/agents_md.rs
2//
3// Auto-loading of project-specific agent rules from AGENTS.md files.
4//
5// Many projects have a file that tells the AI agent how to behave in
6// that specific codebase — coding conventions, forbidden patterns, preferred
7// libraries, etc.  xcodeai looks for these files at session start and
8// prepends their content to the system prompt so the agent is immediately
9// aware of project rules.
10//
11// Search order (first match wins, no concatenation):
12//   1. .xcodeai/AGENTS.md   — xcodeai-specific override
13//   2. AGENTS.md            — convention shared with opencode / claude-code
14//   3. .agents.md           — hidden variant
15//   4. agents.md            — lowercase variant
16//
17// For Rust learners: this module shows idiomatic std::fs::read_to_string
18// usage and how to build clean search-order logic without complex state.
19
20use std::path::Path;
21
22/// Search `project_dir` for an AGENTS.md file and return its content.
23///
24/// Returns `Some(content)` for the first file found in the priority order
25/// listed above, or `None` if no file exists.
26///
27/// # Why first-match-wins?
28/// Concatenating multiple files would make the system prompt unpredictable
29/// in length and could smuggle conflicting instructions.  A single file is
30/// intentional: the most specific file (.xcodeai/AGENTS.md) overrides
31/// the generic one (AGENTS.md).
32pub fn load_agents_md(project_dir: &Path) -> Option<String> {
33    // Priority-ordered list of relative paths to check.
34    // The order matters: more specific paths come first.
35    let candidates: &[&str] = &[".xcodeai/AGENTS.md", "AGENTS.md", ".agents.md", "agents.md"];
36
37    for relative_path in candidates {
38        let full_path = project_dir.join(relative_path);
39        if full_path.is_file() {
40            match std::fs::read_to_string(&full_path) {
41                Ok(content) if !content.trim().is_empty() => {
42                    tracing::info!("Loaded AGENTS.md from: {}", full_path.display());
43                    return Some(content);
44                }
45                Ok(_) => {
46                    // File exists but is empty — skip it.
47                    tracing::debug!("AGENTS.md at {} is empty, skipping", full_path.display());
48                }
49                Err(e) => {
50                    // File exists but can't be read — warn and continue.
51                    tracing::warn!("Could not read AGENTS.md at {}: {}", full_path.display(), e);
52                }
53            }
54        }
55    }
56
57    None
58}
59
60// ─── Unit tests ───────────────────────────────────────────────────────────────
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use std::fs;
66
67    /// Helper: create a file at `dir/relative_path` with `content`.
68    fn write_file(dir: &std::path::Path, relative_path: &str, content: &str) {
69        let path = dir.join(relative_path);
70        if let Some(parent) = path.parent() {
71            fs::create_dir_all(parent).unwrap();
72        }
73        fs::write(&path, content).unwrap();
74    }
75
76    // ── Test: no file → None ─────────────────────────────────────────────────
77
78    #[test]
79    fn test_no_agents_md_returns_none() {
80        // An empty temp directory has no AGENTS.md, so the function must
81        // return None without panicking.
82        let dir = tempfile::tempdir().expect("tempdir");
83        let result = load_agents_md(dir.path());
84        assert!(result.is_none(), "Expected None for empty directory");
85    }
86
87    // ── Test: AGENTS.md is found ─────────────────────────────────────────────
88
89    #[test]
90    fn test_agents_md_is_loaded() {
91        let dir = tempfile::tempdir().expect("tempdir");
92        write_file(dir.path(), "AGENTS.md", "# Rules\nAlways use snake_case.\n");
93
94        let result = load_agents_md(dir.path());
95        assert!(result.is_some(), "Expected Some(content)");
96        assert!(result.unwrap().contains("snake_case"));
97    }
98
99    // ── Test: search order — .xcodeai/AGENTS.md beats AGENTS.md ─────────────
100
101    #[test]
102    fn test_xcodeai_agents_md_takes_priority() {
103        // Both files exist; the .xcodeai/ variant must win.
104        let dir = tempfile::tempdir().expect("tempdir");
105        write_file(dir.path(), ".xcodeai/AGENTS.md", "xcodeai-specific rules");
106        write_file(dir.path(), "AGENTS.md", "generic rules");
107
108        let result = load_agents_md(dir.path()).expect("Expected Some");
109        assert_eq!(result.trim(), "xcodeai-specific rules");
110    }
111
112    // ── Test: AGENTS.md beats .agents.md ────────────────────────────────────
113
114    #[test]
115    fn test_agents_md_beats_dot_agents_md() {
116        let dir = tempfile::tempdir().expect("tempdir");
117        write_file(dir.path(), "AGENTS.md", "uppercase wins");
118        write_file(dir.path(), ".agents.md", "hidden variant");
119
120        let result = load_agents_md(dir.path()).expect("Expected Some");
121        assert_eq!(result.trim(), "uppercase wins");
122    }
123
124    // ── Test: .agents.md is used when AGENTS.md missing ─────────────────────
125
126    #[test]
127    fn test_dot_agents_md_fallback() {
128        let dir = tempfile::tempdir().expect("tempdir");
129        write_file(dir.path(), ".agents.md", "hidden rules");
130
131        let result = load_agents_md(dir.path()).expect("Expected Some");
132        assert_eq!(result.trim(), "hidden rules");
133    }
134
135    // ── Test: agents.md (lowercase) is last resort ───────────────────────────
136
137    #[test]
138    fn test_lowercase_agents_md_last_resort() {
139        let dir = tempfile::tempdir().expect("tempdir");
140        write_file(dir.path(), "agents.md", "lowercase rules");
141
142        let result = load_agents_md(dir.path()).expect("Expected Some");
143        assert_eq!(result.trim(), "lowercase rules");
144    }
145
146    // ── Test: empty file is skipped, falls through to next ───────────────────
147
148    #[test]
149    fn test_empty_agents_md_is_skipped() {
150        let dir = tempfile::tempdir().expect("tempdir");
151        // AGENTS.md exists but is blank — should fall through to .agents.md
152        write_file(dir.path(), "AGENTS.md", "   \n  \n  ");
153        write_file(dir.path(), ".agents.md", "fallback content");
154
155        let result = load_agents_md(dir.path()).expect("Expected Some from fallback");
156        assert_eq!(result.trim(), "fallback content");
157    }
158}