Skip to main content

edda_ask/
staleness.rs

1//! Decision-code staleness detection (Foundry q334 EDDA-STALENESS1).
2//!
3//! Contract: for a decision that names one or more `affected_paths`, check
4//! whether those files have been modified since the decision was recorded.
5//! Modified paths get a `stale` flag so a future agent doesn't cite a decision
6//! about code that no longer looks the way it did at decide time.
7//!
8//! Deterministic (mtime-based). Ledger stays untouched — staleness is a
9//! query-time derivation, not a mutation. Best-effort: unreadable repo /
10//! missing file returns "unknown" rather than false-positive stale.
11//!
12//! Paths are recorded as globs (`edda claim --paths "crates/foo/*"`), so what
13//! gets probed is the deepest wildcard-free ancestor — the area the decision
14//! governs — not the pattern itself, which resolves to nothing. See
15//! [`probe_target`]. Literal paths are probed exactly as written.
16//!
17//! **What a glob can and cannot detect (GH-405).** Probing the directory means a
18//! glob-scoped decision detects entries being *added or removed*, but not a file
19//! inside being *edited* — a directory's mtime does not move when its contents
20//! are rewritten in place. So for globs the contract above is coarser than it
21//! reads: the most common way a decision goes stale is invisible.
22//!
23//! That is a deliberate trade, and it sits with this module's stated preference
24//! for false-negatives over false-positives: before, a glob resolved to nothing
25//! and every such decision was reported `missing`, which trains readers to
26//! ignore the hint and destroys it for the paths that really are stale. A hint
27//! that rarely fires beats one that always lies. Detecting edits properly means
28//! enumerating the glob's matches and taking their newest mtime, which needs an
29//! [`FsOracle`] that can walk a directory rather than only probe one path.
30//!
31//! Vocabulary alignment:
32//! - `fresh`: the probed path exists and its mtime is at or before the decision ts.
33//! - `stale_modified`: it exists but its mtime is strictly after decision ts.
34//! - `missing`: it does not resolve on disk (repo-relative or absolute).
35//! - `unknown`: repo root not supplied, or path attributes unreadable.
36
37use 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    /// mtime as ISO 8601 (RFC 3339) when known; otherwise absent.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub touched_at: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct DecisionStaleness {
63    /// Overall stale flag: any path stale_modified OR missing.
64    pub is_stale: bool,
65    pub paths: Vec<PathStaleness>,
66}
67
68/// What to actually probe on disk for a recorded path pattern.
69///
70/// Scopes are recorded as globs — `edda claim --paths "crates/foo/*"` is the
71/// documented form — and a glob cannot be probed literally, because nothing is
72/// named `*`. Doing so reported every such decision as `Missing` regardless of
73/// the truth (GH-405), which trains readers to ignore the hint and so destroys
74/// it for genuinely stale paths.
75///
76/// The deepest wildcard-free ancestor is what the hint is really asserting:
77/// "the area this decision governs still exists". `crates/foo/*` probes
78/// `crates/foo`; `a/*/c.rs` probes `a`. A pattern with no wildcard is returned
79/// unchanged, so literal paths keep their exact previous behaviour — including
80/// per-file mtime.
81fn 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    // An all-wildcard pattern (`*`) leaves nothing to probe; keep it literal so
91    // it resolves against the repo root rather than silently becoming "".
92    if out.as_os_str().is_empty() {
93        return pattern.to_path_buf();
94    }
95    out
96}
97
98/// Whether a recorded path is a glob (has a wildcard) rather than a literal
99/// file. Same characters `probe_target` splits on, so the two agree on what
100/// "glob" means.
101fn is_glob(pattern: &str) -> bool {
102    pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
103}
104
105/// The later of two optional rfc3339 timestamps. Unparseable values are ignored
106/// rather than treated as newest, keeping the module's false-negative bias: a
107/// timestamp we cannot read must not manufacture staleness.
108fn 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
123/// Most entries scanned when finding the newest mtime under a glob's directory.
124///
125/// This runs per glob-scoped decision per `edda ask`, on the interactive path,
126/// so the walk is bounded: a directory with more entries than this reports the
127/// newest among the first `MAX_GLOB_ENTRIES` it yields. Normal source
128/// directories are far smaller, so the cap only ever bites a pathological tree —
129/// where a bounded, possibly-incomplete answer beats a slow `ask`.
130const MAX_GLOB_ENTRIES: usize = 512;
131
132/// Filesystem oracle abstracts real fs so tests can be deterministic.
133pub trait FsOracle {
134    /// Return (exists, mtime_rfc3339 when available).
135    fn probe(&self, path: &Path) -> (bool, Option<String>);
136
137    /// Newest mtime (rfc3339) among the direct entries of `dir`, or `None` if it
138    /// is not a readable directory.
139    ///
140    /// This is what lets a glob detect an *edit*: a directory's own mtime moves
141    /// when entries are added or removed, but not when a file inside is rewritten
142    /// in place — so the freshest child, not the directory, is the signal
143    /// (GH-424). Direct entries only (one level); see [`check_paths_staleness`]
144    /// for how `**` subtree scopes are handled.
145    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        // system_time → OffsetDateTime → rfc3339
160        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
180/// Check staleness of `affected_paths` for a single decision. `repo_root`
181/// resolves relative paths; absolute paths are used as-is. Returns None when
182/// paths is empty (no staleness concept applies).
183pub 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        // Reduce a glob to the directory it names before resolving; the reported
197        // path stays the pattern the decision actually recorded (GH-405).
198        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        // A glob's directory-mtime moves on add/remove but not on an in-place
230        // edit, so the freshest *entry* is the real signal — take the newer of
231        // the directory and its newest child (GH-424). A literal path names one
232        // file and uses its own mtime, untouched. Only direct children are
233        // walked, so a `**` subtree scope still misses edits nested deeper than
234        // one level; that is a narrower promise than `**` implies, but a fully
235        // kept one for the common `*` case, and it never lies.
236        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
268/// Convenience: annotate an in-memory DecisionHit list with staleness using
269/// the real filesystem. Silent no-op when repo_root is None.
270pub 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        // path key → (exists, mtime rfc3339)
289        entries: RefCell<HashMap<String, (bool, Option<String>)>>,
290        // dir key → newest child mtime rfc3339 (drives newest_mtime_under)
291        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        /// Record the newest mtime among a directory's children, as if a file
306        /// inside it had been edited at that time.
307        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    /// GH-405: `edda claim --paths "crates/foo/*"` is the documented way to
336    /// record a decision's scope, so globs are the norm rather than an edge case.
337    /// Probing one literally can only ever fail — nothing is named `*` — so every
338    /// such decision carried a false `(Missing)`, which trains readers to ignore
339    /// the hint and kills it for genuinely stale paths.
340    /// The point of GH-424: a file *edited* inside a glob scope, after the
341    /// decision, must report `StaleModified` — even though the directory's own
342    /// mtime did not move, because an in-place rewrite does not touch a dir's
343    /// mtime. Before this, the directory looked untouched and the edit was
344    /// invisible.
345    #[test]
346    fn a_glob_detects_a_file_edited_inside_it() {
347        let fs = MockFs::new();
348        // The directory exists and its own mtime PREDATES the decision — no
349        // entries were added or removed, so a directory-mtime check alone would
350        // call this Fresh.
351        fs.set("/repo/crates/foo", true, Some("2026-07-01T00:00:00Z"));
352        // But a file inside was rewritten AFTER the decision.
353        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", // decision recorded between the two
358            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    /// The MockFs tests prove the staleness *logic*; this proves `StdFs`
371    /// actually reads a real directory — a `read_dir` that returned nothing
372    /// would leave the whole feature inert while every MockFs test stayed green.
373    /// Asserts presence, not an exact time, so there is no clock race.
374    #[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    /// A literal path (no wildcard) must not enumerate — it names one file, and
399    /// its own mtime is the whole answer. Guards against the glob path leaking
400    /// into the literal case.
401    #[test]
402    fn a_literal_path_uses_its_own_mtime_not_its_siblings() {
403        let fs = MockFs::new();
404        // The named file is old; a sibling in the same dir is new. A literal
405        // scope must report Fresh — the sibling is not in scope.
406        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        // The directory the glob names exists and predates the decision. Note
428        // nothing is registered for the literal "*" path — that is the point.
429        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    /// The same defect, and the form the issue actually observed: an absolute
448    /// glob into a sibling repo that is present on this machine.
449    #[test]
450    fn an_absolute_glob_into_another_repo_that_exists_is_not_missing() {
451        // The pattern must match the platform. `Path::is_absolute` only counts a
452        // drive letter as absolute on Windows — on Unix `C:/x` is a *relative*
453        // path, so a hardcoded drive letter would quietly exercise the repo-root
454        // branch instead of the absolute one, testing nothing it claims to. CI
455        // caught this; a Windows-only run cannot.
456        #[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    /// The signal must survive the fix: a real deletion still warns.
476    #[test]
477    fn a_glob_whose_directory_is_gone_still_reports_missing() {
478        let fs = MockFs::new(); // nothing exists
479
480        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        // no entry ⇒ !exists
526        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        // Use OS-appropriate absolute path so Path::is_absolute agrees.
541        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}