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}