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 let mut current = start.to_path_buf();
101 for _ in 0..MAX_WALK_DEPTH {
102 let found: Vec<PathBuf> = INSTRUCTION_FILENAMES
103 .iter()
104 .map(|name| current.join(name))
105 .filter(|candidate| candidate.is_file())
106 .collect();
107 if !found.is_empty() {
108 return found;
109 }
110 if current.join(".git").exists() {
113 return Vec::new();
114 }
115 if let Some(ref h) = home
119 && current == *h
120 {
121 return Vec::new();
122 }
123 match current.parent() {
125 Some(parent) if parent != current => current = parent.to_path_buf(),
126 _ => return Vec::new(),
127 }
128 }
129 Vec::new()
130}
131
132pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
136 load_from_paths(&[path.to_path_buf()])
137}
138
139pub fn load_from_paths(paths: &[PathBuf]) -> Option<LoadedInstructions> {
142 let mut sources = Vec::new();
143 let mut bodies = Vec::new();
144 let mut total_byte_len = 0usize;
145 let mut latest_mtime = UNIX_EPOCH;
146
147 for path in paths {
148 let metadata = std::fs::metadata(path).ok()?;
149 let mtime = metadata.modified().ok()?;
150 let raw = std::fs::read_to_string(path).ok()?;
151 total_byte_len = total_byte_len.saturating_add(raw.len());
152 if mtime > latest_mtime {
153 latest_mtime = mtime;
154 }
155 sources.push(InstructionSource {
156 path: path.to_path_buf(),
157 mtime,
158 byte_len: raw.len(),
159 });
160 bodies.push((path.to_path_buf(), raw));
161 }
162 let primary = sources.first()?.path.clone();
163 let raw = combine_instruction_bodies(bodies);
164 let byte_len = total_byte_len;
165 let (content, truncated) = if raw.len() > MAX_INSTRUCTIONS_BYTES {
166 let cut = raw.floor_char_boundary(MAX_INSTRUCTIONS_BYTES);
169 let mut clipped = raw[..cut].to_string();
170 clipped.push_str(INSTRUCTIONS_TRUNCATION_MARKER);
171 (clipped, true)
172 } else {
173 (raw, false)
174 };
175 Some(LoadedInstructions {
176 path: primary,
177 content,
178 mtime: latest_mtime,
179 byte_len,
180 truncated,
181 sources,
182 })
183}
184
185pub fn refresh(
192 current: Option<LoadedInstructions>,
193 cwd: &Path,
194) -> (Option<LoadedInstructions>, ReloadOutcome) {
195 match current {
196 Some(prior) => {
197 let paths: Vec<PathBuf> = if prior.sources.is_empty() {
199 vec![prior.path.clone()]
200 } else {
201 prior
202 .sources
203 .iter()
204 .map(|source| source.path.clone())
205 .collect()
206 };
207 let changed = if prior.sources.is_empty() {
208 std::fs::metadata(&prior.path)
209 .and_then(|m| m.modified())
210 .map(|mtime| mtime != prior.mtime)
211 .unwrap_or(true)
212 } else {
213 prior.sources.iter().any(|source| {
214 std::fs::metadata(&source.path)
215 .and_then(|m| m.modified())
216 .map(|mtime| mtime != source.mtime)
217 .unwrap_or(true)
218 })
219 };
220 if !changed {
221 return (Some(prior), ReloadOutcome::Unchanged);
222 }
223 let old_tokens = prior.approx_tokens();
224 match load_from_paths(&paths) {
225 Some(reloaded) => {
226 let new_tokens = reloaded.approx_tokens();
227 (
228 Some(reloaded),
229 ReloadOutcome::Reloaded {
230 old_tokens,
231 new_tokens,
232 },
233 )
234 },
235 None => {
236 (None, ReloadOutcome::Removed)
239 },
240 }
241 },
242 None => {
243 match load_from_paths(&find_instruction_files(cwd)) {
246 Some(loaded) => {
247 let tokens = loaded.approx_tokens();
248 (Some(loaded), ReloadOutcome::LoadedFirst { tokens })
249 },
250 None => (None, ReloadOutcome::Unchanged),
251 }
252 },
253 }
254}
255
256fn combine_instruction_bodies(bodies: Vec<(PathBuf, String)>) -> String {
257 if bodies.len() == 1 {
258 return bodies
259 .into_iter()
260 .next()
261 .map(|(_, body)| body)
262 .unwrap_or_default();
263 }
264 bodies
265 .into_iter()
266 .map(|(path, body)| {
267 let name = path
268 .file_name()
269 .and_then(|name| name.to_str())
270 .unwrap_or("instructions");
271 format!("# Project Instructions: {}\n\n{}", name, body)
272 })
273 .collect::<Vec<_>>()
274 .join("\n\n---\n\n")
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use std::fs;
281 use std::sync::Mutex;
282
283 static FS_LOCK: Mutex<()> = Mutex::new(());
286
287 fn temp_dir(name: &str) -> PathBuf {
288 let p = std::env::temp_dir().join(format!("mermaid_instructions_test_{}", name));
289 let _ = fs::remove_dir_all(&p);
290 fs::create_dir_all(&p).expect("create temp dir");
291 p
292 }
293
294 #[test]
295 fn find_instruction_files_finds_in_cwd() {
296 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
297 let dir = temp_dir("cwd");
298 fs::write(dir.join("MERMAID.md"), "rules").unwrap();
299 let found = find_instruction_files(&dir);
300 assert_eq!(found, vec![dir.join("MERMAID.md")]);
301 let _ = fs::remove_dir_all(&dir);
302 }
303
304 #[test]
305 fn find_instruction_files_loads_both_in_precedence_order() {
306 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
307 let dir = temp_dir("both");
308 fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
309 fs::write(dir.join("MERMAID.md"), "mermaid rules").unwrap();
310 let found = find_instruction_files(&dir);
311 assert_eq!(found, vec![dir.join("AGENTS.md"), dir.join("MERMAID.md")]);
313 let loaded = load_from_paths(&found).expect("load combined");
314 assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
315 assert!(loaded.content.contains("agent rules"));
316 assert!(
317 loaded
318 .content
319 .contains("# Project Instructions: MERMAID.md")
320 );
321 assert!(loaded.content.contains("mermaid rules"));
322 assert!(
324 loaded.content.find("mermaid rules") > loaded.content.find("agent rules"),
325 "MERMAID.md must come last so its guidance overrides AGENTS.md"
326 );
327 assert_eq!(loaded.sources.len(), 2);
328 let _ = fs::remove_dir_all(&dir);
329 }
330
331 #[test]
332 fn find_instruction_files_walks_up_to_git_root() {
333 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
334 let root = temp_dir("walkup");
335 fs::create_dir(root.join(".git")).unwrap();
336 fs::write(root.join("MERMAID.md"), "root rules").unwrap();
337 let sub = root.join("subdir/deeper");
338 fs::create_dir_all(&sub).unwrap();
339 let found = find_instruction_files(&sub);
340 assert_eq!(found, vec![root.join("MERMAID.md")]);
341 let _ = fs::remove_dir_all(&root);
342 }
343
344 #[test]
345 fn find_instruction_files_stops_at_git_root_without_file() {
346 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
347 let root = temp_dir("git_no_md");
348 fs::create_dir(root.join(".git")).unwrap();
349 let parent = root.parent().unwrap();
352 let above_md = parent.join("MERMAID.md");
353 fs::write(&above_md, "outside").unwrap();
354 let sub = root.join("subdir");
355 fs::create_dir_all(&sub).unwrap();
356 let found = find_instruction_files(&sub);
357 assert!(found.is_empty(), "walk must stop at .git boundary");
358 let _ = fs::remove_dir_all(&root);
359 let _ = fs::remove_file(&above_md);
360 }
361
362 #[test]
363 fn find_instruction_files_returns_empty_if_absent() {
364 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
365 let dir = temp_dir("absent");
366 fs::create_dir(dir.join(".git")).unwrap();
369 let found = find_instruction_files(&dir);
370 assert!(found.is_empty());
371 let _ = fs::remove_dir_all(&dir);
372 }
373
374 #[test]
375 fn load_from_path_truncates_oversized_file() {
376 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
377 let dir = temp_dir("oversized");
378 let path = dir.join("MERMAID.md");
379 let big = "a".repeat(50_000);
381 fs::write(&path, &big).unwrap();
382 let loaded = load_from_path(&path).expect("load");
383 assert!(loaded.truncated);
384 assert_eq!(loaded.byte_len, 50_000); assert!(loaded.content.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
386 assert_eq!(
388 loaded.content.len(),
389 MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
390 );
391 let _ = fs::remove_dir_all(&dir);
392 }
393
394 #[test]
395 fn load_from_path_returns_none_when_missing() {
396 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
397 let dir = temp_dir("missing");
398 assert!(load_from_path(&dir.join("nope.md")).is_none());
399 let _ = fs::remove_dir_all(&dir);
400 }
401
402 #[test]
403 fn refresh_returns_unchanged_when_mtime_stable() {
404 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
405 let dir = temp_dir("stable");
406 let path = dir.join("MERMAID.md");
407 fs::write(&path, "v1").unwrap();
408 let prior = load_from_path(&path).unwrap();
409 let (after, outcome) = refresh(Some(prior.clone()), &dir);
410 assert_eq!(outcome, ReloadOutcome::Unchanged);
411 assert!(after.is_some());
412 let _ = fs::remove_dir_all(&dir);
413 }
414
415 #[test]
416 fn refresh_returns_reloaded_on_content_change() {
417 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
418 let dir = temp_dir("changed");
419 let path = dir.join("MERMAID.md");
420 fs::write(&path, "v1").unwrap();
421 let prior = load_from_path(&path).unwrap();
422 std::thread::sleep(std::time::Duration::from_millis(1100));
425 fs::write(&path, "v2 longer content here").unwrap();
426 let (after, outcome) = refresh(Some(prior), &dir);
427 assert!(matches!(outcome, ReloadOutcome::Reloaded { .. }));
428 assert_eq!(after.unwrap().content, "v2 longer content here");
429 let _ = fs::remove_dir_all(&dir);
430 }
431
432 #[test]
433 fn refresh_returns_removed_when_file_deleted() {
434 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
435 let dir = temp_dir("removed");
436 let path = dir.join("MERMAID.md");
437 fs::write(&path, "v1").unwrap();
438 let prior = load_from_path(&path).unwrap();
439 fs::remove_file(&path).unwrap();
440 let (after, outcome) = refresh(Some(prior), &dir);
441 assert_eq!(outcome, ReloadOutcome::Removed);
442 assert!(after.is_none());
443 let _ = fs::remove_dir_all(&dir);
444 }
445
446 #[test]
447 fn refresh_returns_loaded_first_on_initial_discovery() {
448 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
449 let dir = temp_dir("first");
450 fs::create_dir(dir.join(".git")).unwrap();
452 fs::write(dir.join("MERMAID.md"), "fresh").unwrap();
454 let (after, outcome) = refresh(None, &dir);
455 assert!(matches!(outcome, ReloadOutcome::LoadedFirst { .. }));
456 assert_eq!(after.unwrap().content, "fresh");
457 let _ = fs::remove_dir_all(&dir);
458 }
459}