spec_driven_docs/services/
tracking.rs1use camino::{Utf8Path, Utf8PathBuf};
11use jiff::civil::Date;
12
13use crate::adapters::fs::{DestinationRefusal, check_destination};
14use crate::domain::rule_id::RuleId;
15use crate::domain::tracking::{self, Entry, TrackingError};
16
17#[must_use]
19pub fn registry_path(docs_root: &Utf8Path) -> Utf8PathBuf {
20 docs_root.join("reference/tracking.yaml")
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Freshness {
26 Current,
28 Overdue(i64),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Problem {
35 pub rule: RuleId,
37 pub detail: String,
39}
40
41#[derive(Debug, Clone)]
43pub struct Assessed {
44 pub entry: Entry,
46 pub next_check: Option<Date>,
48 pub freshness: Option<Freshness>,
50 pub problems: Vec<Problem>,
52}
53
54#[derive(Debug)]
56pub struct Report {
57 pub entries: Vec<Assessed>,
59 pub fatal: Option<Problem>,
61}
62
63impl Report {
64 #[must_use]
66 pub fn has_failures(&self) -> bool {
67 self.fatal.is_some()
68 || self.entries.iter().any(|a| {
69 !a.problems.is_empty() || matches!(a.freshness, Some(Freshness::Overdue(_)))
70 })
71 }
72}
73
74#[must_use]
76pub fn today_utc() -> Date {
77 jiff::Timestamp::now()
78 .to_zoned(jiff::tz::TimeZone::UTC)
79 .date()
80}
81
82const fn rule_for(error: &TrackingError) -> RuleId {
83 match error {
84 TrackingError::Bounds(_) | TrackingError::Shape(_) => RuleId::RegistryHasOneReadableShape,
85 TrackingError::Semantic(_) => RuleId::PerishableSourceIsRegistered,
86 }
87}
88
89fn resolvable(root: &Utf8Path, relative: &str) -> Result<bool, String> {
91 if relative.starts_with('/') {
92 return Err("path is absolute".to_string());
93 }
94 if relative == ".."
95 || relative.starts_with("../")
96 || relative.contains("/../")
97 || relative.ends_with("/..")
98 {
99 return Err("path escapes the tree with '..'".to_string());
100 }
101 match check_destination(root, Utf8Path::new(relative)) {
102 Ok(()) => Ok(root.join(relative).is_file()),
103 Err(DestinationRefusal::SymlinkEscape) => {
104 Err("path is reached through a symlink".to_string())
105 }
106 Err(DestinationRefusal::FileBlocksDirectory(_) | DestinationRefusal::NotARegularFile) => {
107 Ok(root.join(relative).is_file())
108 }
109 }
110}
111
112fn assess_entry(root: &Utf8Path, entry: &Entry, as_of: Date) -> Assessed {
113 let mut problems = Vec::new();
114
115 for (label, path) in std::iter::once(("path", &entry.path))
116 .chain(entry.dependents.iter().map(|d| ("dependent", d)))
117 {
118 match resolvable(root, path) {
119 Ok(true) => {}
120 Ok(false) => problems.push(Problem {
121 rule: RuleId::DeclaredDependentExists,
122 detail: format!("entry '{}': {label} '{path}' names no file", entry.id),
123 }),
124 Err(reason) => problems.push(Problem {
125 rule: RuleId::DeclaredDependentExists,
126 detail: format!("entry '{}': {label} '{path}' {reason}", entry.id),
127 }),
128 }
129 }
130
131 if entry.revalidate.is_empty() {
132 problems.push(Problem {
133 rule: RuleId::EntryDeclaresHowToRevalidate,
134 detail: format!("entry '{}': revalidate lists no step", entry.id),
135 });
136 }
137
138 let (next_check, freshness) = if let Ok(last) = entry.last_checked.parse::<Date>() {
139 let next = last
140 .checked_add(jiff::Span::new().days(i64::from(entry.cadence_days)))
141 .unwrap_or(last);
142 let freshness = if next >= as_of {
143 Freshness::Current
144 } else {
145 Freshness::Overdue((as_of - next).get_days().into())
146 };
147 if let Freshness::Overdue(_) = freshness {
148 problems.push(Problem {
149 rule: RuleId::OverdueEntryBlocks,
150 detail: format!(
151 "entry '{}' was due {next}; revalidate it, then advance last_checked. Steps: {}",
152 entry.id,
153 entry.revalidate.join("; ")
154 ),
155 });
156 }
157 (Some(next), Some(freshness))
158 } else {
159 problems.push(Problem {
160 rule: RuleId::PerishableSourceIsRegistered,
161 detail: format!(
162 "entry '{}': last_checked '{}' is not an ISO date",
163 entry.id, entry.last_checked
164 ),
165 });
166 (None, None)
167 };
168
169 Assessed {
170 entry: entry.clone(),
171 next_check,
172 freshness,
173 problems,
174 }
175}
176
177pub fn evaluate(root: &Utf8Path, docs_root: &Utf8Path, as_of: Date) -> std::io::Result<Report> {
183 let path = root.join(registry_path(docs_root));
184 let text = std::fs::read_to_string(&path)?;
185 match tracking::parse(&text) {
186 Ok(registry) => {
187 let entries = registry
188 .tracked
189 .iter()
190 .map(|entry| assess_entry(root, entry, as_of))
191 .collect();
192 Ok(Report {
193 entries,
194 fatal: None,
195 })
196 }
197 Err(error) => Ok(Report {
198 entries: Vec::new(),
199 fatal: Some(Problem {
200 rule: rule_for(&error),
201 detail: error.to_string(),
202 }),
203 }),
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn write(dir: &tempfile::TempDir, rel: &str, text: &str) {
212 let p = dir.path().join(rel);
213 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
214 std::fs::write(p, text).unwrap();
215 }
216
217 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
218 Utf8PathBuf::from(dir.path().to_str().unwrap())
219 }
220
221 fn registry(dependent: &str, last_checked: &str) -> String {
222 format!(
223 "schema_version: 1\ntracked:\n - id: sample\n path: _docs/reference/x.md\n last_checked: {last_checked}\n cadence_days: 30\n why: it moves\n revalidate:\n - re-fetch it\n dependents:\n - {dependent}\n"
224 )
225 }
226
227 #[test]
228 fn a_current_entry_has_no_problem() {
229 let dir = tempfile::tempdir().unwrap();
230 write(
231 &dir,
232 "_docs/reference/tracking.yaml",
233 ®istry("_docs/guide.md", "2026-08-20"),
234 );
235 write(&dir, "_docs/reference/x.md", "# x\n");
236 write(&dir, "_docs/guide.md", "# g\n");
237 let report = evaluate(
238 &root(&dir),
239 Utf8Path::new("_docs"),
240 "2026-09-01".parse().unwrap(),
241 )
242 .unwrap();
243 assert!(!report.has_failures());
244 assert!(matches!(
245 report.entries[0].freshness,
246 Some(Freshness::Current)
247 ));
248 }
249
250 #[test]
251 fn an_overdue_entry_fails_with_the_due_date() {
252 let dir = tempfile::tempdir().unwrap();
253 write(
254 &dir,
255 "_docs/reference/tracking.yaml",
256 ®istry("_docs/guide.md", "2026-01-01"),
257 );
258 write(&dir, "_docs/reference/x.md", "# x\n");
259 write(&dir, "_docs/guide.md", "# g\n");
260 let report = evaluate(
261 &root(&dir),
262 Utf8Path::new("_docs"),
263 "2026-09-01".parse().unwrap(),
264 )
265 .unwrap();
266 assert!(report.has_failures());
267 let overdue = &report.entries[0];
268 assert!(matches!(overdue.freshness, Some(Freshness::Overdue(_))));
269 assert!(
270 overdue
271 .problems
272 .iter()
273 .any(|p| p.rule == RuleId::OverdueEntryBlocks)
274 );
275 assert!(
276 overdue
277 .problems
278 .iter()
279 .any(|p| p.detail.contains("2026-01-31"))
280 );
281 }
282
283 #[test]
284 fn a_missing_dependent_fails() {
285 let dir = tempfile::tempdir().unwrap();
286 write(
287 &dir,
288 "_docs/reference/tracking.yaml",
289 ®istry("_docs/gone.md", "2026-08-25"),
290 );
291 write(&dir, "_docs/reference/x.md", "# x\n");
292 let report = evaluate(
293 &root(&dir),
294 Utf8Path::new("_docs"),
295 "2026-09-01".parse().unwrap(),
296 )
297 .unwrap();
298 assert!(report.has_failures());
299 assert!(
300 report.entries[0]
301 .problems
302 .iter()
303 .any(|p| p.rule == RuleId::DeclaredDependentExists && p.detail.contains("gone.md"))
304 );
305 }
306
307 #[test]
308 fn the_exact_due_boundary_is_current() {
309 let dir = tempfile::tempdir().unwrap();
310 write(
312 &dir,
313 "_docs/reference/tracking.yaml",
314 ®istry("_docs/guide.md", "2026-01-01"),
315 );
316 write(&dir, "_docs/reference/x.md", "# x\n");
317 write(&dir, "_docs/guide.md", "# g\n");
318 let report = evaluate(
319 &root(&dir),
320 Utf8Path::new("_docs"),
321 "2026-01-31".parse().unwrap(),
322 )
323 .unwrap();
324 assert!(matches!(
325 report.entries[0].freshness,
326 Some(Freshness::Current)
327 ));
328 let report = evaluate(
329 &root(&dir),
330 Utf8Path::new("_docs"),
331 "2026-02-01".parse().unwrap(),
332 )
333 .unwrap();
334 assert!(matches!(
335 report.entries[0].freshness,
336 Some(Freshness::Overdue(1))
337 ));
338 }
339
340 #[test]
341 fn a_broken_registry_reports_one_fatal() {
342 let dir = tempfile::tempdir().unwrap();
343 write(
344 &dir,
345 "_docs/reference/tracking.yaml",
346 "schema_version: 2\ntracked: []\n",
347 );
348 let report = evaluate(&root(&dir), Utf8Path::new("_docs"), today_utc()).unwrap();
349 assert!(report.fatal.is_some());
350 assert!(report.has_failures());
351 }
352}