spec_driven_docs/domain/
tracking.rs1use serde::Deserialize;
11use thiserror::Error;
12
13pub const SCHEMA_VERSION: u32 = 1;
15
16const MAX_BYTES: usize = 256 * 1024;
18const MAX_LINES: usize = 5_000;
19const MAX_LINE_LEN: usize = 4_096;
20const MAX_INDENT_SPACES: usize = 64;
21const MAX_ENTRIES: usize = 1_000;
22
23#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct Registry {
27 pub schema_version: u32,
29 pub tracked: Vec<Entry>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Entry {
37 pub id: String,
39 pub path: String,
41 pub last_checked: String,
43 pub cadence_days: u32,
45 pub why: String,
47 pub revalidate: Vec<String>,
49 pub dependents: Vec<String>,
51 #[serde(default)]
53 pub source: Option<Source>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Source {
60 pub kind: String,
62 pub repository: String,
64 pub reference: String,
66 pub revision: String,
68 pub license: String,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Error)]
74pub enum TrackingError {
75 #[error("{0}")]
77 Bounds(String),
78 #[error("invalid tracking registry: {0}")]
80 Shape(String),
81 #[error("{0}")]
83 Semantic(String),
84}
85
86#[must_use]
88pub fn is_object_id(value: &str) -> bool {
89 (value.len() == 40 || value.len() == 64)
90 && value
91 .bytes()
92 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
93}
94
95fn check_bounds(text: &str) -> Result<(), TrackingError> {
100 let bound = |m: String| Err(TrackingError::Bounds(m));
101 if text.len() > MAX_BYTES {
102 return bound(format!("registry is larger than {MAX_BYTES} bytes"));
103 }
104 let lines: Vec<&str> = text.lines().collect();
105 if lines.len() > MAX_LINES {
106 return bound(format!("registry has more than {MAX_LINES} lines"));
107 }
108 let mut scopes: Vec<(usize, std::collections::BTreeSet<String>)> = Vec::new();
110 for (number, raw) in lines.iter().enumerate() {
111 let line = raw.trim_end();
112 if line.len() > MAX_LINE_LEN {
113 return bound(format!(
114 "line {} is longer than {MAX_LINE_LEN} characters",
115 number + 1
116 ));
117 }
118 let indent = line.len() - line.trim_start().len();
119 if indent > MAX_INDENT_SPACES {
120 return bound(format!(
121 "line {} nests past {MAX_INDENT_SPACES} spaces",
122 number + 1
123 ));
124 }
125 let content = line.trim_start();
126 if content.is_empty() || content.starts_with('#') {
127 continue;
128 }
129 if number > 0 && (content == "---" || content == "...") {
130 return bound("registry carries more than one document".to_string());
131 }
132 if let Some(hazard) = control_hazard(content) {
135 return bound(format!("line {}: {hazard}", number + 1));
136 }
137 let (key_indent, key_part) = if let Some(rest) = content.strip_prefix("- ") {
139 (indent + 2, rest)
140 } else if content == "-" {
141 continue;
142 } else {
143 (indent, content)
144 };
145 let Some((key, _)) = key_part.split_once(':') else {
146 continue;
147 };
148 let key = key.trim();
149 if key.is_empty() || key.contains(' ') {
150 continue;
151 }
152 while scopes.last().is_some_and(|(scope, _)| *scope > key_indent) {
153 scopes.pop();
154 }
155 if scopes.last().is_none_or(|(scope, _)| *scope != key_indent) {
156 scopes.push((key_indent, std::collections::BTreeSet::new()));
157 }
158 if content.starts_with("- ") {
159 if let Some(entry) = scopes.last_mut() {
161 entry.1.clear();
162 }
163 }
164 if let Some(entry) = scopes.last_mut()
165 && !entry.1.insert(key.to_string())
166 {
167 return Err(TrackingError::Bounds(format!(
168 "line {}: duplicate mapping key '{key}'",
169 number + 1
170 )));
171 }
172 }
173 Ok(())
174}
175
176fn control_hazard(content: &str) -> Option<&'static str> {
178 let value = content.split_once(": ").map_or(content, |(_, v)| v);
180 let value = value.trim();
181 if value.starts_with('&') {
182 return Some("a YAML anchor is not allowed");
183 }
184 if value.starts_with('*') {
185 return Some("a YAML alias is not allowed");
186 }
187 if value.starts_with('!') {
188 return Some("a YAML tag is not allowed");
189 }
190 if content.trim_start().starts_with("<<") {
191 return Some("a YAML merge key is not allowed");
192 }
193 None
194}
195
196fn check_source(entry: &Entry) -> Result<(), TrackingError> {
200 let Some(source) = &entry.source else {
201 return Ok(());
202 };
203 let bad = |m: String| {
204 Err(TrackingError::Semantic(format!(
205 "entry '{}': {m}",
206 entry.id
207 )))
208 };
209 if source.kind != "git" {
210 return bad(format!("source.kind must be 'git', not '{}'", source.kind));
211 }
212 if !source.repository.starts_with("https://") {
213 return bad("source.repository must be a credential-free https:// URL".to_string());
214 }
215 if source.repository.contains('@') {
216 return bad("source.repository must carry no credentials".to_string());
217 }
218 if !source.reference.starts_with("refs/") {
219 return bad("source.reference must be a full refs/... reference".to_string());
220 }
221 if !is_object_id(&source.revision) {
222 return bad(format!(
223 "source.revision must be a full 40- or 64-character object ID, not '{}'",
224 source.revision
225 ));
226 }
227 if source.license.trim().is_empty() {
228 return bad("source.license must be a non-empty identifier".to_string());
229 }
230 Ok(())
231}
232
233pub fn parse(text: &str) -> Result<Registry, TrackingError> {
242 check_bounds(text)?;
243 let registry: Registry =
244 yaml_serde::from_str(text).map_err(|e| TrackingError::Shape(e.to_string()))?;
245 if registry.schema_version != SCHEMA_VERSION {
246 return Err(TrackingError::Semantic(format!(
247 "schema_version must be {SCHEMA_VERSION}, not {}",
248 registry.schema_version
249 )));
250 }
251 if registry.tracked.len() > MAX_ENTRIES {
252 return Err(TrackingError::Bounds(format!(
253 "registry has more than {MAX_ENTRIES} entries"
254 )));
255 }
256 let mut ids = std::collections::BTreeSet::new();
257 for entry in ®istry.tracked {
258 if entry.cadence_days == 0 {
259 return Err(TrackingError::Semantic(format!(
260 "entry '{}': cadence_days must be a positive integer",
261 entry.id
262 )));
263 }
264 if !ids.insert(entry.id.clone()) {
265 return Err(TrackingError::Semantic(format!(
266 "duplicate entry id '{}'",
267 entry.id
268 )));
269 }
270 let mut dependents = std::collections::BTreeSet::new();
271 for dependent in &entry.dependents {
272 if !dependents.insert(dependent.clone()) {
273 return Err(TrackingError::Semantic(format!(
274 "entry '{}': duplicate dependent '{dependent}'",
275 entry.id
276 )));
277 }
278 }
279 check_source(entry)?;
280 }
281 Ok(registry)
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 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";
289
290 #[test]
291 fn accepts_a_minimal_registry() {
292 let registry = parse(OK).unwrap();
293 assert_eq!(registry.schema_version, 1);
294 assert_eq!(registry.tracked.len(), 1);
295 }
296
297 #[test]
298 fn accepts_a_git_source() {
299 let text = format!(
300 "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",
301 "a".repeat(40)
302 );
303 let registry = parse(&text).unwrap();
304 assert_eq!(registry.tracked[0].source.as_ref().unwrap().kind, "git");
305 }
306
307 #[test]
308 fn rejects_a_wrong_schema_version() {
309 let text = OK.replace("schema_version: 1", "schema_version: 2");
310 assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
311 }
312
313 #[test]
314 fn rejects_a_duplicate_id() {
315 let text = format!(
316 "{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"
317 );
318 assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
319 }
320
321 #[test]
322 fn rejects_a_duplicate_mapping_key() {
323 let text = "schema_version: 1\nschema_version: 1\ntracked: []\n";
324 assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
325 }
326
327 #[test]
328 fn rejects_an_alias_and_an_anchor() {
329 let text = "schema_version: 1\ntracked: &all []\nother: *all\n";
330 assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
331 }
332
333 #[test]
334 fn rejects_a_second_document() {
335 let text = "schema_version: 1\ntracked: []\n---\nschema_version: 1\ntracked: []\n";
336 assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
337 }
338
339 #[test]
340 fn rejects_a_tag() {
341 let text = "schema_version: 1\ntracked: !!seq []\n";
342 assert!(matches!(parse(text), Err(TrackingError::Bounds(_))));
343 }
344
345 #[test]
346 fn rejects_a_branch_revision() {
347 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";
348 assert!(matches!(parse(text), Err(TrackingError::Semantic(_))));
349 }
350
351 #[test]
352 fn rejects_credentials_in_the_repository() {
353 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";
354 assert!(matches!(parse(text), Err(TrackingError::Semantic(_))));
355 }
356
357 #[test]
358 fn rejects_zero_cadence() {
359 let text = OK.replace("cadence_days: 30", "cadence_days: 0");
360 assert!(matches!(parse(&text), Err(TrackingError::Semantic(_))));
361 }
362
363 #[test]
364 fn rejects_an_oversized_file() {
365 let text = format!(
366 "schema_version: 1\ntracked: []\n# {}\n",
367 "x".repeat(MAX_LINE_LEN + 1)
368 );
369 assert!(matches!(parse(&text), Err(TrackingError::Bounds(_))));
370 }
371}