1use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18
19#[cfg(test)]
20use locode_host::resolve_home_from;
21use locode_host::{find_root_from_markers, locode_home};
22
23const AGENTS_FILE: &str = "AGENTS.md";
25const OVERRIDE_FILE: &str = "AGENTS.override.md";
29
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
33pub struct ProjectInstructions {
34 pub entries: Vec<InstructionEntry>,
36}
37
38impl ProjectInstructions {
39 #[must_use]
41 pub fn is_empty(&self) -> bool {
42 self.entries.is_empty()
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct InstructionEntry {
49 pub source_path: PathBuf,
52 pub content: String,
54}
55
56#[derive(Debug, Clone)]
59pub struct InstructionsConfig {
60 pub enabled: bool,
62 pub byte_budget: usize,
65 pub root_markers: Vec<String>,
67 pub root_stop_pattern: Option<String>,
74 pub extra_roots: Vec<PathBuf>,
77 pub global_file: bool,
79 pub extends_dirs: Vec<PathBuf>,
88}
89
90impl Default for InstructionsConfig {
91 fn default() -> Self {
92 Self {
93 enabled: true,
94 byte_budget: 64 * 1024,
95 root_markers: vec![".git".to_string()],
96 root_stop_pattern: None,
97 extra_roots: Vec::new(),
98 global_file: true,
99 extends_dirs: Vec::new(),
100 }
101 }
102}
103
104#[must_use]
115pub fn load_project_instructions(cwd: &Path, cfg: &InstructionsConfig) -> ProjectInstructions {
116 let global = (cfg.enabled && cfg.global_file)
119 .then(global_instruction_path)
120 .flatten();
121 load_impl(cwd, cfg, global.as_deref())
122}
123
124fn load_impl(cwd: &Path, cfg: &InstructionsConfig, global: Option<&Path>) -> ProjectInstructions {
127 if !cfg.enabled {
128 return ProjectInstructions::default();
129 }
130
131 let mut entries: Vec<InstructionEntry> = Vec::new();
132 let mut seen: HashSet<String> = HashSet::new();
133
134 for dir in &cfg.extends_dirs {
137 push_dir_entry(dir, &mut entries, &mut seen, None);
138 }
139
140 if let Some(global) = global {
143 push_dir_entry(&global_dir_of(global), &mut entries, &mut seen, None);
144 }
145
146 let stop_pattern = cfg
150 .root_stop_pattern
151 .as_deref()
152 .and_then(|p| regex::Regex::new(p).ok());
153 let root = find_root_from_markers(cwd, &cfg.root_markers, stop_pattern.as_ref());
154 let ignore = build_gitignore(&root);
155 for dir in dirs_root_to_leaf(cwd, &root) {
156 push_dir_entry(&dir, &mut entries, &mut seen, ignore.as_ref());
157 }
158
159 for extra in &cfg.extra_roots {
161 let extra_root = find_root_from_markers(extra, &cfg.root_markers, stop_pattern.as_ref());
162 for dir in dirs_root_to_leaf(extra, &extra_root) {
163 push_dir_entry(&dir, &mut entries, &mut seen, None);
164 }
165 }
166
167 ProjectInstructions { entries }
168}
169
170fn global_dir_of(global_file: &Path) -> PathBuf {
173 global_file
174 .parent()
175 .map_or_else(|| global_file.to_path_buf(), Path::to_path_buf)
176}
177
178fn global_instruction_path() -> Option<PathBuf> {
183 locode_home().ok().map(|dir| dir.join(AGENTS_FILE))
184}
185
186#[cfg(test)]
189fn global_path_from(
190 locode_home: Option<std::ffi::OsString>,
191 home: Option<std::ffi::OsString>,
192) -> Option<PathBuf> {
193 resolve_home_from(locode_home, home)
194 .ok()
195 .map(|dir| dir.join(AGENTS_FILE))
196}
197
198fn dirs_root_to_leaf(leaf: &Path, root: &Path) -> Vec<PathBuf> {
201 let mut dirs = Vec::new();
202 let mut cur = Some(leaf);
203 while let Some(d) = cur {
204 dirs.push(d.to_path_buf());
205 if d == root {
206 break;
207 }
208 cur = d.parent();
209 }
210 dirs.reverse();
211 dirs
212}
213
214fn push_dir_entry(
218 dir: &Path,
219 entries: &mut Vec<InstructionEntry>,
220 seen: &mut HashSet<String>,
221 ignore: Option<&ignore::gitignore::Gitignore>,
222) {
223 for name in [OVERRIDE_FILE, AGENTS_FILE] {
224 let path = dir.join(name);
225 if !path.is_file() {
226 continue;
227 }
228 if let Some(gi) = ignore
231 && is_gitignored(gi, &path)
232 {
233 return;
234 }
235 let content = std::fs::read_to_string(&path).unwrap_or_default();
236 if content.trim().is_empty() {
237 return;
238 }
239 let key = canonical_key(&path);
240 if seen.insert(key) {
241 entries.push(InstructionEntry {
242 source_path: path,
243 content,
244 });
245 }
246 return;
247 }
248}
249
250fn build_gitignore(root: &Path) -> Option<ignore::gitignore::Gitignore> {
253 if !root.join(".git").exists() {
254 return None;
255 }
256 let base = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
257 let mut builder = ignore::gitignore::GitignoreBuilder::new(&base);
258 let _ = builder.add(base.join(".gitignore"));
259 let _ = builder.add(base.join(".git").join("info").join("exclude"));
260 builder.build().ok()
261}
262
263fn is_gitignored(gi: &ignore::gitignore::Gitignore, path: &Path) -> bool {
266 let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
267 gi.matched_path_or_any_parents(&canon, false)
268 .is_ignore()
269}
270
271fn canonical_key(path: &Path) -> String {
273 std::fs::canonicalize(path)
274 .unwrap_or_else(|_| path.to_path_buf())
275 .to_string_lossy()
276 .to_lowercase()
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use std::fs;
283 use tempfile::TempDir;
284
285 fn tmp() -> (TempDir, PathBuf) {
288 let dir = tempfile::tempdir().unwrap();
289 let root = fs::canonicalize(dir.path()).unwrap();
290 (dir, root)
291 }
292
293 fn write(path: &Path, body: &str) {
294 if let Some(parent) = path.parent() {
295 fs::create_dir_all(parent).unwrap();
296 }
297 fs::write(path, body).unwrap();
298 }
299
300 fn cfg_no_global() -> InstructionsConfig {
303 InstructionsConfig {
304 global_file: false,
305 ..Default::default()
306 }
307 }
308
309 #[test]
310 fn walks_root_to_cwd_deepest_last() {
311 let (_g, root) = tmp();
312 fs::create_dir(root.join(".git")).unwrap();
313 write(&root.join(AGENTS_FILE), "root rules");
314 let sub = root.join("a/b");
315 write(&sub.join(AGENTS_FILE), "leaf rules");
316
317 let got = load_project_instructions(&sub, &cfg_no_global());
318 let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
319 assert_eq!(
320 paths,
321 vec![root.join(AGENTS_FILE), sub.join(AGENTS_FILE)],
322 "root→cwd order, deepest last"
323 );
324 assert_eq!(got.entries[0].content, "root rules");
325 assert_eq!(got.entries[1].content, "leaf rules");
326 }
327
328 #[test]
329 fn no_git_falls_back_to_cwd_only() {
330 let (_g, root) = tmp();
331 write(&root.join(AGENTS_FILE), "parent rules");
333 let sub = root.join("child");
334 write(&sub.join(AGENTS_FILE), "cwd rules");
335
336 let got = load_project_instructions(&sub, &cfg_no_global());
337 assert_eq!(got.entries.len(), 1, "cwd-only: parent not walked");
338 assert_eq!(got.entries[0].source_path, sub.join(AGENTS_FILE));
339 }
340
341 #[test]
342 fn override_replaces_same_dir_agents_md_only() {
343 let (_g, root) = tmp();
344 fs::create_dir(root.join(".git")).unwrap();
345 write(&root.join(AGENTS_FILE), "root plain");
346 let sub = root.join("s");
347 write(&sub.join(AGENTS_FILE), "sub plain");
348 write(&sub.join(OVERRIDE_FILE), "sub override");
349
350 let got = load_project_instructions(&sub, &cfg_no_global());
351 assert_eq!(got.entries.len(), 2);
353 assert_eq!(got.entries[1].source_path, sub.join(OVERRIDE_FILE));
354 assert_eq!(got.entries[1].content, "sub override");
355 assert!(
356 got.entries
357 .iter()
358 .all(|e| e.source_path != sub.join(AGENTS_FILE)),
359 "sub's plain AGENTS.md is suppressed"
360 );
361 }
362
363 #[test]
364 fn empty_override_suppresses_agents_md_and_yields_nothing() {
365 let (_g, root) = tmp();
366 write(&root.join(AGENTS_FILE), "would-be content");
367 write(&root.join(OVERRIDE_FILE), " \n ");
368
369 let got = load_project_instructions(&root, &cfg_no_global());
370 assert!(
371 got.is_empty(),
372 "empty override wins the dir, yields no entry"
373 );
374 }
375
376 #[test]
377 fn empty_and_whitespace_files_dropped() {
378 let (_g, root) = tmp();
379 write(&root.join(AGENTS_FILE), "\n\t \n");
380 let got = load_project_instructions(&root, &cfg_no_global());
381 assert!(got.is_empty());
382 }
383
384 #[test]
385 fn gitignored_agents_md_skipped() {
386 let (_g, root) = tmp();
387 fs::create_dir(root.join(".git")).unwrap();
388 write(&root.join(".gitignore"), "AGENTS.md\n");
389 write(&root.join(AGENTS_FILE), "secret");
390 let got = load_project_instructions(&root, &cfg_no_global());
391 assert!(got.is_empty(), "gitignored AGENTS.md is filtered");
392 }
393
394 #[test]
395 fn dedup_by_canonical_path() {
396 let (_g, root) = tmp();
397 fs::create_dir(root.join(".git")).unwrap();
398 write(&root.join(AGENTS_FILE), "rules");
399 let got = load_project_instructions(&root, &cfg_no_global());
402 assert_eq!(got.entries.len(), 1);
403 assert_eq!(
405 canonical_key(&root.join(AGENTS_FILE)),
406 canonical_key(&root.join(AGENTS_FILE))
407 );
408 }
409
410 #[test]
411 fn extra_roots_appended_after_primary() {
412 let (_g, root) = tmp();
413 fs::create_dir(root.join(".git")).unwrap();
414 write(&root.join(AGENTS_FILE), "primary");
415 let (_g2, other) = tmp();
416 write(&other.join(AGENTS_FILE), "extra");
417
418 let cfg = InstructionsConfig {
419 extra_roots: vec![other.clone()],
420 ..cfg_no_global()
421 };
422 let got = load_project_instructions(&root, &cfg);
423 let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
424 assert_eq!(paths, vec![root.join(AGENTS_FILE), other.join(AGENTS_FILE)]);
425 }
426
427 #[test]
428 fn disabled_returns_empty() {
429 let (_g, root) = tmp();
430 write(&root.join(AGENTS_FILE), "rules");
431 let cfg = InstructionsConfig {
432 enabled: false,
433 ..cfg_no_global()
434 };
435 assert!(load_project_instructions(&root, &cfg).is_empty());
436 }
437
438 #[test]
439 fn root_stop_pattern_stops_the_ascent() {
440 let (_g, root) = tmp();
443 fs::create_dir(root.join(".git")).unwrap();
444 write(&root.join(AGENTS_FILE), "top rules");
445 let x = root.join("x");
446 write(&x.join(AGENTS_FILE), "x rules");
447 let sub = x.join("y");
448 fs::create_dir_all(&sub).unwrap();
449
450 let got = load_project_instructions(&sub, &cfg_no_global());
452 assert_eq!(got.entries.len(), 2);
453
454 let mut cfg = cfg_no_global();
456 cfg.root_stop_pattern = Some("/x$".to_string());
457 let got = load_project_instructions(&sub, &cfg);
458 assert_eq!(got.entries.len(), 1);
459 assert_eq!(got.entries[0].source_path, x.join(AGENTS_FILE));
460 assert_eq!(got.entries[0].content, "x rules");
461 }
462
463 #[test]
464 fn invalid_root_stop_pattern_degrades_to_no_pattern() {
465 let (_g, root) = tmp();
466 fs::create_dir(root.join(".git")).unwrap();
467 write(&root.join(AGENTS_FILE), "rules");
468 let mut cfg = cfg_no_global();
469 cfg.root_stop_pattern = Some("[invalid".to_string());
470 let got = load_project_instructions(&root, &cfg);
471 assert_eq!(got.entries.len(), 1, "marker detection still works");
472 }
473
474 #[test]
475 fn global_file_is_lowest_precedence() {
476 let (_home_g, home) = tmp();
479 let global = home.join(".locode").join(AGENTS_FILE);
480 write(&global, "global");
481 let (_g, root) = tmp();
482 fs::create_dir(root.join(".git")).unwrap();
483 write(&root.join(AGENTS_FILE), "project");
484
485 let cfg = InstructionsConfig {
486 global_file: true,
487 ..Default::default()
488 };
489 let got = load_impl(&root, &cfg, Some(&global));
490 let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
491 assert_eq!(
492 paths,
493 vec![global.clone(), root.join(AGENTS_FILE)],
494 "global first (lowest precedence), project after"
495 );
496 }
497
498 #[test]
502 fn extends_dotfolders_rank_below_the_global_file() {
503 let (_home_g, home) = tmp();
504 let global = home.join(".locode").join(AGENTS_FILE);
505 write(&global, "global");
506 let (_team_g, team) = tmp();
507 write(&team.join(AGENTS_FILE), "team");
508 let (_team2_g, team2) = tmp();
509 write(&team2.join(AGENTS_FILE), "team2");
510 let (_g, root) = tmp();
511 fs::create_dir(root.join(".git")).unwrap();
512 write(&root.join(AGENTS_FILE), "project");
513
514 let cfg = InstructionsConfig {
515 global_file: true,
516 extends_dirs: vec![team.clone(), team2.clone()],
517 ..Default::default()
518 };
519 let got = load_impl(&root, &cfg, Some(&global));
520 let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
521 assert_eq!(
522 paths,
523 vec![
524 team.join(AGENTS_FILE),
525 team2.join(AGENTS_FILE),
526 global.clone(),
527 root.join(AGENTS_FILE),
528 ],
529 "extends (list order) → global → repo chain"
530 );
531 }
532
533 #[test]
535 fn extends_dotfolder_without_agents_md_contributes_nothing() {
536 let (_team_g, team) = tmp();
537 let (_g, root) = tmp();
538 fs::create_dir(root.join(".git")).unwrap();
539 write(&root.join(AGENTS_FILE), "project");
540
541 let cfg = InstructionsConfig {
542 extends_dirs: vec![team],
543 ..cfg_no_global()
544 };
545 let got = load_impl(&root, &cfg, None);
546 let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
547 assert_eq!(paths, vec![root.join(AGENTS_FILE)]);
548 }
549
550 #[test]
551 fn global_file_disabled_passes_none() {
552 let (_g, root) = tmp();
555 write(&root.join(AGENTS_FILE), "project");
556 let got = load_project_instructions(&root, &cfg_no_global());
557 assert_eq!(got.entries.len(), 1);
558 assert_eq!(got.entries[0].source_path, root.join(AGENTS_FILE));
559 }
560
561 #[test]
562 fn global_instruction_path_shape() {
563 if let Some(p) = global_instruction_path() {
565 assert!(p.ends_with("AGENTS.md"));
566 }
567 }
568
569 #[test]
570 fn global_path_prefers_locode_home_over_home() {
571 use std::ffi::OsString;
572 let dir = tempfile::tempdir().unwrap();
575 let canon = fs::canonicalize(dir.path()).unwrap();
576 let p = global_path_from(Some(dir.path().as_os_str().to_owned()), None).unwrap();
577 assert_eq!(p, canon.join(AGENTS_FILE));
578 assert!(global_path_from(Some(OsString::from("/definitely/not/here")), None).is_none());
580 let p = global_path_from(Some(OsString::new()), Some(OsString::from("/home/u"))).unwrap();
583 assert_eq!(p, PathBuf::from("/home/u/.locode").join(AGENTS_FILE));
584 assert!(global_path_from(None, None).is_none());
586 assert!(global_path_from(None, Some(OsString::new())).is_none());
587 }
588}