1use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use crate::constants::{INSTRUCTIONS_TRUNCATION_MARKER, MAX_INSTRUCTIONS_BYTES};
19
20pub const INSTRUCTION_FILENAMES: &[&str] = &["AGENTS.md", "MERMAID.md"];
25
26const MAX_WALK_DEPTH: usize = 32;
29
30#[derive(Debug, Clone)]
33pub struct InstructionSource {
34 pub path: PathBuf,
35 pub mtime: SystemTime,
36 pub byte_len: usize,
37}
38
39#[derive(Debug, Clone)]
43pub struct LoadedInstructions {
44 pub path: PathBuf,
48 pub content: String,
51 pub mtime: SystemTime,
54 pub byte_len: usize,
56 pub truncated: bool,
59 pub sources: Vec<InstructionSource>,
61}
62
63impl LoadedInstructions {
64 pub fn approx_tokens(&self) -> usize {
67 self.content.len() / 4
68 }
69}
70
71#[derive(Debug, PartialEq, Eq)]
74pub enum ReloadOutcome {
75 Unchanged,
77 LoadedFirst { tokens: usize },
80 Reloaded {
82 old_tokens: usize,
83 new_tokens: usize,
84 },
85 Removed,
87}
88
89pub fn find_instruction_files(start: &Path) -> Vec<PathBuf> {
99 let home = std::env::var_os("HOME").map(PathBuf::from);
100 find_instruction_files_bounded(start, home.as_deref())
101}
102
103fn find_instruction_files_bounded(start: &Path, home: Option<&Path>) -> Vec<PathBuf> {
107 let mut current = start.to_path_buf();
108 for _ in 0..MAX_WALK_DEPTH {
109 if home == Some(current.as_path()) {
114 return Vec::new();
115 }
116 let found: Vec<PathBuf> = INSTRUCTION_FILENAMES
117 .iter()
118 .map(|name| current.join(name))
119 .filter(|candidate| candidate.is_file())
120 .collect();
121 if !found.is_empty() {
122 return found;
123 }
124 if current.join(".git").exists() {
128 return Vec::new();
129 }
130 match current.parent() {
132 Some(parent) if parent != current => current = parent.to_path_buf(),
133 _ => return Vec::new(),
134 }
135 }
136 Vec::new()
137}
138
139pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
143 load_from_paths(&[path.to_path_buf()])
144}
145
146pub fn load_from_paths(paths: &[PathBuf]) -> Option<LoadedInstructions> {
149 let mut sources = Vec::new();
150 let mut bodies = Vec::new();
151 let mut total_byte_len = 0usize;
152 let mut latest_mtime = UNIX_EPOCH;
153
154 for path in paths {
155 let metadata = std::fs::metadata(path).ok()?;
156 let mtime = metadata.modified().ok()?;
157 let true_len = metadata.len() as usize;
158 let (bytes, _truncated) =
164 crate::utils::read_file_capped(path, MAX_INSTRUCTIONS_BYTES.saturating_add(1)).ok()?;
165 let raw = String::from_utf8_lossy(&bytes).into_owned();
166 total_byte_len = total_byte_len.saturating_add(true_len);
167 if mtime > latest_mtime {
168 latest_mtime = mtime;
169 }
170 sources.push(InstructionSource {
171 path: path.to_path_buf(),
172 mtime,
173 byte_len: true_len,
174 });
175 bodies.push((path.to_path_buf(), raw));
176 }
177 let primary = sources.first()?.path.clone();
178 let raw = combine_instruction_bodies(bodies);
179 let byte_len = total_byte_len;
180 let (content, truncated) = if raw.len() > MAX_INSTRUCTIONS_BYTES {
181 let cut = raw.floor_char_boundary(MAX_INSTRUCTIONS_BYTES);
184 let mut clipped = raw[..cut].to_string();
185 clipped.push_str(INSTRUCTIONS_TRUNCATION_MARKER);
186 (clipped, true)
187 } else {
188 (raw, false)
189 };
190 Some(LoadedInstructions {
191 path: primary,
192 content,
193 mtime: latest_mtime,
194 byte_len,
195 truncated,
196 sources,
197 })
198}
199
200pub fn refresh(
207 current: Option<LoadedInstructions>,
208 cwd: &Path,
209) -> (Option<LoadedInstructions>, ReloadOutcome) {
210 match current {
211 Some(prior) => {
212 let paths: Vec<PathBuf> = if prior.sources.is_empty() {
214 vec![prior.path.clone()]
215 } else {
216 prior
217 .sources
218 .iter()
219 .map(|source| source.path.clone())
220 .collect()
221 };
222 let changed = if prior.sources.is_empty() {
223 std::fs::metadata(&prior.path)
224 .and_then(|m| m.modified())
225 .map(|mtime| mtime != prior.mtime)
226 .unwrap_or(true)
227 } else {
228 prior.sources.iter().any(|source| {
229 std::fs::metadata(&source.path)
230 .and_then(|m| m.modified())
231 .map(|mtime| mtime != source.mtime)
232 .unwrap_or(true)
233 })
234 };
235 if !changed {
236 return (Some(prior), ReloadOutcome::Unchanged);
237 }
238 let old_tokens = prior.approx_tokens();
239 match load_from_paths(&paths) {
240 Some(reloaded) => {
241 let new_tokens = reloaded.approx_tokens();
242 (
243 Some(reloaded),
244 ReloadOutcome::Reloaded {
245 old_tokens,
246 new_tokens,
247 },
248 )
249 },
250 None => {
251 (None, ReloadOutcome::Removed)
254 },
255 }
256 },
257 None => {
258 match load_from_paths(&find_instruction_files(cwd)) {
261 Some(loaded) => {
262 let tokens = loaded.approx_tokens();
263 (Some(loaded), ReloadOutcome::LoadedFirst { tokens })
264 },
265 None => (None, ReloadOutcome::Unchanged),
266 }
267 },
268 }
269}
270
271fn combine_instruction_bodies(bodies: Vec<(PathBuf, String)>) -> String {
272 bodies
277 .into_iter()
278 .map(|(path, body)| {
279 let name = path
280 .file_name()
281 .and_then(|name| name.to_str())
282 .unwrap_or("instructions");
283 format!("# Project Instructions: {}\n\n{}", name, body)
284 })
285 .collect::<Vec<_>>()
286 .join("\n\n---\n\n")
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::fs;
293 use std::sync::Mutex;
294
295 static FS_LOCK: Mutex<()> = Mutex::new(());
298
299 fn temp_dir(name: &str) -> PathBuf {
300 let p = std::env::temp_dir().join(format!("mermaid_instructions_test_{}", name));
301 let _ = fs::remove_dir_all(&p);
302 fs::create_dir_all(&p).expect("create temp dir");
303 p
304 }
305
306 #[test]
307 fn find_instruction_files_finds_in_cwd() {
308 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
309 let dir = temp_dir("cwd");
310 fs::write(dir.join("MERMAID.md"), "rules").unwrap();
311 let found = find_instruction_files(&dir);
312 assert_eq!(found, vec![dir.join("MERMAID.md")]);
313 let _ = fs::remove_dir_all(&dir);
314 }
315
316 #[test]
317 fn find_instruction_files_loads_both_in_precedence_order() {
318 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
319 let dir = temp_dir("both");
320 fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
321 fs::write(dir.join("MERMAID.md"), "mermaid rules").unwrap();
322 let found = find_instruction_files(&dir);
323 assert_eq!(found, vec![dir.join("AGENTS.md"), dir.join("MERMAID.md")]);
325 let loaded = load_from_paths(&found).expect("load combined");
326 assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
327 assert!(loaded.content.contains("agent rules"));
328 assert!(
329 loaded
330 .content
331 .contains("# Project Instructions: MERMAID.md")
332 );
333 assert!(loaded.content.contains("mermaid rules"));
334 assert!(
336 loaded.content.find("mermaid rules") > loaded.content.find("agent rules"),
337 "MERMAID.md must come last so its guidance overrides AGENTS.md"
338 );
339 assert_eq!(loaded.sources.len(), 2);
340 let _ = fs::remove_dir_all(&dir);
341 }
342
343 #[test]
344 fn find_instruction_files_walks_up_to_git_root() {
345 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
346 let root = temp_dir("walkup");
347 fs::create_dir(root.join(".git")).unwrap();
348 fs::write(root.join("MERMAID.md"), "root rules").unwrap();
349 let sub = root.join("subdir/deeper");
350 fs::create_dir_all(&sub).unwrap();
351 let found = find_instruction_files(&sub);
352 assert_eq!(found, vec![root.join("MERMAID.md")]);
353 let _ = fs::remove_dir_all(&root);
354 }
355
356 #[test]
357 fn find_instruction_files_stops_at_git_root_without_file() {
358 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
359 let root = temp_dir("git_no_md");
360 fs::create_dir(root.join(".git")).unwrap();
361 let parent = root.parent().unwrap();
364 let above_md = parent.join("MERMAID.md");
365 fs::write(&above_md, "outside").unwrap();
366 let sub = root.join("subdir");
367 fs::create_dir_all(&sub).unwrap();
368 let found = find_instruction_files(&sub);
369 assert!(found.is_empty(), "walk must stop at .git boundary");
370 let _ = fs::remove_dir_all(&root);
371 let _ = fs::remove_file(&above_md);
372 }
373
374 #[test]
375 fn find_instruction_files_returns_empty_if_absent() {
376 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
377 let dir = temp_dir("absent");
378 fs::create_dir(dir.join(".git")).unwrap();
381 let found = find_instruction_files(&dir);
382 assert!(found.is_empty());
383 let _ = fs::remove_dir_all(&dir);
384 }
385
386 #[test]
387 fn find_instruction_files_stops_at_home_boundary() {
388 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
393 let home = temp_dir("home_boundary");
394 fs::write(home.join("AGENTS.md"), "home rules").unwrap();
395 let child = home.join("project");
396 fs::create_dir_all(&child).unwrap();
397 let found = find_instruction_files_bounded(&child, Some(home.as_path()));
400 assert!(
401 found.is_empty(),
402 "walk must stop at $HOME and not load ~/AGENTS.md, got {found:?}"
403 );
404 let _ = fs::remove_dir_all(&home);
405 }
406
407 #[test]
408 fn single_file_instructions_get_labeled_header() {
409 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
413 let dir = temp_dir("single_header");
414 fs::write(dir.join("MERMAID.md"), "do the thing").unwrap();
415 let loaded = load_from_path(&dir.join("MERMAID.md")).expect("load");
416 assert!(
417 loaded
418 .content
419 .starts_with("# Project Instructions: MERMAID.md"),
420 "single-file instructions must carry a labeled header, got: {:?}",
421 loaded.content
422 );
423 assert!(loaded.content.contains("do the thing"));
424 let _ = fs::remove_dir_all(&dir);
425 }
426
427 #[test]
428 fn load_from_path_truncates_oversized_file() {
429 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
430 let dir = temp_dir("oversized");
431 let path = dir.join("MERMAID.md");
432 let big = "a".repeat(50_000);
434 fs::write(&path, &big).unwrap();
435 let loaded = load_from_path(&path).expect("load");
436 assert!(loaded.truncated);
437 assert_eq!(loaded.byte_len, 50_000); assert!(loaded.content.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
439 assert_eq!(
441 loaded.content.len(),
442 MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
443 );
444 let _ = fs::remove_dir_all(&dir);
445 }
446
447 #[test]
448 fn load_from_path_returns_none_when_missing() {
449 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
450 let dir = temp_dir("missing");
451 assert!(load_from_path(&dir.join("nope.md")).is_none());
452 let _ = fs::remove_dir_all(&dir);
453 }
454
455 #[test]
456 fn refresh_returns_unchanged_when_mtime_stable() {
457 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
458 let dir = temp_dir("stable");
459 let path = dir.join("MERMAID.md");
460 fs::write(&path, "v1").unwrap();
461 let prior = load_from_path(&path).unwrap();
462 let (after, outcome) = refresh(Some(prior.clone()), &dir);
463 assert_eq!(outcome, ReloadOutcome::Unchanged);
464 assert!(after.is_some());
465 let _ = fs::remove_dir_all(&dir);
466 }
467
468 #[test]
469 fn refresh_returns_reloaded_on_content_change() {
470 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
471 let dir = temp_dir("changed");
472 let path = dir.join("MERMAID.md");
473 fs::write(&path, "v1").unwrap();
474 let prior = load_from_path(&path).unwrap();
475 std::thread::sleep(std::time::Duration::from_millis(1100));
478 fs::write(&path, "v2 longer content here").unwrap();
479 let (after, outcome) = refresh(Some(prior), &dir);
480 assert!(matches!(outcome, ReloadOutcome::Reloaded { .. }));
481 let content = after.unwrap().content;
482 assert!(content.contains("# Project Instructions: MERMAID.md"));
483 assert!(content.contains("v2 longer content here"));
484 let _ = fs::remove_dir_all(&dir);
485 }
486
487 #[test]
488 fn refresh_returns_removed_when_file_deleted() {
489 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
490 let dir = temp_dir("removed");
491 let path = dir.join("MERMAID.md");
492 fs::write(&path, "v1").unwrap();
493 let prior = load_from_path(&path).unwrap();
494 fs::remove_file(&path).unwrap();
495 let (after, outcome) = refresh(Some(prior), &dir);
496 assert_eq!(outcome, ReloadOutcome::Removed);
497 assert!(after.is_none());
498 let _ = fs::remove_dir_all(&dir);
499 }
500
501 #[test]
502 fn refresh_returns_loaded_first_on_initial_discovery() {
503 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
504 let dir = temp_dir("first");
505 fs::create_dir(dir.join(".git")).unwrap();
507 fs::write(dir.join("MERMAID.md"), "fresh").unwrap();
509 let (after, outcome) = refresh(None, &dir);
510 assert!(matches!(outcome, ReloadOutcome::LoadedFirst { .. }));
511 let content = after.unwrap().content;
512 assert!(content.contains("# Project Instructions: MERMAID.md"));
513 assert!(content.contains("fresh"));
514 let _ = fs::remove_dir_all(&dir);
515 }
516}