use camino::{Utf8Path, Utf8PathBuf};
use jiff::civil::Date;
use crate::adapters::fs::{DestinationRefusal, check_destination};
use crate::domain::rule_id::RuleId;
use crate::domain::tracking::{self, Entry, TrackingError};
#[must_use]
pub fn registry_path(docs_root: &Utf8Path) -> Utf8PathBuf {
docs_root.join("reference/tracking.yaml")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Freshness {
Current,
Overdue(i64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Problem {
pub rule: RuleId,
pub detail: String,
}
#[derive(Debug, Clone)]
pub struct Assessed {
pub entry: Entry,
pub next_check: Option<Date>,
pub freshness: Option<Freshness>,
pub problems: Vec<Problem>,
}
#[derive(Debug)]
pub struct Report {
pub entries: Vec<Assessed>,
pub fatal: Option<Problem>,
}
impl Report {
#[must_use]
pub fn has_failures(&self) -> bool {
self.fatal.is_some()
|| self.entries.iter().any(|a| {
!a.problems.is_empty() || matches!(a.freshness, Some(Freshness::Overdue(_)))
})
}
}
#[must_use]
pub fn today_utc() -> Date {
jiff::Timestamp::now()
.to_zoned(jiff::tz::TimeZone::UTC)
.date()
}
const fn rule_for(error: &TrackingError) -> RuleId {
match error {
TrackingError::Bounds(_) | TrackingError::Shape(_) => RuleId::RegistryHasOneReadableShape,
TrackingError::Semantic(_) => RuleId::PerishableSourceIsRegistered,
}
}
fn resolvable(root: &Utf8Path, relative: &str) -> Result<bool, String> {
if relative.starts_with('/') {
return Err("path is absolute".to_string());
}
if relative == ".."
|| relative.starts_with("../")
|| relative.contains("/../")
|| relative.ends_with("/..")
{
return Err("path escapes the tree with '..'".to_string());
}
match check_destination(root, Utf8Path::new(relative)) {
Ok(()) => Ok(root.join(relative).is_file()),
Err(DestinationRefusal::SymlinkEscape) => {
Err("path is reached through a symlink".to_string())
}
Err(DestinationRefusal::FileBlocksDirectory(_) | DestinationRefusal::NotARegularFile) => {
Ok(root.join(relative).is_file())
}
}
}
fn assess_entry(root: &Utf8Path, entry: &Entry, as_of: Date) -> Assessed {
let mut problems = Vec::new();
for (label, path) in std::iter::once(("path", &entry.path))
.chain(entry.dependents.iter().map(|d| ("dependent", d)))
{
match resolvable(root, path) {
Ok(true) => {}
Ok(false) => problems.push(Problem {
rule: RuleId::DeclaredDependentExists,
detail: format!("entry '{}': {label} '{path}' names no file", entry.id),
}),
Err(reason) => problems.push(Problem {
rule: RuleId::DeclaredDependentExists,
detail: format!("entry '{}': {label} '{path}' {reason}", entry.id),
}),
}
}
if entry.revalidate.is_empty() {
problems.push(Problem {
rule: RuleId::EntryDeclaresHowToRevalidate,
detail: format!("entry '{}': revalidate lists no step", entry.id),
});
}
let (next_check, freshness) = if let Ok(last) = entry.last_checked.parse::<Date>() {
let next = last
.checked_add(jiff::Span::new().days(i64::from(entry.cadence_days)))
.unwrap_or(last);
let freshness = if next >= as_of {
Freshness::Current
} else {
Freshness::Overdue((as_of - next).get_days().into())
};
if let Freshness::Overdue(_) = freshness {
problems.push(Problem {
rule: RuleId::OverdueEntryBlocks,
detail: format!(
"entry '{}' was due {next}; revalidate it, then advance last_checked. Steps: {}",
entry.id,
entry.revalidate.join("; ")
),
});
}
(Some(next), Some(freshness))
} else {
problems.push(Problem {
rule: RuleId::PerishableSourceIsRegistered,
detail: format!(
"entry '{}': last_checked '{}' is not an ISO date",
entry.id, entry.last_checked
),
});
(None, None)
};
Assessed {
entry: entry.clone(),
next_check,
freshness,
problems,
}
}
pub fn evaluate(root: &Utf8Path, docs_root: &Utf8Path, as_of: Date) -> std::io::Result<Report> {
let path = root.join(registry_path(docs_root));
let text = std::fs::read_to_string(&path)?;
match tracking::parse(&text) {
Ok(registry) => {
let entries = registry
.tracked
.iter()
.map(|entry| assess_entry(root, entry, as_of))
.collect();
Ok(Report {
entries,
fatal: None,
})
}
Err(error) => Ok(Report {
entries: Vec::new(),
fatal: Some(Problem {
rule: rule_for(&error),
detail: error.to_string(),
}),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &tempfile::TempDir, rel: &str, text: &str) {
let p = dir.path().join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from(dir.path().to_str().unwrap())
}
fn registry(dependent: &str, last_checked: &str) -> String {
format!(
"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"
)
}
#[test]
fn a_current_entry_has_no_problem() {
let dir = tempfile::tempdir().unwrap();
write(
&dir,
"_docs/reference/tracking.yaml",
®istry("_docs/guide.md", "2026-08-20"),
);
write(&dir, "_docs/reference/x.md", "# x\n");
write(&dir, "_docs/guide.md", "# g\n");
let report = evaluate(
&root(&dir),
Utf8Path::new("_docs"),
"2026-09-01".parse().unwrap(),
)
.unwrap();
assert!(!report.has_failures());
assert!(matches!(
report.entries[0].freshness,
Some(Freshness::Current)
));
}
#[test]
fn an_overdue_entry_fails_with_the_due_date() {
let dir = tempfile::tempdir().unwrap();
write(
&dir,
"_docs/reference/tracking.yaml",
®istry("_docs/guide.md", "2026-01-01"),
);
write(&dir, "_docs/reference/x.md", "# x\n");
write(&dir, "_docs/guide.md", "# g\n");
let report = evaluate(
&root(&dir),
Utf8Path::new("_docs"),
"2026-09-01".parse().unwrap(),
)
.unwrap();
assert!(report.has_failures());
let overdue = &report.entries[0];
assert!(matches!(overdue.freshness, Some(Freshness::Overdue(_))));
assert!(
overdue
.problems
.iter()
.any(|p| p.rule == RuleId::OverdueEntryBlocks)
);
assert!(
overdue
.problems
.iter()
.any(|p| p.detail.contains("2026-01-31"))
);
}
#[test]
fn a_missing_dependent_fails() {
let dir = tempfile::tempdir().unwrap();
write(
&dir,
"_docs/reference/tracking.yaml",
®istry("_docs/gone.md", "2026-08-25"),
);
write(&dir, "_docs/reference/x.md", "# x\n");
let report = evaluate(
&root(&dir),
Utf8Path::new("_docs"),
"2026-09-01".parse().unwrap(),
)
.unwrap();
assert!(report.has_failures());
assert!(
report.entries[0]
.problems
.iter()
.any(|p| p.rule == RuleId::DeclaredDependentExists && p.detail.contains("gone.md"))
);
}
#[test]
fn the_exact_due_boundary_is_current() {
let dir = tempfile::tempdir().unwrap();
write(
&dir,
"_docs/reference/tracking.yaml",
®istry("_docs/guide.md", "2026-01-01"),
);
write(&dir, "_docs/reference/x.md", "# x\n");
write(&dir, "_docs/guide.md", "# g\n");
let report = evaluate(
&root(&dir),
Utf8Path::new("_docs"),
"2026-01-31".parse().unwrap(),
)
.unwrap();
assert!(matches!(
report.entries[0].freshness,
Some(Freshness::Current)
));
let report = evaluate(
&root(&dir),
Utf8Path::new("_docs"),
"2026-02-01".parse().unwrap(),
)
.unwrap();
assert!(matches!(
report.entries[0].freshness,
Some(Freshness::Overdue(1))
));
}
#[test]
fn a_broken_registry_reports_one_fatal() {
let dir = tempfile::tempdir().unwrap();
write(
&dir,
"_docs/reference/tracking.yaml",
"schema_version: 2\ntracked: []\n",
);
let report = evaluate(&root(&dir), Utf8Path::new("_docs"), today_utc()).unwrap();
assert!(report.fatal.is_some());
assert!(report.has_failures());
}
}