use crate::DecisionHit;
use serde::Serialize;
use std::path::{Path, PathBuf};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PathStatus {
Fresh,
StaleModified,
Missing,
Unknown,
}
#[derive(Debug, Clone, Serialize)]
pub struct PathStaleness {
pub path: String,
pub status: PathStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub touched_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DecisionStaleness {
pub is_stale: bool,
pub paths: Vec<PathStaleness>,
}
fn probe_target(pattern: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in pattern.components() {
let text = comp.as_os_str().to_string_lossy();
if text.contains('*') || text.contains('?') || text.contains('[') {
break;
}
out.push(comp);
}
if out.as_os_str().is_empty() {
return pattern.to_path_buf();
}
out
}
fn is_glob(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
}
fn newer_of(a: Option<String>, b: Option<String>) -> Option<String> {
let parse = |s: &str| OffsetDateTime::parse(s, &Rfc3339).ok();
match (a, b) {
(Some(x), Some(y)) => match (parse(&x), parse(&y)) {
(Some(dx), Some(dy)) => Some(if dy > dx { y } else { x }),
(Some(_), None) => Some(x),
(None, Some(_)) => Some(y),
(None, None) => Some(x),
},
(Some(x), None) => Some(x),
(None, Some(y)) => Some(y),
(None, None) => None,
}
}
const MAX_GLOB_ENTRIES: usize = 512;
pub trait FsOracle {
fn probe(&self, path: &Path) -> (bool, Option<String>);
fn newest_mtime_under(&self, _dir: &Path) -> Option<String> {
None
}
}
pub struct StdFs;
impl FsOracle for StdFs {
fn probe(&self, path: &Path) -> (bool, Option<String>) {
let Ok(meta) = std::fs::metadata(path) else {
return (false, None);
};
let Ok(modified) = meta.modified() else {
return (true, None);
};
let ts = OffsetDateTime::from(modified);
let rendered = ts.format(&Rfc3339).ok();
(true, rendered)
}
fn newest_mtime_under(&self, dir: &Path) -> Option<String> {
let rd = std::fs::read_dir(dir).ok()?;
let mut newest: Option<OffsetDateTime> = None;
for entry in rd.flatten().take(MAX_GLOB_ENTRIES) {
let Ok(meta) = entry.metadata() else { continue };
let Ok(modified) = meta.modified() else {
continue;
};
let ts = OffsetDateTime::from(modified);
newest = Some(newest.map_or(ts, |n| n.max(ts)));
}
newest.and_then(|t| t.format(&Rfc3339).ok())
}
}
pub fn check_paths_staleness<F: FsOracle>(
affected_paths: &[String],
decision_ts: &str,
repo_root: Option<&Path>,
fs: &F,
) -> Option<DecisionStaleness> {
if affected_paths.is_empty() {
return None;
}
let decision_dt = OffsetDateTime::parse(decision_ts, &Rfc3339).ok();
let mut out = Vec::with_capacity(affected_paths.len());
let mut any_stale = false;
for rel in affected_paths {
let pattern = probe_target(Path::new(rel));
let resolved: PathBuf = {
let p = pattern.as_path();
if p.is_absolute() {
p.to_path_buf()
} else {
match repo_root {
Some(root) => root.join(p),
None => {
out.push(PathStaleness {
path: rel.clone(),
status: PathStatus::Unknown,
touched_at: None,
});
continue;
}
}
}
};
let (exists, dir_mtime) = fs.probe(&resolved);
if !exists {
any_stale = true;
out.push(PathStaleness {
path: rel.clone(),
status: PathStatus::Missing,
touched_at: dir_mtime,
});
continue;
}
let touched_at = if is_glob(rel) {
newer_of(dir_mtime, fs.newest_mtime_under(&resolved))
} else {
dir_mtime
};
let status = match (&touched_at, &decision_dt) {
(Some(t), Some(dt)) => match OffsetDateTime::parse(t, &Rfc3339) {
Ok(mtime) => {
if mtime > *dt {
any_stale = true;
PathStatus::StaleModified
} else {
PathStatus::Fresh
}
}
Err(_) => PathStatus::Unknown,
},
_ => PathStatus::Unknown,
};
out.push(PathStaleness {
path: rel.clone(),
status,
touched_at,
});
}
Some(DecisionStaleness {
is_stale: any_stale,
paths: out,
})
}
pub fn annotate_hits(
hits: &mut [DecisionHit],
hits_paths: &[Vec<String>],
repo_root: Option<&Path>,
) {
let fs = StdFs;
for (hit, paths) in hits.iter_mut().zip(hits_paths.iter()) {
hit.staleness = check_paths_staleness(paths, &hit.ts, repo_root, &fs);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::HashMap;
struct MockFs {
entries: RefCell<HashMap<String, (bool, Option<String>)>>,
newest: RefCell<HashMap<String, Option<String>>>,
}
impl MockFs {
fn new() -> Self {
Self {
entries: RefCell::new(HashMap::new()),
newest: RefCell::new(HashMap::new()),
}
}
fn set(&self, path: &str, exists: bool, mtime: Option<&str>) {
self.entries
.borrow_mut()
.insert(path.to_string(), (exists, mtime.map(String::from)));
}
fn set_newest_under(&self, dir: &str, mtime: Option<&str>) {
self.newest
.borrow_mut()
.insert(dir.to_string(), mtime.map(String::from));
}
}
impl FsOracle for MockFs {
fn probe(&self, path: &Path) -> (bool, Option<String>) {
let key = path.to_string_lossy().replace('\\', "/");
self.entries
.borrow()
.get(&key)
.cloned()
.unwrap_or((false, None))
}
fn newest_mtime_under(&self, dir: &Path) -> Option<String> {
let key = dir.to_string_lossy().replace('\\', "/");
self.newest.borrow().get(&key).cloned().flatten()
}
}
#[test]
fn empty_paths_returns_none() {
let fs = MockFs::new();
let out = check_paths_staleness(&[], "2026-07-01T00:00:00Z", None, &fs);
assert!(out.is_none());
}
#[test]
fn a_glob_detects_a_file_edited_inside_it() {
let fs = MockFs::new();
fs.set("/repo/crates/foo", true, Some("2026-07-01T00:00:00Z"));
fs.set_newest_under("/repo/crates/foo", Some("2026-07-10T00:00:00Z"));
let out = check_paths_staleness(
&["crates/foo/*".to_string()],
"2026-07-05T00:00:00Z", Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert!(
out.is_stale,
"an edit inside the glob, after the decision, must be stale: {out:?}"
);
assert_eq!(out.paths[0].status, PathStatus::StaleModified);
}
#[test]
fn stdfs_newest_mtime_reads_a_real_directory() {
let dir = std::env::temp_dir().join(format!("edda_glob_stdfs_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), b"x").unwrap();
let fs = StdFs;
assert!(
fs.newest_mtime_under(&dir).is_some(),
"a real directory with an entry must yield a newest mtime"
);
assert!(
fs.newest_mtime_under(&dir.join("missing")).is_none(),
"a path that is not a readable directory yields None"
);
assert!(
fs.newest_mtime_under(&dir.join("a.rs")).is_none(),
"a plain file is not a directory to walk"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_literal_path_uses_its_own_mtime_not_its_siblings() {
let fs = MockFs::new();
fs.set("/repo/src/main.rs", true, Some("2026-07-01T00:00:00Z"));
fs.set_newest_under("/repo/src", Some("2026-07-10T00:00:00Z"));
let out = check_paths_staleness(
&["src/main.rs".to_string()],
"2026-07-05T00:00:00Z",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert_eq!(
out.paths[0].status,
PathStatus::Fresh,
"a literal path must ignore its siblings' edits: {out:?}"
);
}
#[test]
fn a_glob_is_checked_against_the_directory_it_names_not_literally() {
let fs = MockFs::new();
fs.set("/repo/crates/foo", true, Some("2026-06-01T00:00:00Z"));
let out = check_paths_staleness(
&["crates/foo/*".to_string()],
"2026-07-01T00:00:00Z",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert_eq!(
out.paths[0].status,
PathStatus::Fresh,
"a glob over an existing, untouched directory is not missing"
);
assert!(!out.is_stale);
}
#[test]
fn an_absolute_glob_into_another_repo_that_exists_is_not_missing() {
#[cfg(windows)]
let (dir, pattern) = ("C:/ai_agent/edda/crates", "C:/ai_agent/edda/crates/*");
#[cfg(not(windows))]
let (dir, pattern) = ("/ai_agent/edda/crates", "/ai_agent/edda/crates/*");
let fs = MockFs::new();
fs.set(dir, true, Some("2026-06-01T00:00:00Z"));
let out = check_paths_staleness(
&[pattern.to_string()],
"2026-07-01T00:00:00Z",
Some(Path::new("/some/other/repo")),
&fs,
)
.unwrap();
assert_eq!(out.paths[0].status, PathStatus::Fresh);
}
#[test]
fn a_glob_whose_directory_is_gone_still_reports_missing() {
let fs = MockFs::new();
let out = check_paths_staleness(
&["crates/deleted/*".to_string()],
"2026-07-01T00:00:00Z",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert_eq!(out.paths[0].status, PathStatus::Missing);
assert!(out.is_stale);
}
#[test]
fn path_modified_after_decision_is_stale_modified() {
let fs = MockFs::new();
fs.set("/repo/src/foo.rs", true, Some("2026-07-05T10:00:00Z"));
let out = check_paths_staleness(
&["src/foo.rs".to_string()],
"2026-07-01T00:00:00Z",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert!(out.is_stale);
assert_eq!(out.paths[0].status, PathStatus::StaleModified);
}
#[test]
fn path_untouched_since_decision_is_fresh() {
let fs = MockFs::new();
fs.set("/repo/src/bar.rs", true, Some("2026-06-01T00:00:00Z"));
let out = check_paths_staleness(
&["src/bar.rs".to_string()],
"2026-07-01T00:00:00Z",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert!(!out.is_stale);
assert_eq!(out.paths[0].status, PathStatus::Fresh);
}
#[test]
fn missing_path_is_stale_missing() {
let fs = MockFs::new();
let out = check_paths_staleness(
&["src/deleted.rs".to_string()],
"2026-07-01T00:00:00Z",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert!(out.is_stale, "missing counts as stale");
assert_eq!(out.paths[0].status, PathStatus::Missing);
}
#[test]
fn absolute_path_bypasses_repo_root() {
let fs = MockFs::new();
let abs = if cfg!(windows) {
"C:/opt/config.json"
} else {
"/opt/config.json"
};
fs.set(abs, true, Some("2026-07-05T00:00:00Z"));
let out =
check_paths_staleness(&[abs.to_string()], "2026-07-01T00:00:00Z", None, &fs).unwrap();
assert_eq!(out.paths[0].status, PathStatus::StaleModified);
}
#[test]
fn no_repo_root_and_relative_path_is_unknown_not_missing() {
let fs = MockFs::new();
let out = check_paths_staleness(
&["src/foo.rs".to_string()],
"2026-07-01T00:00:00Z",
None,
&fs,
)
.unwrap();
assert!(
!out.is_stale,
"unknown does not flip is_stale (F9-shaped restraint)"
);
assert_eq!(out.paths[0].status, PathStatus::Unknown);
}
#[test]
fn unparseable_decision_ts_marks_paths_unknown() {
let fs = MockFs::new();
fs.set("/repo/src/foo.rs", true, Some("2026-07-05T10:00:00Z"));
let out = check_paths_staleness(
&["src/foo.rs".to_string()],
"not-a-date",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert!(!out.is_stale);
assert_eq!(out.paths[0].status, PathStatus::Unknown);
}
#[test]
fn mixed_bag_is_stale_when_any_path_stale() {
let fs = MockFs::new();
fs.set("/repo/fresh.rs", true, Some("2026-06-01T00:00:00Z"));
fs.set("/repo/modified.rs", true, Some("2026-07-05T00:00:00Z"));
let out = check_paths_staleness(
&[
"fresh.rs".to_string(),
"modified.rs".to_string(),
"deleted.rs".to_string(),
],
"2026-07-01T00:00:00Z",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert!(out.is_stale);
assert_eq!(out.paths[0].status, PathStatus::Fresh);
assert_eq!(out.paths[1].status, PathStatus::StaleModified);
assert_eq!(out.paths[2].status, PathStatus::Missing);
}
#[test]
fn touched_at_carried_through_when_available() {
let fs = MockFs::new();
fs.set("/repo/a.rs", true, Some("2026-07-05T10:00:00Z"));
let out = check_paths_staleness(
&["a.rs".to_string()],
"2026-07-01T00:00:00Z",
Some(Path::new("/repo")),
&fs,
)
.unwrap();
assert_eq!(
out.paths[0].touched_at.as_deref(),
Some("2026-07-05T10:00:00Z")
);
}
}