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] = &["MERMAID.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md"];
23
24const MAX_WALK_DEPTH: usize = 32;
27
28#[derive(Debug, Clone)]
31pub struct InstructionSource {
32 pub path: PathBuf,
33 pub mtime: SystemTime,
34 pub byte_len: usize,
35}
36
37#[derive(Debug, Clone)]
41pub struct LoadedInstructions {
42 pub path: PathBuf,
46 pub content: String,
49 pub mtime: SystemTime,
52 pub byte_len: usize,
54 pub truncated: bool,
57 pub sources: Vec<InstructionSource>,
59}
60
61impl LoadedInstructions {
62 pub fn approx_tokens(&self) -> usize {
65 self.content.len() / 4
66 }
67}
68
69#[derive(Debug, PartialEq, Eq)]
72pub enum ReloadOutcome {
73 Unchanged,
75 LoadedFirst { tokens: usize },
78 Reloaded {
80 old_tokens: usize,
81 new_tokens: usize,
82 },
83 Removed,
85}
86
87pub fn find_instruction_files(start: &Path) -> Vec<PathBuf> {
97 let home = std::env::var_os("HOME").map(PathBuf::from);
98 let mut current = start.to_path_buf();
99 for _ in 0..MAX_WALK_DEPTH {
100 let found: Vec<PathBuf> = INSTRUCTION_FILENAMES
101 .iter()
102 .map(|name| current.join(name))
103 .filter(|candidate| candidate.is_file())
104 .chain(
105 [current.join(".mermaid").join("memory").join("memory.jsonl")]
106 .into_iter()
107 .filter(|candidate| candidate.is_file()),
108 )
109 .collect();
110 if !found.is_empty() {
111 return found;
112 }
113 if current.join(".git").exists() {
116 return Vec::new();
117 }
118 if let Some(ref h) = home
122 && current == *h
123 {
124 return Vec::new();
125 }
126 match current.parent() {
128 Some(parent) if parent != current => current = parent.to_path_buf(),
129 _ => return Vec::new(),
130 }
131 }
132 Vec::new()
133}
134
135pub fn find_mermaid_md(start: &Path) -> Option<PathBuf> {
138 find_instruction_files(start)
139 .into_iter()
140 .find(|path| path.file_name().is_some_and(|name| name == "MERMAID.md"))
141}
142
143pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
147 load_from_paths(&[path.to_path_buf()])
148}
149
150pub fn load_from_paths(paths: &[PathBuf]) -> Option<LoadedInstructions> {
153 let mut sources = Vec::new();
154 let mut bodies = Vec::new();
155 let mut total_byte_len = 0usize;
156 let mut latest_mtime = UNIX_EPOCH;
157
158 for path in paths {
159 let metadata = std::fs::metadata(path).ok()?;
160 let mtime = metadata.modified().ok()?;
161 let raw = std::fs::read_to_string(path).ok()?;
162 total_byte_len = total_byte_len.saturating_add(raw.len());
163 if mtime > latest_mtime {
164 latest_mtime = mtime;
165 }
166 sources.push(InstructionSource {
167 path: path.to_path_buf(),
168 mtime,
169 byte_len: raw.len(),
170 });
171 bodies.push((path.to_path_buf(), raw));
172 }
173 let primary = sources.first()?.path.clone();
174 let raw = combine_instruction_bodies(bodies);
175 let byte_len = total_byte_len;
176 let (content, truncated) = if raw.len() > MAX_INSTRUCTIONS_BYTES {
177 let cut = raw.floor_char_boundary(MAX_INSTRUCTIONS_BYTES);
180 let mut clipped = raw[..cut].to_string();
181 clipped.push_str(INSTRUCTIONS_TRUNCATION_MARKER);
182 (clipped, true)
183 } else {
184 (raw, false)
185 };
186 Some(LoadedInstructions {
187 path: primary,
188 content,
189 mtime: latest_mtime,
190 byte_len,
191 truncated,
192 sources,
193 })
194}
195
196pub fn refresh(
203 current: Option<LoadedInstructions>,
204 cwd: &Path,
205) -> (Option<LoadedInstructions>, ReloadOutcome) {
206 match current {
207 Some(prior) => {
208 let paths: Vec<PathBuf> = if prior.sources.is_empty() {
210 vec![prior.path.clone()]
211 } else {
212 prior
213 .sources
214 .iter()
215 .map(|source| source.path.clone())
216 .collect()
217 };
218 let changed = if prior.sources.is_empty() {
219 std::fs::metadata(&prior.path)
220 .and_then(|m| m.modified())
221 .map(|mtime| mtime != prior.mtime)
222 .unwrap_or(true)
223 } else {
224 prior.sources.iter().any(|source| {
225 std::fs::metadata(&source.path)
226 .and_then(|m| m.modified())
227 .map(|mtime| mtime != source.mtime)
228 .unwrap_or(true)
229 })
230 };
231 if !changed {
232 return (Some(prior), ReloadOutcome::Unchanged);
233 }
234 let old_tokens = prior.approx_tokens();
235 match load_from_paths(&paths) {
236 Some(reloaded) => {
237 let new_tokens = reloaded.approx_tokens();
238 (
239 Some(reloaded),
240 ReloadOutcome::Reloaded {
241 old_tokens,
242 new_tokens,
243 },
244 )
245 },
246 None => {
247 (None, ReloadOutcome::Removed)
250 },
251 }
252 },
253 None => {
254 match load_from_paths(&find_instruction_files(cwd)) {
257 Some(loaded) => {
258 let tokens = loaded.approx_tokens();
259 (Some(loaded), ReloadOutcome::LoadedFirst { tokens })
260 },
261 None => (None, ReloadOutcome::Unchanged),
262 }
263 },
264 }
265}
266
267fn combine_instruction_bodies(bodies: Vec<(PathBuf, String)>) -> String {
268 if bodies.len() == 1 {
269 return bodies
270 .into_iter()
271 .next()
272 .map(|(_, body)| body)
273 .unwrap_or_default();
274 }
275 bodies
276 .into_iter()
277 .map(|(path, body)| {
278 let name = path
279 .file_name()
280 .and_then(|name| name.to_str())
281 .unwrap_or("instructions");
282 format!("# Project Instructions: {}\n\n{}", name, body)
283 })
284 .collect::<Vec<_>>()
285 .join("\n\n---\n\n")
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::fs;
292 use std::sync::Mutex;
293
294 static FS_LOCK: Mutex<()> = Mutex::new(());
297
298 fn temp_dir(name: &str) -> PathBuf {
299 let p = std::env::temp_dir().join(format!("mermaid_instructions_test_{}", name));
300 let _ = fs::remove_dir_all(&p);
301 fs::create_dir_all(&p).expect("create temp dir");
302 p
303 }
304
305 #[test]
306 fn find_mermaid_md_finds_in_cwd() {
307 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
308 let dir = temp_dir("cwd");
309 fs::write(dir.join("MERMAID.md"), "rules").unwrap();
310 let found = find_mermaid_md(&dir).expect("should find");
311 assert_eq!(found, dir.join("MERMAID.md"));
312 let _ = fs::remove_dir_all(&dir);
313 }
314
315 #[test]
316 fn find_instruction_files_loads_interoperable_files() {
317 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
318 let dir = temp_dir("agents");
319 fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
320 fs::write(dir.join("CLAUDE.md"), "claude rules").unwrap();
321 let found = find_instruction_files(&dir);
322 assert_eq!(found, vec![dir.join("AGENTS.md"), dir.join("CLAUDE.md")]);
323 let loaded = load_from_paths(&found).expect("load combined");
324 assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
325 assert!(loaded.content.contains("agent rules"));
326 assert!(loaded.content.contains("# Project Instructions: CLAUDE.md"));
327 assert_eq!(loaded.sources.len(), 2);
328 let _ = fs::remove_dir_all(&dir);
329 }
330
331 #[test]
332 fn find_mermaid_md_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_mermaid_md(&sub).expect("should walk up");
340 assert_eq!(found, root.join("MERMAID.md"));
341 let _ = fs::remove_dir_all(&root);
342 }
343
344 #[test]
345 fn find_mermaid_md_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_mermaid_md(&sub);
357 assert!(found.is_none(), "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_mermaid_md_returns_none_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();
371 let found = find_mermaid_md(&dir);
372 assert!(found.is_none());
373 let _ = fs::remove_dir_all(&dir);
374 }
375
376 #[test]
377 fn load_from_path_truncates_oversized_file() {
378 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
379 let dir = temp_dir("oversized");
380 let path = dir.join("MERMAID.md");
381 let big = "a".repeat(50_000);
383 fs::write(&path, &big).unwrap();
384 let loaded = load_from_path(&path).expect("load");
385 assert!(loaded.truncated);
386 assert_eq!(loaded.byte_len, 50_000); assert!(loaded.content.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
388 assert_eq!(
390 loaded.content.len(),
391 MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
392 );
393 let _ = fs::remove_dir_all(&dir);
394 }
395
396 #[test]
397 fn load_from_path_returns_none_when_missing() {
398 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
399 let dir = temp_dir("missing");
400 assert!(load_from_path(&dir.join("nope.md")).is_none());
401 let _ = fs::remove_dir_all(&dir);
402 }
403
404 #[test]
405 fn refresh_returns_unchanged_when_mtime_stable() {
406 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
407 let dir = temp_dir("stable");
408 let path = dir.join("MERMAID.md");
409 fs::write(&path, "v1").unwrap();
410 let prior = load_from_path(&path).unwrap();
411 let (after, outcome) = refresh(Some(prior.clone()), &dir);
412 assert_eq!(outcome, ReloadOutcome::Unchanged);
413 assert!(after.is_some());
414 let _ = fs::remove_dir_all(&dir);
415 }
416
417 #[test]
418 fn refresh_returns_reloaded_on_content_change() {
419 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
420 let dir = temp_dir("changed");
421 let path = dir.join("MERMAID.md");
422 fs::write(&path, "v1").unwrap();
423 let prior = load_from_path(&path).unwrap();
424 std::thread::sleep(std::time::Duration::from_millis(1100));
427 fs::write(&path, "v2 longer content here").unwrap();
428 let (after, outcome) = refresh(Some(prior), &dir);
429 assert!(matches!(outcome, ReloadOutcome::Reloaded { .. }));
430 assert_eq!(after.unwrap().content, "v2 longer content here");
431 let _ = fs::remove_dir_all(&dir);
432 }
433
434 #[test]
435 fn refresh_returns_removed_when_file_deleted() {
436 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
437 let dir = temp_dir("removed");
438 let path = dir.join("MERMAID.md");
439 fs::write(&path, "v1").unwrap();
440 let prior = load_from_path(&path).unwrap();
441 fs::remove_file(&path).unwrap();
442 let (after, outcome) = refresh(Some(prior), &dir);
443 assert_eq!(outcome, ReloadOutcome::Removed);
444 assert!(after.is_none());
445 let _ = fs::remove_dir_all(&dir);
446 }
447
448 #[test]
449 fn refresh_returns_loaded_first_on_initial_discovery() {
450 let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
451 let dir = temp_dir("first");
452 fs::create_dir(dir.join(".git")).unwrap();
454 fs::write(dir.join("MERMAID.md"), "fresh").unwrap();
456 let (after, outcome) = refresh(None, &dir);
457 assert!(matches!(outcome, ReloadOutcome::LoadedFirst { .. }));
458 assert_eq!(after.unwrap().content, "fresh");
459 let _ = fs::remove_dir_all(&dir);
460 }
461}