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//! Vocabulary alignment:
13//! - `fresh`: the path exists and its mtime is at or before the decision ts.
14//! - `stale_modified`: file exists but its mtime is strictly after decision ts.
15//! - `missing`: path does not resolve on disk (repo-relative or absolute).
16//! - `unknown`: repo root not supplied, or path attributes unreadable.
17
18use crate::DecisionHit;
19use serde::Serialize;
20use std::path::{Path, PathBuf};
21use time::format_description::well_known::Rfc3339;
22use time::OffsetDateTime;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum PathStatus {
27    Fresh,
28    StaleModified,
29    Missing,
30    Unknown,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct PathStaleness {
35    pub path: String,
36    pub status: PathStatus,
37    /// mtime as ISO 8601 (RFC 3339) when known; otherwise absent.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub touched_at: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct DecisionStaleness {
44    /// Overall stale flag: any path stale_modified OR missing.
45    pub is_stale: bool,
46    pub paths: Vec<PathStaleness>,
47}
48
49/// Filesystem oracle abstracts real fs so tests can be deterministic.
50pub trait FsOracle {
51    /// Return (exists, mtime_rfc3339 when available).
52    fn probe(&self, path: &Path) -> (bool, Option<String>);
53}
54
55pub struct StdFs;
56impl FsOracle for StdFs {
57    fn probe(&self, path: &Path) -> (bool, Option<String>) {
58        let Ok(meta) = std::fs::metadata(path) else {
59            return (false, None);
60        };
61        let Ok(modified) = meta.modified() else {
62            return (true, None);
63        };
64        // system_time → OffsetDateTime → rfc3339
65        let ts = OffsetDateTime::from(modified);
66        let rendered = ts.format(&Rfc3339).ok();
67        (true, rendered)
68    }
69}
70
71/// Check staleness of `affected_paths` for a single decision. `repo_root`
72/// resolves relative paths; absolute paths are used as-is. Returns None when
73/// paths is empty (no staleness concept applies).
74pub fn check_paths_staleness<F: FsOracle>(
75    affected_paths: &[String],
76    decision_ts: &str,
77    repo_root: Option<&Path>,
78    fs: &F,
79) -> Option<DecisionStaleness> {
80    if affected_paths.is_empty() {
81        return None;
82    }
83    let decision_dt = OffsetDateTime::parse(decision_ts, &Rfc3339).ok();
84    let mut out = Vec::with_capacity(affected_paths.len());
85    let mut any_stale = false;
86    for rel in affected_paths {
87        let resolved: PathBuf = {
88            let p = Path::new(rel);
89            if p.is_absolute() {
90                p.to_path_buf()
91            } else {
92                match repo_root {
93                    Some(root) => root.join(p),
94                    None => {
95                        out.push(PathStaleness {
96                            path: rel.clone(),
97                            status: PathStatus::Unknown,
98                            touched_at: None,
99                        });
100                        continue;
101                    }
102                }
103            }
104        };
105
106        let (exists, touched_at) = fs.probe(&resolved);
107        if !exists {
108            any_stale = true;
109            out.push(PathStaleness {
110                path: rel.clone(),
111                status: PathStatus::Missing,
112                touched_at,
113            });
114            continue;
115        }
116
117        let status = match (&touched_at, &decision_dt) {
118            (Some(t), Some(dt)) => match OffsetDateTime::parse(t, &Rfc3339) {
119                Ok(mtime) => {
120                    if mtime > *dt {
121                        any_stale = true;
122                        PathStatus::StaleModified
123                    } else {
124                        PathStatus::Fresh
125                    }
126                }
127                Err(_) => PathStatus::Unknown,
128            },
129            _ => PathStatus::Unknown,
130        };
131        out.push(PathStaleness {
132            path: rel.clone(),
133            status,
134            touched_at,
135        });
136    }
137    Some(DecisionStaleness {
138        is_stale: any_stale,
139        paths: out,
140    })
141}
142
143/// Convenience: annotate an in-memory DecisionHit list with staleness using
144/// the real filesystem. Silent no-op when repo_root is None.
145pub fn annotate_hits(
146    hits: &mut [DecisionHit],
147    hits_paths: &[Vec<String>],
148    repo_root: Option<&Path>,
149) {
150    let fs = StdFs;
151    for (hit, paths) in hits.iter_mut().zip(hits_paths.iter()) {
152        hit.staleness = check_paths_staleness(paths, &hit.ts, repo_root, &fs);
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::cell::RefCell;
160    use std::collections::HashMap;
161
162    struct MockFs {
163        // path key → (exists, mtime rfc3339)
164        entries: RefCell<HashMap<String, (bool, Option<String>)>>,
165    }
166    impl MockFs {
167        fn new() -> Self {
168            Self {
169                entries: RefCell::new(HashMap::new()),
170            }
171        }
172        fn set(&self, path: &str, exists: bool, mtime: Option<&str>) {
173            self.entries
174                .borrow_mut()
175                .insert(path.to_string(), (exists, mtime.map(String::from)));
176        }
177    }
178    impl FsOracle for MockFs {
179        fn probe(&self, path: &Path) -> (bool, Option<String>) {
180            let key = path.to_string_lossy().replace('\\', "/");
181            self.entries
182                .borrow()
183                .get(&key)
184                .cloned()
185                .unwrap_or((false, None))
186        }
187    }
188
189    #[test]
190    fn empty_paths_returns_none() {
191        let fs = MockFs::new();
192        let out = check_paths_staleness(&[], "2026-07-01T00:00:00Z", None, &fs);
193        assert!(out.is_none());
194    }
195
196    #[test]
197    fn path_modified_after_decision_is_stale_modified() {
198        let fs = MockFs::new();
199        fs.set("/repo/src/foo.rs", true, Some("2026-07-05T10:00:00Z"));
200        let out = check_paths_staleness(
201            &["src/foo.rs".to_string()],
202            "2026-07-01T00:00:00Z",
203            Some(Path::new("/repo")),
204            &fs,
205        )
206        .unwrap();
207        assert!(out.is_stale);
208        assert_eq!(out.paths[0].status, PathStatus::StaleModified);
209    }
210
211    #[test]
212    fn path_untouched_since_decision_is_fresh() {
213        let fs = MockFs::new();
214        fs.set("/repo/src/bar.rs", true, Some("2026-06-01T00:00:00Z"));
215        let out = check_paths_staleness(
216            &["src/bar.rs".to_string()],
217            "2026-07-01T00:00:00Z",
218            Some(Path::new("/repo")),
219            &fs,
220        )
221        .unwrap();
222        assert!(!out.is_stale);
223        assert_eq!(out.paths[0].status, PathStatus::Fresh);
224    }
225
226    #[test]
227    fn missing_path_is_stale_missing() {
228        let fs = MockFs::new();
229        // no entry ⇒ !exists
230        let out = check_paths_staleness(
231            &["src/deleted.rs".to_string()],
232            "2026-07-01T00:00:00Z",
233            Some(Path::new("/repo")),
234            &fs,
235        )
236        .unwrap();
237        assert!(out.is_stale, "missing counts as stale");
238        assert_eq!(out.paths[0].status, PathStatus::Missing);
239    }
240
241    #[test]
242    fn absolute_path_bypasses_repo_root() {
243        let fs = MockFs::new();
244        // Use OS-appropriate absolute path so Path::is_absolute agrees.
245        let abs = if cfg!(windows) {
246            "C:/opt/config.json"
247        } else {
248            "/opt/config.json"
249        };
250        fs.set(abs, true, Some("2026-07-05T00:00:00Z"));
251        let out =
252            check_paths_staleness(&[abs.to_string()], "2026-07-01T00:00:00Z", None, &fs).unwrap();
253        assert_eq!(out.paths[0].status, PathStatus::StaleModified);
254    }
255
256    #[test]
257    fn no_repo_root_and_relative_path_is_unknown_not_missing() {
258        let fs = MockFs::new();
259        let out = check_paths_staleness(
260            &["src/foo.rs".to_string()],
261            "2026-07-01T00:00:00Z",
262            None,
263            &fs,
264        )
265        .unwrap();
266        assert!(
267            !out.is_stale,
268            "unknown does not flip is_stale (F9-shaped restraint)"
269        );
270        assert_eq!(out.paths[0].status, PathStatus::Unknown);
271    }
272
273    #[test]
274    fn unparseable_decision_ts_marks_paths_unknown() {
275        let fs = MockFs::new();
276        fs.set("/repo/src/foo.rs", true, Some("2026-07-05T10:00:00Z"));
277        let out = check_paths_staleness(
278            &["src/foo.rs".to_string()],
279            "not-a-date",
280            Some(Path::new("/repo")),
281            &fs,
282        )
283        .unwrap();
284        assert!(!out.is_stale);
285        assert_eq!(out.paths[0].status, PathStatus::Unknown);
286    }
287
288    #[test]
289    fn mixed_bag_is_stale_when_any_path_stale() {
290        let fs = MockFs::new();
291        fs.set("/repo/fresh.rs", true, Some("2026-06-01T00:00:00Z"));
292        fs.set("/repo/modified.rs", true, Some("2026-07-05T00:00:00Z"));
293        let out = check_paths_staleness(
294            &[
295                "fresh.rs".to_string(),
296                "modified.rs".to_string(),
297                "deleted.rs".to_string(),
298            ],
299            "2026-07-01T00:00:00Z",
300            Some(Path::new("/repo")),
301            &fs,
302        )
303        .unwrap();
304        assert!(out.is_stale);
305        assert_eq!(out.paths[0].status, PathStatus::Fresh);
306        assert_eq!(out.paths[1].status, PathStatus::StaleModified);
307        assert_eq!(out.paths[2].status, PathStatus::Missing);
308    }
309
310    #[test]
311    fn touched_at_carried_through_when_available() {
312        let fs = MockFs::new();
313        fs.set("/repo/a.rs", true, Some("2026-07-05T10:00:00Z"));
314        let out = check_paths_staleness(
315            &["a.rs".to_string()],
316            "2026-07-01T00:00:00Z",
317            Some(Path::new("/repo")),
318            &fs,
319        )
320        .unwrap();
321        assert_eq!(
322            out.paths[0].touched_at.as_deref(),
323            Some("2026-07-05T10:00:00Z")
324        );
325    }
326}