1use 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 #[serde(skip_serializing_if = "Option::is_none")]
39 pub touched_at: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct DecisionStaleness {
44 pub is_stale: bool,
46 pub paths: Vec<PathStaleness>,
47}
48
49pub trait FsOracle {
51 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 let ts = OffsetDateTime::from(modified);
66 let rendered = ts.format(&Rfc3339).ok();
67 (true, rendered)
68 }
69}
70
71pub 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
143pub 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 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 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 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}