use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use crate::gitconfig as config;
pub const KEY_NOTICE: &str = "amont.agent.agentsMdNotice";
const START: &str = "<!-- amont:start -->";
const DRIFT_BLOCK: &str = "drifted from the generated block";
const DRIFT_POINTER: &str = "signpost drifted from the generated one";
const BUDGET: Duration = Duration::from_secs(5);
pub fn notice(cwd: &Path) -> Option<String> {
if crate::stance::switched_off() || !config::boolean_or(crate::stance::KEY_ENABLED, true) {
return None;
}
let root = crate::git::stdout_in(cwd, &["rev-parse", "--show-toplevel"])?;
let root = Path::new(&root);
let has_markers = |p: &Path| std::fs::read_to_string(p).is_ok_and(|s| s.contains(START));
if !has_markers(&root.join("AGENTS.md")) && !has_markers(&root.join("CLAUDE.md")) {
return None;
}
if !config::boolean_or(KEY_NOTICE, true) {
return None;
}
let stale = drifted(root)?;
Some(format!(
"amont-agent/agents-md: {} in this repository {} behind the block amont \
generates — the hook list, budgets and conventions it states may be last \
release's. `amont agents-md` regenerates it (commit the result); until \
then, `amont list --json` is the current answer.",
stale.join(" and "),
if stale.len() == 1 { "is" } else { "are" },
))
}
fn drifted(root: &Path) -> Option<Vec<String>> {
let stderr = ask(root)?;
let mut stale: Vec<String> = Vec::new();
for line in stderr.lines() {
let marker = if line.contains(DRIFT_POINTER) {
DRIFT_POINTER
} else if line.contains(DRIFT_BLOCK) {
DRIFT_BLOCK
} else {
continue;
};
let named = line[..line.find(marker)?]
.trim_end()
.trim_end_matches(':')
.trim();
let name = Path::new(named)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| named.to_string());
if !name.is_empty() && !stale.contains(&name) {
stale.push(crate::ui::sanitize(&name));
}
}
(!stale.is_empty()).then_some(stale)
}
fn ask(root: &Path) -> Option<String> {
let mut child = Command::new("amont")
.arg("agents-md")
.arg("--check")
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let deadline = std::time::Instant::now() + BUDGET;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(25));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Err(_) => return None,
}
}
let mut buf = String::new();
use std::io::Read;
child.stderr.take()?.read_to_string(&mut buf).ok()?;
Some(buf)
}
#[cfg(test)]
mod tests {
use super::*;
fn names(stderr: &str) -> Option<Vec<String>> {
let mut stale: Vec<String> = Vec::new();
for line in stderr.lines() {
let marker = if line.contains(DRIFT_POINTER) {
DRIFT_POINTER
} else if line.contains(DRIFT_BLOCK) {
DRIFT_BLOCK
} else {
continue;
};
let named = line[..line.find(marker).unwrap()]
.trim_end()
.trim_end_matches(':')
.trim();
let name = Path::new(named)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| named.to_string());
if !name.is_empty() && !stale.contains(&name) {
stale.push(name);
}
}
(!stale.is_empty()).then_some(stale)
}
#[test]
fn a_drifted_block_is_named_by_its_filename() {
let out = "/repo/AGENTS.md: drifted from the generated block — run `amont agents-md`";
assert_eq!(names(out), Some(vec!["AGENTS.md".to_string()]));
}
#[test]
fn both_files_are_reported_in_the_order_amont_printed_them() {
let out = "/r/CLAUDE.md: signpost drifted from the generated one — run `amont agents-md`\n\
/r/AGENTS.md: drifted from the generated block — run `amont agents-md`";
assert_eq!(
names(out),
Some(vec!["CLAUDE.md".to_string(), "AGENTS.md".to_string()])
);
}
#[test]
fn an_error_that_is_not_drift_says_nothing() {
assert_eq!(
names("/repo/AGENTS.md: Permission denied (os error 13)"),
None
);
assert_eq!(names("fatal: not a git repository"), None);
assert_eq!(
names("/repo/AGENTS.md: no closing <!-- amont:end --> marker"),
None
);
assert_eq!(names(""), None);
}
#[test]
fn up_to_date_says_nothing() {
assert_eq!(names("/repo/AGENTS.md: up to date"), None);
}
}