use serde::Deserialize;
use thiserror::Error;
pub const SCHEMA_VERSION: u32 = 1;
const MAX_BYTES: usize = 256 * 1024;
const MAX_LINES: usize = 5_000;
const MAX_LINE_LEN: usize = 4_096;
const MAX_INDENT_SPACES: usize = 64;
const MAX_ENTRIES: usize = 1_000;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Registry {
pub schema_version: u32,
pub tracked: Vec<Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub id: String,
pub path: String,
pub last_checked: String,
pub cadence_days: u32,
pub why: String,
pub revalidate: Vec<String>,
pub dependents: Vec<String>,
#[serde(default)]
pub source: Option<Source>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Source {
pub kind: String,
pub repository: String,
pub reference: String,
pub revision: String,
pub license: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TrackingError {
#[error("{0}")]
Bounds(String),
#[error("invalid tracking registry: {0}")]
Shape(String),
#[error("{0}")]
Semantic(String),
}
#[must_use]
pub fn is_object_id(value: &str) -> bool {
(value.len() == 40 || value.len() == 64)
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn check_bounds(text: &str) -> Result<(), TrackingError> {
let bound = |m: String| Err(TrackingError::Bounds(m));
if text.len() > MAX_BYTES {
return bound(format!("registry is larger than {MAX_BYTES} bytes"));
}
let lines: Vec<&str> = text.lines().collect();
if lines.len() > MAX_LINES {
return bound(format!("registry has more than {MAX_LINES} lines"));
}
let mut scopes: Vec<(usize, std::collections::BTreeSet<String>)> = Vec::new();
for (number, raw) in lines.iter().enumerate() {
let line = raw.trim_end();
if line.len() > MAX_LINE_LEN {
return bound(format!(
"line {} is longer than {MAX_LINE_LEN} characters",
number + 1
));
}
let indent = line.len() - line.trim_start().len();
if indent > MAX_INDENT_SPACES {
return bound(format!(
"line {} nests past {MAX_INDENT_SPACES} spaces",
number + 1
));
}
let content = line.trim_start();
if content.is_empty() || content.starts_with('#') {
continue;
}
if number > 0 && (content == "---" || content == "...") {
return bound("registry carries more than one document".to_string());
}
if let Some(hazard) = control_hazard(content) {
return bound(format!("line {}: {hazard}", number + 1));
}
let (key_indent, key_part) = if let Some(rest) = content.strip_prefix("- ") {
(indent + 2, rest)
} else if content == "-" {
continue;
} else {
(indent, content)
};
let Some((key, _)) = key_part.split_once(':') else {
continue;
};
let key = key.trim();
if key.is_empty() || key.contains(' ') {
continue;
}
while scopes.last().is_some_and(|(scope, _)| *scope > key_indent) {
scopes.pop();
}
if scopes.last().is_none_or(|(scope, _)| *scope != key_indent) {
scopes.push((key_indent, std::collections::BTreeSet::new()));
}
if content.starts_with("- ") {
if let Some(entry) = scopes.last_mut() {
entry.1.clear();
}
}
if let Some(entry) = scopes.last_mut()
&& !entry.1.insert(key.to_string())
{
return Err(TrackingError::Bounds(format!(
"line {}: duplicate mapping key '{key}'",
number + 1
)));
}
}
Ok(())
}
fn control_hazard(content: &str) -> Option<&'static str> {
let value = content.split_once(": ").map_or(content, |(_, v)| v);
let value = value.trim();
if value.starts_with('&') {
return Some("a YAML anchor is not allowed");
}
if value.starts_with('*') {
return Some("a YAML alias is not allowed");
}
if value.starts_with('!') {
return Some("a YAML tag is not allowed");
}
if content.trim_start().starts_with("<<") {
return Some("a YAML merge key is not allowed");
}
None
}
fn check_source(entry: &Entry) -> Result<(), TrackingError> {
let Some(source) = &entry.source else {
return Ok(());
};
let bad = |m: String| {
Err(TrackingError::Semantic(format!(
"entry '{}': {m}",
entry.id
)))
};
if source.kind != "git" {
return bad(format!("source.kind must be 'git', not '{}'", source.kind));
}
if !source.repository.starts_with("https://") {
return bad("source.repository must be a credential-free https:// URL".to_string());
}
if source.repository.contains('@') {
return bad("source.repository must carry no credentials".to_string());
}
if !source.reference.starts_with("refs/") {
return bad("source.reference must be a full refs/... reference".to_string());
}
if !is_object_id(&source.revision) {
return bad(format!(
"source.revision must be a full 40- or 64-character object ID, not '{}'",
source.revision
));
}
if source.license.trim().is_empty() {
return bad("source.license must be a non-empty identifier".to_string());
}
Ok(())
}
pub fn parse(text: &str) -> Result<Registry, TrackingError> {
check_bounds(text)?;
let registry: Registry =
yaml_serde::from_str(text).map_err(|e| TrackingError::Shape(e.to_string()))?;
if registry.schema_version != SCHEMA_VERSION {
return Err(TrackingError::Semantic(format!(
"schema_version must be {SCHEMA_VERSION}, not {}",
registry.schema_version
)));
}
if registry.tracked.len() > MAX_ENTRIES {
return Err(TrackingError::Bounds(format!(
"registry has more than {MAX_ENTRIES} entries"
)));
}
let mut ids = std::collections::BTreeSet::new();
for entry in ®istry.tracked {
if entry.cadence_days == 0 {
return Err(TrackingError::Semantic(format!(
"entry '{}': cadence_days must be a positive integer",
entry.id
)));
}
if !ids.insert(entry.id.clone()) {
return Err(TrackingError::Semantic(format!(
"duplicate entry id '{}'",
entry.id
)));
}
let mut dependents = std::collections::BTreeSet::new();
for dependent in &entry.dependents {
if !dependents.insert(dependent.clone()) {
return Err(TrackingError::Semantic(format!(
"entry '{}': duplicate dependent '{dependent}'",
entry.id
)));
}
}
check_source(entry)?;
}
Ok(registry)
}
#[cfg(test)]
mod tests {
use super::*;
const OK: &str = "schema_version: 1\ntracked:\n - id: sample\n path: reference/x.md\n last_checked: 2026-01-01\n cadence_days: 30\n why: it moves\n revalidate:\n - re-fetch it\n dependents: []\n";
#[test]
fn accepts_a_minimal_registry() {
let registry = parse(OK).unwrap();
assert_eq!(registry.schema_version, 1);
assert_eq!(registry.tracked.len(), 1);
}
#[test]
fn accepts_a_git_source() {
let text = format!(
"schema_version: 1\ntracked:\n - id: sample\n path: reference/x.md\n last_checked: 2026-01-01\n cadence_days: 30\n why: it moves\n revalidate:\n - re-fetch it\n dependents: []\n source:\n kind: git\n repository: https://github.com/o/r\n reference: refs/tags/v1\n revision: {}\n license: MIT\n",
"a".repeat(40)
);
let registry = parse(&text).unwrap();
assert_eq!(registry.tracked[0].source.as_ref().unwrap().kind, "git");
}
#[test]
fn rejects_a_wrong_schema_version() {
let text = OK.replace("schema_version: 1", "schema_version: 2");
assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
}
#[test]
fn rejects_a_duplicate_id() {
let text = format!(
"{OK} - id: sample\n path: reference/y.md\n last_checked: 2026-01-01\n cadence_days: 30\n why: also\n revalidate:\n - go\n dependents: []\n"
);
assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
}
#[test]
fn rejects_a_duplicate_mapping_key() {
let text = "schema_version: 1\nschema_version: 1\ntracked: []\n";
assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
}
#[test]
fn rejects_an_alias_and_an_anchor() {
let text = "schema_version: 1\ntracked: &all []\nother: *all\n";
assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
}
#[test]
fn rejects_a_second_document() {
let text = "schema_version: 1\ntracked: []\n---\nschema_version: 1\ntracked: []\n";
assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
}
#[test]
fn rejects_a_tag() {
let text = "schema_version: 1\ntracked: !!seq []\n";
assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
}
#[test]
fn rejects_a_branch_revision() {
let text = "schema_version: 1\ntracked:\n - id: s\n path: r/x.md\n last_checked: 2026-01-01\n cadence_days: 30\n why: w\n revalidate:\n - go\n dependents: []\n source:\n kind: git\n repository: https://github.com/o/r\n reference: refs/heads/main\n revision: main\n license: MIT\n";
assert!(matches!(parse(text), Err(TrackingError::Semantic(_))));
}
#[test]
fn rejects_credentials_in_the_repository() {
let text = "schema_version: 1\ntracked:\n - id: s\n path: r/x.md\n last_checked: 2026-01-01\n cadence_days: 30\n why: w\n revalidate:\n - go\n dependents: []\n source:\n kind: git\n repository: https://user:pass@github.com/o/r\n reference: refs/tags/v1\n revision: 1111111111111111111111111111111111111111\n license: MIT\n";
assert!(matches!(parse(text), Err(TrackingError::Semantic(_))));
}
#[test]
fn rejects_zero_cadence() {
let text = OK.replace("cadence_days: 30", "cadence_days: 0");
assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
}
#[test]
fn rejects_an_oversized_file() {
let text = format!(
"schema_version: 1\ntracked: []\n# {}\n",
"x".repeat(MAX_LINE_LEN + 1)
);
assert!(matches!(parse(&text), Err(TrackingError::Bounds(_))));
}
}