use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::constants::{INSTRUCTIONS_TRUNCATION_MARKER, MAX_INSTRUCTIONS_BYTES};
const INSTRUCTIONS_FILENAME: &str = "MERMAID.md";
const MAX_WALK_DEPTH: usize = 32;
#[derive(Debug, Clone)]
pub struct LoadedInstructions {
pub path: PathBuf,
pub content: String,
pub mtime: SystemTime,
pub byte_len: usize,
pub truncated: bool,
}
impl LoadedInstructions {
pub fn approx_tokens(&self) -> usize {
self.content.len() / 4
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum ReloadOutcome {
Unchanged,
LoadedFirst { tokens: usize },
Reloaded {
old_tokens: usize,
new_tokens: usize,
},
Removed,
}
pub fn find_mermaid_md(start: &Path) -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut current = start.to_path_buf();
for _ in 0..MAX_WALK_DEPTH {
let candidate = current.join(INSTRUCTIONS_FILENAME);
if candidate.is_file() {
return Some(candidate);
}
if current.join(".git").exists() {
return None;
}
if let Some(ref h) = home
&& current == *h
{
return None;
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => return None,
}
}
None
}
pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
let metadata = std::fs::metadata(path).ok()?;
let mtime = metadata.modified().ok()?;
let raw = std::fs::read_to_string(path).ok()?;
let byte_len = raw.len();
let (content, truncated) = if byte_len > MAX_INSTRUCTIONS_BYTES {
let cut = raw.floor_char_boundary(MAX_INSTRUCTIONS_BYTES);
let mut clipped = raw[..cut].to_string();
clipped.push_str(INSTRUCTIONS_TRUNCATION_MARKER);
(clipped, true)
} else {
(raw, false)
};
Some(LoadedInstructions {
path: path.to_path_buf(),
content,
mtime,
byte_len,
truncated,
})
}
pub fn refresh(
current: Option<LoadedInstructions>,
cwd: &Path,
) -> (Option<LoadedInstructions>, ReloadOutcome) {
match current {
Some(prior) => {
let metadata = std::fs::metadata(&prior.path);
match metadata.and_then(|m| m.modified()) {
Ok(new_mtime) if new_mtime == prior.mtime => {
(Some(prior), ReloadOutcome::Unchanged)
},
Ok(_) => {
let old_tokens = prior.approx_tokens();
let path = prior.path.clone();
match load_from_path(&path) {
Some(reloaded) => {
let new_tokens = reloaded.approx_tokens();
(
Some(reloaded),
ReloadOutcome::Reloaded {
old_tokens,
new_tokens,
},
)
},
None => {
(None, ReloadOutcome::Removed)
},
}
},
Err(_) => {
(None, ReloadOutcome::Removed)
},
}
},
None => {
match find_mermaid_md(cwd).and_then(|p| load_from_path(&p)) {
Some(loaded) => {
let tokens = loaded.approx_tokens();
(Some(loaded), ReloadOutcome::LoadedFirst { tokens })
},
None => (None, ReloadOutcome::Unchanged),
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Mutex;
static FS_LOCK: Mutex<()> = Mutex::new(());
fn temp_dir(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("mermaid_instructions_test_{}", name));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).expect("create temp dir");
p
}
#[test]
fn find_mermaid_md_finds_in_cwd() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("cwd");
fs::write(dir.join("MERMAID.md"), "rules").unwrap();
let found = find_mermaid_md(&dir).expect("should find");
assert_eq!(found, dir.join("MERMAID.md"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_mermaid_md_walks_up_to_git_root() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let root = temp_dir("walkup");
fs::create_dir(root.join(".git")).unwrap();
fs::write(root.join("MERMAID.md"), "root rules").unwrap();
let sub = root.join("subdir/deeper");
fs::create_dir_all(&sub).unwrap();
let found = find_mermaid_md(&sub).expect("should walk up");
assert_eq!(found, root.join("MERMAID.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn find_mermaid_md_stops_at_git_root_without_file() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let root = temp_dir("git_no_md");
fs::create_dir(root.join(".git")).unwrap();
let parent = root.parent().unwrap();
let above_md = parent.join("MERMAID.md");
fs::write(&above_md, "outside").unwrap();
let sub = root.join("subdir");
fs::create_dir_all(&sub).unwrap();
let found = find_mermaid_md(&sub);
assert!(found.is_none(), "walk must stop at .git boundary");
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_file(&above_md);
}
#[test]
fn find_mermaid_md_returns_none_if_absent() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("absent");
fs::create_dir(dir.join(".git")).unwrap();
let found = find_mermaid_md(&dir);
assert!(found.is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_from_path_truncates_oversized_file() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("oversized");
let path = dir.join("MERMAID.md");
let big = "a".repeat(50_000);
fs::write(&path, &big).unwrap();
let loaded = load_from_path(&path).expect("load");
assert!(loaded.truncated);
assert_eq!(loaded.byte_len, 50_000); assert!(loaded.content.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
assert_eq!(
loaded.content.len(),
MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_from_path_returns_none_when_missing() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("missing");
assert!(load_from_path(&dir.join("nope.md")).is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn refresh_returns_unchanged_when_mtime_stable() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("stable");
let path = dir.join("MERMAID.md");
fs::write(&path, "v1").unwrap();
let prior = load_from_path(&path).unwrap();
let (after, outcome) = refresh(Some(prior.clone()), &dir);
assert_eq!(outcome, ReloadOutcome::Unchanged);
assert!(after.is_some());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn refresh_returns_reloaded_on_content_change() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("changed");
let path = dir.join("MERMAID.md");
fs::write(&path, "v1").unwrap();
let prior = load_from_path(&path).unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
fs::write(&path, "v2 longer content here").unwrap();
let (after, outcome) = refresh(Some(prior), &dir);
assert!(matches!(outcome, ReloadOutcome::Reloaded { .. }));
assert_eq!(after.unwrap().content, "v2 longer content here");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn refresh_returns_removed_when_file_deleted() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("removed");
let path = dir.join("MERMAID.md");
fs::write(&path, "v1").unwrap();
let prior = load_from_path(&path).unwrap();
fs::remove_file(&path).unwrap();
let (after, outcome) = refresh(Some(prior), &dir);
assert_eq!(outcome, ReloadOutcome::Removed);
assert!(after.is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn refresh_returns_loaded_first_on_initial_discovery() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("first");
fs::create_dir(dir.join(".git")).unwrap();
fs::write(dir.join("MERMAID.md"), "fresh").unwrap();
let (after, outcome) = refresh(None, &dir);
assert!(matches!(outcome, ReloadOutcome::LoadedFirst { .. }));
assert_eq!(after.unwrap().content, "fresh");
let _ = fs::remove_dir_all(&dir);
}
}