use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::constants::{INSTRUCTIONS_TRUNCATION_MARKER, MAX_INSTRUCTIONS_BYTES};
pub const INSTRUCTION_FILENAMES: &[&str] = &["AGENTS.md", "MERMAID.md"];
const MAX_WALK_DEPTH: usize = 32;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InstructionSource {
pub path: PathBuf,
pub mtime: SystemTime,
pub byte_len: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LoadedInstructions {
pub path: PathBuf,
pub content: String,
pub mtime: SystemTime,
pub byte_len: usize,
pub truncated: bool,
pub sources: Vec<InstructionSource>,
}
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_instruction_files(start: &Path) -> Vec<PathBuf> {
find_instruction_files_bounded(start, home_dir_boundary().as_deref())
}
fn home_dir_boundary() -> Option<PathBuf> {
let home = std::env::var_os("HOME");
#[cfg(windows)]
let resolved = pick_home_boundary(
home.as_deref(),
std::env::var_os("USERPROFILE").as_deref(),
std::env::var_os("HOMEDRIVE").as_deref(),
std::env::var_os("HOMEPATH").as_deref(),
);
#[cfg(not(windows))]
let resolved = pick_home_boundary(home.as_deref(), None, None, None);
resolved
}
fn pick_home_boundary(
home: Option<&std::ffi::OsStr>,
userprofile: Option<&std::ffi::OsStr>,
homedrive: Option<&std::ffi::OsStr>,
homepath: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
fn nonempty(v: Option<&std::ffi::OsStr>) -> Option<&std::ffi::OsStr> {
v.filter(|s| !s.is_empty())
}
if let Some(home) = nonempty(home) {
return Some(PathBuf::from(home));
}
if let Some(profile) = nonempty(userprofile) {
return Some(PathBuf::from(profile));
}
if let (Some(drive), Some(path)) = (nonempty(homedrive), nonempty(homepath)) {
let mut combined = drive.to_os_string();
combined.push(path);
return Some(PathBuf::from(combined));
}
None
}
fn find_instruction_files_bounded(start: &Path, home: Option<&Path>) -> Vec<PathBuf> {
let mut current = start.to_path_buf();
for _ in 0..MAX_WALK_DEPTH {
if home == Some(current.as_path()) {
return Vec::new();
}
let found: Vec<PathBuf> = INSTRUCTION_FILENAMES
.iter()
.map(|name| current.join(name))
.filter(|candidate| candidate.is_file())
.collect();
if !found.is_empty() {
return found;
}
if current.join(".git").exists() {
return Vec::new();
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => return Vec::new(),
}
}
Vec::new()
}
pub fn load_from_path(path: &Path) -> Option<LoadedInstructions> {
load_from_paths(&[path.to_path_buf()])
}
pub fn load_from_paths(paths: &[PathBuf]) -> Option<LoadedInstructions> {
let mut sources = Vec::new();
let mut bodies = Vec::new();
let mut total_byte_len = 0usize;
let mut latest_mtime = UNIX_EPOCH;
for path in paths {
let Ok(metadata) = std::fs::metadata(path) else {
continue;
};
let Ok(mtime) = metadata.modified() else {
continue;
};
let true_len = metadata.len() as usize;
let Ok((bytes, _truncated)) =
crate::utils::read_file_capped(path, MAX_INSTRUCTIONS_BYTES.saturating_add(1))
else {
continue;
};
let raw = String::from_utf8_lossy(&bytes).into_owned();
total_byte_len = total_byte_len.saturating_add(true_len);
if mtime > latest_mtime {
latest_mtime = mtime;
}
sources.push(InstructionSource {
path: path.to_path_buf(),
mtime,
byte_len: true_len,
});
bodies.push((path.to_path_buf(), raw));
}
let primary = sources.first()?.path.clone();
let sections = label_instruction_bodies(bodies);
let byte_len = total_byte_len;
let (content, truncated) = combine_and_cap_sections(sections, MAX_INSTRUCTIONS_BYTES);
Some(LoadedInstructions {
path: primary,
content,
mtime: latest_mtime,
byte_len,
truncated,
sources,
})
}
pub fn refresh(
current: Option<LoadedInstructions>,
cwd: &Path,
) -> (Option<LoadedInstructions>, ReloadOutcome) {
match current {
Some(prior) => {
let paths: Vec<PathBuf> = if prior.sources.is_empty() {
vec![prior.path.clone()]
} else {
prior
.sources
.iter()
.map(|source| source.path.clone())
.collect()
};
let changed = if prior.sources.is_empty() {
std::fs::metadata(&prior.path)
.and_then(|m| m.modified())
.map(|mtime| mtime != prior.mtime)
.unwrap_or(true)
} else {
prior.sources.iter().any(|source| {
std::fs::metadata(&source.path)
.and_then(|m| m.modified())
.map(|mtime| mtime != source.mtime)
.unwrap_or(true)
})
};
if !changed {
return (Some(prior), ReloadOutcome::Unchanged);
}
let old_tokens = prior.approx_tokens();
match load_from_paths(&paths) {
Some(reloaded) => {
let new_tokens = reloaded.approx_tokens();
(
Some(reloaded),
ReloadOutcome::Reloaded {
old_tokens,
new_tokens,
},
)
},
None => {
(None, ReloadOutcome::Removed)
},
}
},
None => {
match load_from_paths(&find_instruction_files(cwd)) {
Some(loaded) => {
let tokens = loaded.approx_tokens();
(Some(loaded), ReloadOutcome::LoadedFirst { tokens })
},
None => (None, ReloadOutcome::Unchanged),
}
},
}
}
pub fn load_project_context(
cwd: &Path,
mem_cfg: &crate::app::MemoryConfig,
) -> (
Option<LoadedInstructions>,
Option<crate::app::memory::LoadedMemory>,
) {
let (instructions, _) = refresh(None, cwd);
let (memory, _) = crate::app::memory::refresh(None, cwd, mem_cfg);
(instructions, memory)
}
const INSTRUCTION_SECTION_SEPARATOR: &str = "\n\n---\n\n";
fn label_instruction_bodies(bodies: Vec<(PathBuf, String)>) -> Vec<String> {
bodies
.into_iter()
.map(|(path, body)| {
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("instructions");
format!("# Project Instructions: {}\n\n{}", name, body)
})
.collect()
}
fn combine_and_cap_sections(sections: Vec<String>, cap: usize) -> (String, bool) {
const SEP: &str = INSTRUCTION_SECTION_SEPARATOR;
let marker = INSTRUCTIONS_TRUNCATION_MARKER;
let full = sections.join(SEP);
if full.len() <= cap {
return (full, false);
}
let Some(winner) = sections.last() else {
return (String::new(), false);
};
if winner.len() >= cap {
let cut = winner.floor_char_boundary(cap);
let mut clipped = winner[..cut].to_string();
clipped.push_str(marker);
return (clipped, true);
}
let earlier_len = sections.len() - 1;
let earlier_full = sections[..earlier_len].join(SEP);
let avail = cap.saturating_sub(winner.len() + SEP.len());
let cut = earlier_full.floor_char_boundary(avail);
if cut == 0 {
let mut body = winner.clone();
body.push_str(marker);
return (body, true);
}
let mut body = String::with_capacity(cut + marker.len() + SEP.len() + winner.len());
body.push_str(&earlier_full[..cut]);
body.push_str(marker);
body.push_str(SEP);
body.push_str(winner.as_str());
(body, true)
}
#[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_instruction_files_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_instruction_files(&dir);
assert_eq!(found, vec![dir.join("MERMAID.md")]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_instruction_files_loads_both_in_precedence_order() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("both");
fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
fs::write(dir.join("MERMAID.md"), "mermaid rules").unwrap();
let found = find_instruction_files(&dir);
assert_eq!(found, vec![dir.join("AGENTS.md"), dir.join("MERMAID.md")]);
let loaded = load_from_paths(&found).expect("load combined");
assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
assert!(loaded.content.contains("agent rules"));
assert!(
loaded
.content
.contains("# Project Instructions: MERMAID.md")
);
assert!(loaded.content.contains("mermaid rules"));
assert!(
loaded.content.find("mermaid rules") > loaded.content.find("agent rules"),
"MERMAID.md must come last so its guidance overrides AGENTS.md"
);
assert_eq!(loaded.sources.len(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_instruction_files_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_instruction_files(&sub);
assert_eq!(found, vec![root.join("MERMAID.md")]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn find_instruction_files_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_instruction_files(&sub);
assert!(found.is_empty(), "walk must stop at .git boundary");
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_file(&above_md);
}
#[test]
fn find_instruction_files_returns_empty_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_instruction_files(&dir);
assert!(found.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_instruction_files_stops_at_home_boundary() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = temp_dir("home_boundary");
fs::write(home.join("AGENTS.md"), "home rules").unwrap();
let child = home.join("project");
fs::create_dir_all(&child).unwrap();
let found = find_instruction_files_bounded(&child, Some(home.as_path()));
assert!(
found.is_empty(),
"walk must stop at $HOME and not load ~/AGENTS.md, got {found:?}"
);
let _ = fs::remove_dir_all(&home);
}
#[test]
fn single_file_instructions_get_labeled_header() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("single_header");
fs::write(dir.join("MERMAID.md"), "do the thing").unwrap();
let loaded = load_from_path(&dir.join("MERMAID.md")).expect("load");
assert!(
loaded
.content
.starts_with("# Project Instructions: MERMAID.md"),
"single-file instructions must carry a labeled header, got: {:?}",
loaded.content
);
assert!(loaded.content.contains("do the thing"));
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 { .. }));
let content = after.unwrap().content;
assert!(content.contains("# Project Instructions: MERMAID.md"));
assert!(content.contains("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 { .. }));
let content = after.unwrap().content;
assert!(content.contains("# Project Instructions: MERMAID.md"));
assert!(content.contains("fresh"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_project_context_loads_instructions_synchronously() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("project_context");
fs::create_dir(dir.join(".git")).unwrap();
fs::write(dir.join("MERMAID.md"), "sync-loaded instructions").unwrap();
let (instructions, _memory) =
load_project_context(&dir, &crate::app::MemoryConfig::default());
let content = instructions
.expect("instructions must load synchronously")
.content;
assert!(content.contains("sync-loaded instructions"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn oversized_agents_does_not_drop_mermaid_winner() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("agents_huge");
fs::write(dir.join("AGENTS.md"), "A".repeat(60_000)).unwrap();
fs::write(dir.join("MERMAID.md"), "MERMAID_WINS_SENTINEL").unwrap();
let loaded =
load_from_paths(&[dir.join("AGENTS.md"), dir.join("MERMAID.md")]).expect("load");
assert!(loaded.truncated, "combined body exceeds the cap");
assert!(
loaded.content.contains("MERMAID_WINS_SENTINEL"),
"MERMAID.md (the winner) must survive the cap, not be dropped"
);
assert!(
loaded
.content
.contains("# Project Instructions: MERMAID.md")
);
assert!(loaded.content.contains(INSTRUCTIONS_TRUNCATION_MARKER));
let marker_at = loaded.content.find(INSTRUCTIONS_TRUNCATION_MARKER).unwrap();
let winner_at = loaded.content.find("MERMAID_WINS_SENTINEL").unwrap();
assert!(
winner_at > marker_at,
"MERMAID.md must come after the truncated AGENTS.md"
);
assert!(
loaded.content.len() <= MAX_INSTRUCTIONS_BYTES + INSTRUCTIONS_TRUNCATION_MARKER.len()
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn combine_and_cap_protects_the_last_section() {
let lower = format!("# Project Instructions: AGENTS.md\n\n{}", "L".repeat(200));
let winner = "# Project Instructions: MERMAID.md\n\nWIN".to_string();
let (body, truncated) = combine_and_cap_sections(vec![lower, winner], 100);
assert!(truncated);
assert!(body.contains("WIN"), "winner survives the cap");
assert!(body.contains(INSTRUCTIONS_TRUNCATION_MARKER));
let marker_at = body.find(INSTRUCTIONS_TRUNCATION_MARKER).unwrap();
assert!(
body.find("WIN").unwrap() > marker_at,
"winner stays last so it overrides on conflict"
);
assert!(body.len() <= 100 + INSTRUCTIONS_TRUNCATION_MARKER.len());
}
#[test]
fn combine_and_cap_clips_winner_when_it_alone_overflows() {
let lower = "# Project Instructions: AGENTS.md\n\nlower-content".to_string();
let winner = format!("# Project Instructions: MERMAID.md\n\n{}", "W".repeat(300));
let (body, truncated) = combine_and_cap_sections(vec![lower, winner], 100);
assert!(truncated);
assert!(body.ends_with(INSTRUCTIONS_TRUNCATION_MARKER));
assert_eq!(body.len(), 100 + INSTRUCTIONS_TRUNCATION_MARKER.len());
assert!(
!body.contains("lower-content"),
"no room for the lower-precedence section"
);
}
#[test]
fn combine_and_cap_passes_through_when_it_fits() {
let a = "# Project Instructions: AGENTS.md\n\naye".to_string();
let b = "# Project Instructions: MERMAID.md\n\nbee".to_string();
let (body, truncated) = combine_and_cap_sections(vec![a, b], 10_000);
assert!(!truncated);
assert!(body.contains("aye") && body.contains("bee"));
assert!(
body.find("bee").unwrap() > body.find("aye").unwrap(),
"highest-precedence section stays last"
);
}
#[test]
fn load_from_paths_tolerates_a_missing_file() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("partial_load");
fs::write(dir.join("AGENTS.md"), "agent rules").unwrap();
let missing = dir.join("MERMAID.md"); let loaded = load_from_paths(&[dir.join("AGENTS.md"), missing])
.expect("AGENTS.md must still load when MERMAID.md is absent");
assert!(loaded.content.contains("# Project Instructions: AGENTS.md"));
assert!(loaded.content.contains("agent rules"));
assert_eq!(loaded.sources.len(), 1, "only the present file is a source");
assert_eq!(loaded.path, dir.join("AGENTS.md"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_from_paths_none_when_all_missing() {
let _lock = FS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = temp_dir("all_missing");
assert!(
load_from_paths(&[dir.join("AGENTS.md"), dir.join("MERMAID.md")]).is_none(),
"no present files => None"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn pick_home_boundary_resolves_windows_home_vars() {
use std::ffi::OsStr;
assert_eq!(
pick_home_boundary(
Some(OsStr::new("/home/me")),
Some(OsStr::new("C:\\Users\\me")),
None,
None
),
Some(PathBuf::from("/home/me"))
);
assert_eq!(
pick_home_boundary(None, Some(OsStr::new("C:\\Users\\me")), None, None),
Some(PathBuf::from("C:\\Users\\me"))
);
assert_eq!(
pick_home_boundary(
None,
None,
Some(OsStr::new("C:")),
Some(OsStr::new("\\Users\\me"))
),
Some(PathBuf::from("C:\\Users\\me"))
);
assert_eq!(
pick_home_boundary(Some(OsStr::new("")), Some(OsStr::new("")), None, None),
None
);
assert_eq!(
pick_home_boundary(None, None, Some(OsStr::new("C:")), None),
None
);
assert_eq!(
pick_home_boundary(None, None, None, Some(OsStr::new("\\Users\\me"))),
None
);
}
}