1use crate::DecisionHit;
38use serde::Serialize;
39use std::path::{Path, PathBuf};
40use time::format_description::well_known::Rfc3339;
41use time::OffsetDateTime;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum PathStatus {
46 Fresh,
47 StaleModified,
48 Missing,
49 Unknown,
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct PathStaleness {
54 pub path: String,
55 pub status: PathStatus,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub touched_at: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct DecisionStaleness {
63 pub is_stale: bool,
65 pub paths: Vec<PathStaleness>,
66}
67
68fn probe_target(pattern: &Path) -> PathBuf {
82 let mut out = PathBuf::new();
83 for comp in pattern.components() {
84 let text = comp.as_os_str().to_string_lossy();
85 if text.contains('*') || text.contains('?') || text.contains('[') {
86 break;
87 }
88 out.push(comp);
89 }
90 if out.as_os_str().is_empty() {
93 return pattern.to_path_buf();
94 }
95 out
96}
97
98fn is_glob(pattern: &str) -> bool {
102 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
103}
104
105fn newer_of(a: Option<String>, b: Option<String>) -> Option<String> {
109 let parse = |s: &str| OffsetDateTime::parse(s, &Rfc3339).ok();
110 match (a, b) {
111 (Some(x), Some(y)) => match (parse(&x), parse(&y)) {
112 (Some(dx), Some(dy)) => Some(if dy > dx { y } else { x }),
113 (Some(_), None) => Some(x),
114 (None, Some(_)) => Some(y),
115 (None, None) => Some(x),
116 },
117 (Some(x), None) => Some(x),
118 (None, Some(y)) => Some(y),
119 (None, None) => None,
120 }
121}
122
123const MAX_GLOB_ENTRIES: usize = 512;
131
132pub trait FsOracle {
134 fn probe(&self, path: &Path) -> (bool, Option<String>);
136
137 fn newest_mtime_under(&self, _dir: &Path) -> Option<String> {
146 None
147 }
148}
149
150pub struct StdFs;
151impl FsOracle for StdFs {
152 fn probe(&self, path: &Path) -> (bool, Option<String>) {
153 let Ok(meta) = std::fs::metadata(path) else {
154 return (false, None);
155 };
156 let Ok(modified) = meta.modified() else {
157 return (true, None);
158 };
159 let ts = OffsetDateTime::from(modified);
161 let rendered = ts.format(&Rfc3339).ok();
162 (true, rendered)
163 }
164
165 fn newest_mtime_under(&self, dir: &Path) -> Option<String> {
166 let rd = std::fs::read_dir(dir).ok()?;
167 let mut newest: Option<OffsetDateTime> = None;
168 for entry in rd.flatten().take(MAX_GLOB_ENTRIES) {
169 let Ok(meta) = entry.metadata() else { continue };
170 let Ok(modified) = meta.modified() else {
171 continue;
172 };
173 let ts = OffsetDateTime::from(modified);
174 newest = Some(newest.map_or(ts, |n| n.max(ts)));
175 }
176 newest.and_then(|t| t.format(&Rfc3339).ok())
177 }
178}
179
180pub fn check_paths_staleness<F: FsOracle>(
184 affected_paths: &[String],
185 decision_ts: &str,
186 repo_root: Option<&Path>,
187 fs: &F,
188) -> Option<DecisionStaleness> {
189 if affected_paths.is_empty() {
190 return None;
191 }
192 let decision_dt = OffsetDateTime::parse(decision_ts, &Rfc3339).ok();
193 let mut out = Vec::with_capacity(affected_paths.len());
194 let mut any_stale = false;
195 for rel in affected_paths {
196 let pattern = probe_target(Path::new(rel));
199 let resolved: PathBuf = {
200 let p = pattern.as_path();
201 if p.is_absolute() {
202 p.to_path_buf()
203 } else {
204 match repo_root {
205 Some(root) => root.join(p),
206 None => {
207 out.push(PathStaleness {
208 path: rel.clone(),
209 status: PathStatus::Unknown,
210 touched_at: None,
211 });
212 continue;
213 }
214 }
215 }
216 };
217
218 let (exists, dir_mtime) = fs.probe(&resolved);
219 if !exists {
220 any_stale = true;
221 out.push(PathStaleness {
222 path: rel.clone(),
223 status: PathStatus::Missing,
224 touched_at: dir_mtime,
225 });
226 continue;
227 }
228
229 let touched_at = if is_glob(rel) {
237 newer_of(dir_mtime, fs.newest_mtime_under(&resolved))
238 } else {
239 dir_mtime
240 };
241
242 let status = match (&touched_at, &decision_dt) {
243 (Some(t), Some(dt)) => match OffsetDateTime::parse(t, &Rfc3339) {
244 Ok(mtime) => {
245 if mtime > *dt {
246 any_stale = true;
247 PathStatus::StaleModified
248 } else {
249 PathStatus::Fresh
250 }
251 }
252 Err(_) => PathStatus::Unknown,
253 },
254 _ => PathStatus::Unknown,
255 };
256 out.push(PathStaleness {
257 path: rel.clone(),
258 status,
259 touched_at,
260 });
261 }
262 Some(DecisionStaleness {
263 is_stale: any_stale,
264 paths: out,
265 })
266}
267
268pub fn annotate_hits(
271 hits: &mut [DecisionHit],
272 hits_paths: &[Vec<String>],
273 repo_root: Option<&Path>,
274) {
275 let fs = StdFs;
276 for (hit, paths) in hits.iter_mut().zip(hits_paths.iter()) {
277 hit.staleness = check_paths_staleness(paths, &hit.ts, repo_root, &fs);
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use std::cell::RefCell;
285 use std::collections::HashMap;
286
287 struct MockFs {
288 entries: RefCell<HashMap<String, (bool, Option<String>)>>,
290 newest: RefCell<HashMap<String, Option<String>>>,
292 }
293 impl MockFs {
294 fn new() -> Self {
295 Self {
296 entries: RefCell::new(HashMap::new()),
297 newest: RefCell::new(HashMap::new()),
298 }
299 }
300 fn set(&self, path: &str, exists: bool, mtime: Option<&str>) {
301 self.entries
302 .borrow_mut()
303 .insert(path.to_string(), (exists, mtime.map(String::from)));
304 }
305 fn set_newest_under(&self, dir: &str, mtime: Option<&str>) {
308 self.newest
309 .borrow_mut()
310 .insert(dir.to_string(), mtime.map(String::from));
311 }
312 }
313 impl FsOracle for MockFs {
314 fn probe(&self, path: &Path) -> (bool, Option<String>) {
315 let key = path.to_string_lossy().replace('\\', "/");
316 self.entries
317 .borrow()
318 .get(&key)
319 .cloned()
320 .unwrap_or((false, None))
321 }
322 fn newest_mtime_under(&self, dir: &Path) -> Option<String> {
323 let key = dir.to_string_lossy().replace('\\', "/");
324 self.newest.borrow().get(&key).cloned().flatten()
325 }
326 }
327
328 #[test]
329 fn empty_paths_returns_none() {
330 let fs = MockFs::new();
331 let out = check_paths_staleness(&[], "2026-07-01T00:00:00Z", None, &fs);
332 assert!(out.is_none());
333 }
334
335 #[test]
346 fn a_glob_detects_a_file_edited_inside_it() {
347 let fs = MockFs::new();
348 fs.set("/repo/crates/foo", true, Some("2026-07-01T00:00:00Z"));
352 fs.set_newest_under("/repo/crates/foo", Some("2026-07-10T00:00:00Z"));
354
355 let out = check_paths_staleness(
356 &["crates/foo/*".to_string()],
357 "2026-07-05T00:00:00Z", Some(Path::new("/repo")),
359 &fs,
360 )
361 .unwrap();
362
363 assert!(
364 out.is_stale,
365 "an edit inside the glob, after the decision, must be stale: {out:?}"
366 );
367 assert_eq!(out.paths[0].status, PathStatus::StaleModified);
368 }
369
370 #[test]
375 fn stdfs_newest_mtime_reads_a_real_directory() {
376 let dir = std::env::temp_dir().join(format!("edda_glob_stdfs_{}", std::process::id()));
377 let _ = std::fs::remove_dir_all(&dir);
378 std::fs::create_dir_all(&dir).unwrap();
379 std::fs::write(dir.join("a.rs"), b"x").unwrap();
380
381 let fs = StdFs;
382 assert!(
383 fs.newest_mtime_under(&dir).is_some(),
384 "a real directory with an entry must yield a newest mtime"
385 );
386 assert!(
387 fs.newest_mtime_under(&dir.join("missing")).is_none(),
388 "a path that is not a readable directory yields None"
389 );
390 assert!(
391 fs.newest_mtime_under(&dir.join("a.rs")).is_none(),
392 "a plain file is not a directory to walk"
393 );
394
395 let _ = std::fs::remove_dir_all(&dir);
396 }
397
398 #[test]
402 fn a_literal_path_uses_its_own_mtime_not_its_siblings() {
403 let fs = MockFs::new();
404 fs.set("/repo/src/main.rs", true, Some("2026-07-01T00:00:00Z"));
407 fs.set_newest_under("/repo/src", Some("2026-07-10T00:00:00Z"));
408
409 let out = check_paths_staleness(
410 &["src/main.rs".to_string()],
411 "2026-07-05T00:00:00Z",
412 Some(Path::new("/repo")),
413 &fs,
414 )
415 .unwrap();
416
417 assert_eq!(
418 out.paths[0].status,
419 PathStatus::Fresh,
420 "a literal path must ignore its siblings' edits: {out:?}"
421 );
422 }
423
424 #[test]
425 fn a_glob_is_checked_against_the_directory_it_names_not_literally() {
426 let fs = MockFs::new();
427 fs.set("/repo/crates/foo", true, Some("2026-06-01T00:00:00Z"));
430
431 let out = check_paths_staleness(
432 &["crates/foo/*".to_string()],
433 "2026-07-01T00:00:00Z",
434 Some(Path::new("/repo")),
435 &fs,
436 )
437 .unwrap();
438
439 assert_eq!(
440 out.paths[0].status,
441 PathStatus::Fresh,
442 "a glob over an existing, untouched directory is not missing"
443 );
444 assert!(!out.is_stale);
445 }
446
447 #[test]
450 fn an_absolute_glob_into_another_repo_that_exists_is_not_missing() {
451 #[cfg(windows)]
457 let (dir, pattern) = ("C:/ai_agent/edda/crates", "C:/ai_agent/edda/crates/*");
458 #[cfg(not(windows))]
459 let (dir, pattern) = ("/ai_agent/edda/crates", "/ai_agent/edda/crates/*");
460
461 let fs = MockFs::new();
462 fs.set(dir, true, Some("2026-06-01T00:00:00Z"));
463
464 let out = check_paths_staleness(
465 &[pattern.to_string()],
466 "2026-07-01T00:00:00Z",
467 Some(Path::new("/some/other/repo")),
468 &fs,
469 )
470 .unwrap();
471
472 assert_eq!(out.paths[0].status, PathStatus::Fresh);
473 }
474
475 #[test]
477 fn a_glob_whose_directory_is_gone_still_reports_missing() {
478 let fs = MockFs::new(); let out = check_paths_staleness(
481 &["crates/deleted/*".to_string()],
482 "2026-07-01T00:00:00Z",
483 Some(Path::new("/repo")),
484 &fs,
485 )
486 .unwrap();
487
488 assert_eq!(out.paths[0].status, PathStatus::Missing);
489 assert!(out.is_stale);
490 }
491
492 #[test]
493 fn path_modified_after_decision_is_stale_modified() {
494 let fs = MockFs::new();
495 fs.set("/repo/src/foo.rs", true, Some("2026-07-05T10:00:00Z"));
496 let out = check_paths_staleness(
497 &["src/foo.rs".to_string()],
498 "2026-07-01T00:00:00Z",
499 Some(Path::new("/repo")),
500 &fs,
501 )
502 .unwrap();
503 assert!(out.is_stale);
504 assert_eq!(out.paths[0].status, PathStatus::StaleModified);
505 }
506
507 #[test]
508 fn path_untouched_since_decision_is_fresh() {
509 let fs = MockFs::new();
510 fs.set("/repo/src/bar.rs", true, Some("2026-06-01T00:00:00Z"));
511 let out = check_paths_staleness(
512 &["src/bar.rs".to_string()],
513 "2026-07-01T00:00:00Z",
514 Some(Path::new("/repo")),
515 &fs,
516 )
517 .unwrap();
518 assert!(!out.is_stale);
519 assert_eq!(out.paths[0].status, PathStatus::Fresh);
520 }
521
522 #[test]
523 fn missing_path_is_stale_missing() {
524 let fs = MockFs::new();
525 let out = check_paths_staleness(
527 &["src/deleted.rs".to_string()],
528 "2026-07-01T00:00:00Z",
529 Some(Path::new("/repo")),
530 &fs,
531 )
532 .unwrap();
533 assert!(out.is_stale, "missing counts as stale");
534 assert_eq!(out.paths[0].status, PathStatus::Missing);
535 }
536
537 #[test]
538 fn absolute_path_bypasses_repo_root() {
539 let fs = MockFs::new();
540 let abs = if cfg!(windows) {
542 "C:/opt/config.json"
543 } else {
544 "/opt/config.json"
545 };
546 fs.set(abs, true, Some("2026-07-05T00:00:00Z"));
547 let out =
548 check_paths_staleness(&[abs.to_string()], "2026-07-01T00:00:00Z", None, &fs).unwrap();
549 assert_eq!(out.paths[0].status, PathStatus::StaleModified);
550 }
551
552 #[test]
553 fn no_repo_root_and_relative_path_is_unknown_not_missing() {
554 let fs = MockFs::new();
555 let out = check_paths_staleness(
556 &["src/foo.rs".to_string()],
557 "2026-07-01T00:00:00Z",
558 None,
559 &fs,
560 )
561 .unwrap();
562 assert!(
563 !out.is_stale,
564 "unknown does not flip is_stale (F9-shaped restraint)"
565 );
566 assert_eq!(out.paths[0].status, PathStatus::Unknown);
567 }
568
569 #[test]
570 fn unparseable_decision_ts_marks_paths_unknown() {
571 let fs = MockFs::new();
572 fs.set("/repo/src/foo.rs", true, Some("2026-07-05T10:00:00Z"));
573 let out = check_paths_staleness(
574 &["src/foo.rs".to_string()],
575 "not-a-date",
576 Some(Path::new("/repo")),
577 &fs,
578 )
579 .unwrap();
580 assert!(!out.is_stale);
581 assert_eq!(out.paths[0].status, PathStatus::Unknown);
582 }
583
584 #[test]
585 fn mixed_bag_is_stale_when_any_path_stale() {
586 let fs = MockFs::new();
587 fs.set("/repo/fresh.rs", true, Some("2026-06-01T00:00:00Z"));
588 fs.set("/repo/modified.rs", true, Some("2026-07-05T00:00:00Z"));
589 let out = check_paths_staleness(
590 &[
591 "fresh.rs".to_string(),
592 "modified.rs".to_string(),
593 "deleted.rs".to_string(),
594 ],
595 "2026-07-01T00:00:00Z",
596 Some(Path::new("/repo")),
597 &fs,
598 )
599 .unwrap();
600 assert!(out.is_stale);
601 assert_eq!(out.paths[0].status, PathStatus::Fresh);
602 assert_eq!(out.paths[1].status, PathStatus::StaleModified);
603 assert_eq!(out.paths[2].status, PathStatus::Missing);
604 }
605
606 #[test]
607 fn touched_at_carried_through_when_available() {
608 let fs = MockFs::new();
609 fs.set("/repo/a.rs", true, Some("2026-07-05T10:00:00Z"));
610 let out = check_paths_staleness(
611 &["a.rs".to_string()],
612 "2026-07-01T00:00:00Z",
613 Some(Path::new("/repo")),
614 &fs,
615 )
616 .unwrap();
617 assert_eq!(
618 out.paths[0].touched_at.as_deref(),
619 Some("2026-07-05T10:00:00Z")
620 );
621 }
622}