Skip to main content

spec_driven_docs/services/
tracking.rs

1//! Offline tracking-registry evaluation.
2//!
3//! Locate `<docs_root>/reference/tracking.yaml`, parse and bound it through
4//! `domain::tracking`, resolve every declared path without a `..` or a
5//! symlink escape, and classify each entry as current, due, or overdue
6//! against a clock the caller supplies. Everything here is local: no network.
7//! Comparing a pinned revision to its upstream is `commands::track`'s job,
8//! over an explicit network boundary.
9
10use 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/// Where the registry lives, relative to the repository root.
18#[must_use]
19pub fn registry_path(docs_root: &Utf8Path) -> Utf8PathBuf {
20    docs_root.join("reference/tracking.yaml")
21}
22
23/// How an entry stands against the clock.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Freshness {
26    /// The next check is in the future.
27    Current,
28    /// The next check is today or earlier by this many days (0 means today).
29    Overdue(i64),
30}
31
32/// One problem found in an entry, addressed to the rule it breaks.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Problem {
35    /// The rule the problem breaks.
36    pub rule: RuleId,
37    /// What is wrong, stated for the reader who will fix it.
38    pub detail: String,
39}
40
41/// One evaluated entry.
42#[derive(Debug, Clone)]
43pub struct Assessed {
44    /// The entry as parsed.
45    pub entry: Entry,
46    /// The date the next check falls due, when the date parsed.
47    pub next_check: Option<Date>,
48    /// How the entry stands against the clock, when the date parsed.
49    pub freshness: Option<Freshness>,
50    /// Every problem the entry carries.
51    pub problems: Vec<Problem>,
52}
53
54/// A whole-registry evaluation.
55#[derive(Debug)]
56pub struct Report {
57    /// Every entry, evaluated.
58    pub entries: Vec<Assessed>,
59    /// A registry-level failure that stops per-entry evaluation.
60    pub fatal: Option<Problem>,
61}
62
63impl Report {
64    /// Whether the registry has any problem or overdue entry.
65    #[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/// Today's date in UTC.
75#[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
89/// Resolve a repository-relative path for reading, refusing an escape.
90fn 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
177/// Evaluate the registry at `<docs_root>/reference/tracking.yaml`, offline.
178///
179/// # Errors
180///
181/// I/O errors when the registry file cannot be read.
182pub 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            &registry("_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            &registry("_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            &registry("_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        // last_checked + 30 days = 2026-01-31; as_of 2026-01-31 is not past it.
311        write(
312            &dir,
313            "_docs/reference/tracking.yaml",
314            &registry("_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}