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 { entries: RefCell::new(HashMap::new()) }
169        }
170        fn set(&self, path: &str, exists: bool, mtime: Option<&str>) {
171            self.entries
172                .borrow_mut()
173                .insert(path.to_string(), (exists, mtime.map(String::from)));
174        }
175    }
176    impl FsOracle for MockFs {
177        fn probe(&self, path: &Path) -> (bool, Option<String>) {
178            let key = path.to_string_lossy().replace('\\', "/");
179            self.entries
180                .borrow()
181                .get(&key)
182                .cloned()
183                .unwrap_or((false, None))
184        }
185    }
186
187    #[test]
188    fn empty_paths_returns_none() {
189        let fs = MockFs::new();
190        let out = check_paths_staleness(&[], "2026-07-01T00:00:00Z", None, &fs);
191        assert!(out.is_none());
192    }
193
194    #[test]
195    fn path_modified_after_decision_is_stale_modified() {
196        let fs = MockFs::new();
197        fs.set("/repo/src/foo.rs", true, Some("2026-07-05T10:00:00Z"));
198        let out = check_paths_staleness(
199            &["src/foo.rs".to_string()],
200            "2026-07-01T00:00:00Z",
201            Some(Path::new("/repo")),
202            &fs,
203        ).unwrap();
204        assert!(out.is_stale);
205        assert_eq!(out.paths[0].status, PathStatus::StaleModified);
206    }
207
208    #[test]
209    fn path_untouched_since_decision_is_fresh() {
210        let fs = MockFs::new();
211        fs.set("/repo/src/bar.rs", true, Some("2026-06-01T00:00:00Z"));
212        let out = check_paths_staleness(
213            &["src/bar.rs".to_string()],
214            "2026-07-01T00:00:00Z",
215            Some(Path::new("/repo")),
216            &fs,
217        ).unwrap();
218        assert!(!out.is_stale);
219        assert_eq!(out.paths[0].status, PathStatus::Fresh);
220    }
221
222    #[test]
223    fn missing_path_is_stale_missing() {
224        let fs = MockFs::new();
225        // no entry ⇒ !exists
226        let out = check_paths_staleness(
227            &["src/deleted.rs".to_string()],
228            "2026-07-01T00:00:00Z",
229            Some(Path::new("/repo")),
230            &fs,
231        ).unwrap();
232        assert!(out.is_stale, "missing counts as stale");
233        assert_eq!(out.paths[0].status, PathStatus::Missing);
234    }
235
236    #[test]
237    fn absolute_path_bypasses_repo_root() {
238        let fs = MockFs::new();
239        // Use OS-appropriate absolute path so Path::is_absolute agrees.
240        let abs = if cfg!(windows) { "C:/opt/config.json" } else { "/opt/config.json" };
241        fs.set(abs, true, Some("2026-07-05T00:00:00Z"));
242        let out = check_paths_staleness(
243            &[abs.to_string()],
244            "2026-07-01T00:00:00Z",
245            None,
246            &fs,
247        ).unwrap();
248        assert_eq!(out.paths[0].status, PathStatus::StaleModified);
249    }
250
251    #[test]
252    fn no_repo_root_and_relative_path_is_unknown_not_missing() {
253        let fs = MockFs::new();
254        let out = check_paths_staleness(
255            &["src/foo.rs".to_string()],
256            "2026-07-01T00:00:00Z",
257            None,
258            &fs,
259        ).unwrap();
260        assert!(!out.is_stale, "unknown does not flip is_stale (F9-shaped restraint)");
261        assert_eq!(out.paths[0].status, PathStatus::Unknown);
262    }
263
264    #[test]
265    fn unparseable_decision_ts_marks_paths_unknown() {
266        let fs = MockFs::new();
267        fs.set("/repo/src/foo.rs", true, Some("2026-07-05T10:00:00Z"));
268        let out = check_paths_staleness(
269            &["src/foo.rs".to_string()],
270            "not-a-date",
271            Some(Path::new("/repo")),
272            &fs,
273        ).unwrap();
274        assert!(!out.is_stale);
275        assert_eq!(out.paths[0].status, PathStatus::Unknown);
276    }
277
278    #[test]
279    fn mixed_bag_is_stale_when_any_path_stale() {
280        let fs = MockFs::new();
281        fs.set("/repo/fresh.rs", true, Some("2026-06-01T00:00:00Z"));
282        fs.set("/repo/modified.rs", true, Some("2026-07-05T00:00:00Z"));
283        let out = check_paths_staleness(
284            &["fresh.rs".to_string(), "modified.rs".to_string(), "deleted.rs".to_string()],
285            "2026-07-01T00:00:00Z",
286            Some(Path::new("/repo")),
287            &fs,
288        ).unwrap();
289        assert!(out.is_stale);
290        assert_eq!(out.paths[0].status, PathStatus::Fresh);
291        assert_eq!(out.paths[1].status, PathStatus::StaleModified);
292        assert_eq!(out.paths[2].status, PathStatus::Missing);
293    }
294
295    #[test]
296    fn touched_at_carried_through_when_available() {
297        let fs = MockFs::new();
298        fs.set("/repo/a.rs", true, Some("2026-07-05T10:00:00Z"));
299        let out = check_paths_staleness(
300            &["a.rs".to_string()],
301            "2026-07-01T00:00:00Z",
302            Some(Path::new("/repo")),
303            &fs,
304        ).unwrap();
305        assert_eq!(out.paths[0].touched_at.as_deref(), Some("2026-07-05T10:00:00Z"));
306    }
307}