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 {
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 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 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}