Skip to main content

sac/
agents_md.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7use crate::paths::{sac_config_path, sac_home_dir};
8
9const AGENTS_MD_MAX_BYTES: usize = 4 * 1024 * 1024;
10const AGENTS_MD_NOTICE: &str =
11    "Below are instructions from the user's AGENTS.md configuration files. More specific files override broader ones when they conflict.";
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct AgentsMdFile {
15    pub path: PathBuf,
16    pub content: String,
17}
18
19#[derive(Clone, Debug, Default)]
20pub struct AgentsMdBundle {
21    files: Vec<AgentsMdFile>,
22}
23
24#[derive(Debug, Default, Deserialize)]
25struct AgentsMdConfigFile {
26    #[serde(default)]
27    agents_md: AgentsMdConfigSection,
28    project_doc_fallback_filenames: Option<Vec<String>>,
29    project_doc_max_bytes: Option<usize>,
30}
31
32#[derive(Debug, Default, Deserialize)]
33struct AgentsMdConfigSection {
34    fallback_filenames: Option<Vec<String>>,
35    max_bytes: Option<usize>,
36}
37
38#[derive(Debug, Clone)]
39struct AgentsMdSettings {
40    fallback_filenames: Vec<String>,
41    max_bytes: usize,
42}
43
44impl AgentsMdBundle {
45    pub fn load(workspace_dir: Option<&Path>) -> Result<Self> {
46        let settings = load_settings();
47        let mut files = Vec::new();
48
49        if let Some(global_file) = select_non_empty_file(
50            sac_home_dir().as_deref(),
51            &["AGENTS.override.md", "AGENTS.md"],
52        )? {
53            files.push(global_file);
54        }
55
56        if let Some(workspace_dir) = workspace_dir {
57            let dirs = if let Some(root) = find_project_root(workspace_dir) {
58                dirs_from_root_to_scope(&root, workspace_dir)
59            } else {
60                vec![workspace_dir.to_path_buf()]
61            };
62            for dir in dirs {
63                let mut names = vec!["AGENTS.override.md", "AGENTS.md"];
64                for fallback in &settings.fallback_filenames {
65                    names.push(fallback.as_str());
66                }
67                if let Some(file) = select_non_empty_file(Some(&dir), &names)? {
68                    files.push(file);
69                }
70            }
71        }
72
73        Ok(Self {
74            files: truncate_files_to_limit(files, settings.max_bytes),
75        })
76    }
77
78    pub fn status_text(&self) -> String {
79        match self.files.len() {
80            0 => "off".to_string(),
81            1 => "1 file loaded".to_string(),
82            count => format!("{count} files loaded"),
83        }
84    }
85
86    pub fn system_message(&self) -> Option<String> {
87        if self.files.is_empty() {
88            return None;
89        }
90
91        Some(render_system_message(&self.files))
92    }
93
94    pub fn files(&self) -> &[AgentsMdFile] {
95        &self.files
96    }
97}
98
99fn render_system_message(files: &[AgentsMdFile]) -> String {
100    let docs = files
101        .iter()
102        .map(|file| file.content.as_str())
103        .collect::<Vec<_>>()
104        .join("\n\n");
105    format!("{AGENTS_MD_NOTICE}\n\n{docs}")
106}
107
108fn truncate_files_to_limit(files: Vec<AgentsMdFile>, max_bytes: usize) -> Vec<AgentsMdFile> {
109    let mut kept = Vec::new();
110    let mut used_bytes = 0usize;
111
112    for mut file in files {
113        let separator_bytes = if kept.is_empty() { 0 } else { 2 };
114        let remaining = max_bytes.saturating_sub(used_bytes + separator_bytes);
115        if remaining == 0 {
116            break;
117        }
118
119        if file.content.len() > remaining {
120            file.content = truncate_utf8_to_bytes(&file.content, remaining);
121            kept.push(file);
122            break;
123        }
124
125        used_bytes += separator_bytes + file.content.len();
126        kept.push(file);
127    }
128
129    kept
130}
131
132fn select_non_empty_file(dir: Option<&Path>, filenames: &[&str]) -> Result<Option<AgentsMdFile>> {
133    let Some(dir) = dir else {
134        return Ok(None);
135    };
136
137    for name in filenames {
138        let path = dir.join(name);
139        if !path.is_file() {
140            continue;
141        }
142        if let Some(file) = read_non_empty_file(&path)? {
143            return Ok(Some(file));
144        }
145    }
146
147    Ok(None)
148}
149
150fn read_non_empty_file(path: &Path) -> Result<Option<AgentsMdFile>> {
151    let content = fs::read_to_string(path)
152        .with_context(|| format!("failed to read AGENTS.md file '{}'", path.display()))?;
153    let trimmed = content.trim();
154    if trimmed.is_empty() {
155        return Ok(None);
156    }
157
158    Ok(Some(AgentsMdFile {
159        path: path.to_path_buf(),
160        content: trimmed.to_string(),
161    }))
162}
163
164fn find_project_root(workspace_dir: &Path) -> Option<PathBuf> {
165    let mut current = Some(workspace_dir);
166    while let Some(dir) = current {
167        let git_marker = dir.join(".git");
168        if git_marker.is_dir() || git_marker.is_file() {
169            return Some(dir.to_path_buf());
170        }
171        current = dir.parent();
172    }
173    None
174}
175
176fn dirs_from_root_to_scope(root: &Path, scope: &Path) -> Vec<PathBuf> {
177    if !scope.starts_with(root) {
178        return vec![scope.to_path_buf()];
179    }
180
181    let mut dirs = vec![root.to_path_buf()];
182    let mut current = root.to_path_buf();
183    if let Ok(relative) = scope.strip_prefix(root) {
184        for component in relative.components() {
185            current.push(component.as_os_str());
186            dirs.push(current.clone());
187        }
188    }
189    dirs
190}
191
192fn truncate_utf8_to_bytes(content: &str, max_bytes: usize) -> String {
193    if content.len() <= max_bytes {
194        return content.to_string();
195    }
196
197    let mut end = max_bytes.min(content.len());
198    while end > 0 && !content.is_char_boundary(end) {
199        end -= 1;
200    }
201    content[..end].to_string()
202}
203
204fn load_settings() -> AgentsMdSettings {
205    let mut settings = AgentsMdSettings {
206        fallback_filenames: Vec::new(),
207        max_bytes: AGENTS_MD_MAX_BYTES,
208    };
209
210    let Some(path) = sac_config_path() else {
211        return settings;
212    };
213    let Ok(raw) = fs::read_to_string(&path) else {
214        return settings;
215    };
216    let Ok(config) = toml::from_str::<AgentsMdConfigFile>(&raw) else {
217        return settings;
218    };
219
220    settings.fallback_filenames = config
221        .agents_md
222        .fallback_filenames
223        .or(config.project_doc_fallback_filenames)
224        .unwrap_or_default();
225    settings.max_bytes = config
226        .agents_md
227        .max_bytes
228        .or(config.project_doc_max_bytes)
229        .unwrap_or(AGENTS_MD_MAX_BYTES)
230        .max(1);
231    settings
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::test_env_lock;
238    use std::env;
239    use std::time::{SystemTime, UNIX_EPOCH};
240
241    fn temp_dir(label: &str) -> PathBuf {
242        let unique = SystemTime::now()
243            .duration_since(UNIX_EPOCH)
244            .unwrap()
245            .as_nanos();
246        let dir = std::env::temp_dir().join(format!("sac_agents_md_{label}_{unique}"));
247        fs::create_dir_all(&dir).unwrap();
248        dir
249    }
250
251    #[test]
252    fn loads_global_and_project_files_in_order() {
253        let _guard = test_env_lock();
254        let root = temp_dir("hierarchy");
255        let sac_home = root.join("sac-home");
256        let project_root = root.join("repo");
257        let nested = project_root.join("src").join("deep");
258
259        fs::create_dir_all(&sac_home).unwrap();
260        fs::create_dir_all(project_root.join(".git")).unwrap();
261        fs::create_dir_all(&nested).unwrap();
262        fs::write(sac_home.join("AGENTS.md"), "global").unwrap();
263        fs::write(project_root.join("AGENTS.md"), "root").unwrap();
264        fs::write(
265            project_root.join("src").join("AGENTS.override.md"),
266            "src override",
267        )
268        .unwrap();
269        fs::write(nested.join("AGENTS.md"), "deep").unwrap();
270
271        let original_sac_home = env::var_os("SAC_HOME");
272        unsafe {
273            env::set_var("SAC_HOME", &sac_home);
274        }
275
276        let bundle = AgentsMdBundle::load(Some(&nested)).unwrap();
277        let contents: Vec<&str> = bundle
278            .files()
279            .iter()
280            .map(|file| file.content.as_str())
281            .collect();
282        assert_eq!(contents, vec!["global", "root", "src override", "deep"]);
283
284        match original_sac_home {
285            Some(value) => unsafe { env::set_var("SAC_HOME", value) },
286            None => unsafe { env::remove_var("SAC_HOME") },
287        }
288    }
289
290    #[test]
291    fn git_file_marks_project_root() {
292        let root = temp_dir("git_file_root");
293        let project_root = root.join("repo");
294        let nested = project_root.join("nested");
295        fs::create_dir_all(&nested).unwrap();
296        fs::write(project_root.join(".git"), "gitdir: /tmp/fake").unwrap();
297
298        assert_eq!(find_project_root(&nested).unwrap(), project_root);
299    }
300
301    #[test]
302    #[ignore]
303    fn non_git_scope_walks_parent_to_child_without_git_root() {
304        let _guard = test_env_lock();
305        let root = temp_dir("non_git");
306        let parent = root.join("parent");
307        let child = parent.join("child");
308        fs::create_dir_all(&child).unwrap();
309        fs::write(parent.join("AGENTS.md"), "parent").unwrap();
310        fs::write(child.join("AGENTS.md"), "child").unwrap();
311
312        let bundle = AgentsMdBundle::load(Some(&child)).unwrap();
313        let contents: Vec<&str> = bundle
314            .files()
315            .iter()
316            .map(|file| file.content.as_str())
317            .collect();
318        assert_eq!(contents, vec!["parent", "child"]);
319    }
320
321    #[test]
322    fn truncation_preserves_order_until_limit() {
323        let files = vec![
324            AgentsMdFile {
325                path: PathBuf::from("/repo/AGENTS.md"),
326                content: "broad broad broad broad".to_string(),
327            },
328            AgentsMdFile {
329                path: PathBuf::from("/repo/src/AGENTS.md"),
330                content: "specific specific specific".to_string(),
331            },
332        ];
333
334        let trimmed = truncate_files_to_limit(files, 30);
335        assert_eq!(trimmed.len(), 2);
336        assert_eq!(trimmed[0].content, "broad broad broad broad");
337        assert!(trimmed[1].content.len() < "specific specific specific".len());
338    }
339
340    #[test]
341    fn empty_override_falls_back_to_agents_md() {
342        let root = temp_dir("empty_override");
343        fs::write(root.join("AGENTS.override.md"), "\n\n").unwrap();
344        fs::write(root.join("AGENTS.md"), "fallback").unwrap();
345
346        let selected =
347            select_non_empty_file(Some(&root), &["AGENTS.override.md", "AGENTS.md"]).unwrap();
348        assert_eq!(selected.unwrap().content, "fallback");
349    }
350
351    #[test]
352    fn project_fallback_filenames_are_respected() {
353        let _guard = test_env_lock();
354        let root = temp_dir("fallback_names");
355        let sac_home = root.join("sac-home");
356        let project = root.join("repo");
357        fs::create_dir_all(&sac_home).unwrap();
358        fs::create_dir_all(project.join(".git")).unwrap();
359        fs::write(
360            sac_home.join("config.toml"),
361            "[agents_md]\nfallback_filenames = [\"TEAM_GUIDE.md\"]\n",
362        )
363        .unwrap();
364        fs::write(project.join("TEAM_GUIDE.md"), "team guide").unwrap();
365
366        let original_sac_home = env::var_os("SAC_HOME");
367        unsafe {
368            env::set_var("SAC_HOME", &sac_home);
369        }
370
371        let bundle = AgentsMdBundle::load(Some(&project)).unwrap();
372        assert_eq!(bundle.files().len(), 1);
373        assert_eq!(bundle.files()[0].content, "team guide");
374
375        match original_sac_home {
376            Some(value) => unsafe { env::set_var("SAC_HOME", value) },
377            None => unsafe { env::remove_var("SAC_HOME") },
378        }
379    }
380}