1use std::path::PathBuf;
2
3use chrono::NaiveDate;
4use dm_meta::{Category, Severity};
5use dm_scan::DocTree;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CheckType {
14 Stale,
15 Orphan,
16 BrokenLink,
17 MissingFrontmatter,
18 InvalidMetadata,
19}
20
21impl std::fmt::Display for CheckType {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 CheckType::Stale => write!(f, "stale"),
25 CheckType::Orphan => write!(f, "orphan"),
26 CheckType::BrokenLink => write!(f, "broken_link"),
27 CheckType::MissingFrontmatter => write!(f, "missing_frontmatter"),
28 CheckType::InvalidMetadata => write!(f, "invalid_metadata"),
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct CheckIssue {
36 pub path: PathBuf,
37 pub check_type: CheckType,
38 pub severity: Severity,
39 pub message: String,
40}
41
42#[derive(Debug, Clone)]
44pub struct CheckReport {
45 pub issues: Vec<CheckIssue>,
46 pub docs_checked: usize,
47 pub timestamp: NaiveDate,
48}
49
50impl CheckReport {
51 pub fn has_errors(&self) -> bool {
53 self.issues.iter().any(|i| i.severity == Severity::Error)
54 }
55
56 pub fn has_warnings(&self) -> bool {
58 self.issues.iter().any(|i| i.severity == Severity::Warning)
59 }
60
61 pub fn error_count(&self) -> usize {
63 self.issues.iter().filter(|i| i.severity == Severity::Error).count()
64 }
65
66 pub fn warning_count(&self) -> usize {
68 self.issues.iter().filter(|i| i.severity == Severity::Warning).count()
69 }
70
71 pub fn info_count(&self) -> usize {
73 self.issues.iter().filter(|i| i.severity == Severity::Info).count()
74 }
75}
76
77pub fn check_stale(tree: &DocTree, today: NaiveDate) -> Vec<CheckIssue> {
83 let mut issues = Vec::new();
84
85 for doc in tree.all() {
86 if let Some(next_review) = doc.frontmatter.next_review {
88 if today > next_review {
89 issues.push(CheckIssue {
90 path: doc.path.clone(),
91 check_type: CheckType::Stale,
92 severity: Severity::Warning,
93 message: format!("Review overdue since {next_review}"),
94 });
95 }
96 }
97
98 if let Some(last_updated) = doc.frontmatter.last_updated {
100 let days_since = (today - last_updated).num_days();
101 if days_since > 180 {
102 issues.push(CheckIssue {
103 path: doc.path.clone(),
104 check_type: CheckType::Stale,
105 severity: Severity::Warning,
106 message: format!("Not updated in over 6 months (last: {last_updated})"),
107 });
108 }
109 }
110
111 if doc.category == Category::Active && doc.frontmatter.next_review.is_none() {
113 issues.push(CheckIssue {
114 path: doc.path.clone(),
115 check_type: CheckType::Stale,
116 severity: Severity::Info,
117 message: "No review date set".into(),
118 });
119 }
120 }
121
122 issues
123}
124
125pub fn check_orphans(tree: &DocTree) -> Vec<CheckIssue> {
131 let today = chrono::Local::now().date_naive();
132 check_orphans_with_date(tree, today)
133}
134
135fn check_orphans_with_date(tree: &DocTree, today: NaiveDate) -> Vec<CheckIssue> {
136 let mut issues = Vec::new();
137 let design_docs = tree.by_category(Category::Design);
138
139 for doc in &design_docs {
140 let status = doc.frontmatter.status.as_deref().unwrap_or("").to_lowercase();
141
142 if status == "accepted" {
143 if doc.frontmatter.implementation_pr.is_none() {
145 issues.push(CheckIssue {
146 path: doc.path.clone(),
147 check_type: CheckType::Orphan,
148 severity: Severity::Warning,
149 message: "Accepted design doc has no implementation PR".into(),
150 });
151 }
152
153 if let Some(decision_date) = doc.frontmatter.decision_date {
155 let days = (today - decision_date).num_days();
156 if days > 90 {
157 issues.push(CheckIssue {
158 path: doc.path.clone(),
159 check_type: CheckType::Orphan,
160 severity: Severity::Warning,
161 message: "Accepted >90 days without implementation".into(),
162 });
163 }
164 }
165 }
166
167 if status == "implemented" {
168 let active_docs = tree.by_category(Category::Active);
170 let doc_path_str = doc.path.to_string_lossy();
171 let is_referenced = active_docs.iter().any(|active| {
172 active.frontmatter.related_docs.as_deref().unwrap_or(&[])
173 .iter()
174 .any(|rd| doc_path_str.contains(rd) || rd.contains(&*doc_path_str))
175 });
176 if !is_referenced {
177 issues.push(CheckIssue {
178 path: doc.path.clone(),
179 check_type: CheckType::Orphan,
180 severity: Severity::Info,
181 message: "Implemented design doc not referenced by any active doc".into(),
182 });
183 }
184 }
185 }
186
187 issues
188}
189
190pub fn check_broken_links(tree: &DocTree) -> Vec<CheckIssue> {
196 let mut issues = Vec::new();
197
198 let known_paths: Vec<String> = tree.all().iter()
200 .map(|d| {
201 d.path
202 .strip_prefix(&tree.root)
203 .unwrap_or(&d.path)
204 .to_string_lossy()
205 .replace('\\', "/")
206 })
207 .collect();
208
209 let path_exists = |link: &str| -> bool {
211 if known_paths.iter().any(|p| p == link || p.ends_with(link) || link.ends_with(p.as_str())) {
213 return true;
214 }
215 let candidate = tree.root.join(link);
217 if candidate.exists() {
218 return true;
219 }
220 if let Some(stripped) = link.strip_prefix("docs/") {
222 if known_paths.iter().any(|p| p == stripped || p.ends_with(stripped)) {
223 return true;
224 }
225 if tree.root.join(stripped).exists() {
226 return true;
227 }
228 }
229 false
230 };
231
232 for doc in tree.all() {
233 if let Some(ref related) = doc.frontmatter.related_docs {
235 for link in related {
236 if !path_exists(link) {
237 issues.push(CheckIssue {
238 path: doc.path.clone(),
239 check_type: CheckType::BrokenLink,
240 severity: Severity::Error,
241 message: format!("Broken link: {link} does not exist"),
242 });
243 }
244 }
245 }
246
247 if let Some(ref target) = doc.frontmatter.supersedes {
249 if !path_exists(target) {
250 issues.push(CheckIssue {
251 path: doc.path.clone(),
252 check_type: CheckType::BrokenLink,
253 severity: Severity::Error,
254 message: format!("Supersedes target not found: {target}"),
255 });
256 }
257 }
258
259 if let Some(ref target) = doc.frontmatter.superseded_by {
261 if !path_exists(target) {
262 issues.push(CheckIssue {
263 path: doc.path.clone(),
264 check_type: CheckType::BrokenLink,
265 severity: Severity::Error,
266 message: format!("Superseded_by target not found: {target}"),
267 });
268 }
269 }
270 }
271
272 issues
273}
274
275pub fn check_frontmatter(tree: &DocTree) -> Vec<CheckIssue> {
281 let mut issues = Vec::new();
282
283 for doc in tree.all() {
284 let validation_issues = dm_meta::validate_frontmatter(doc);
285 for vi in validation_issues {
286 let check_type = if vi.message.contains("no frontmatter") {
287 CheckType::MissingFrontmatter
288 } else {
289 CheckType::InvalidMetadata
290 };
291 issues.push(CheckIssue {
292 path: vi.path,
293 check_type,
294 severity: vi.severity,
295 message: vi.message,
296 });
297 }
298 }
299
300 issues
301}
302
303pub fn run_all_checks(tree: &DocTree) -> CheckReport {
309 let today = chrono::Local::now().date_naive();
310 run_all_checks_with_date(tree, today)
311}
312
313fn run_all_checks_with_date(tree: &DocTree, today: NaiveDate) -> CheckReport {
314 let mut issues = Vec::new();
315 issues.extend(check_stale(tree, today));
316 issues.extend(check_orphans_with_date(tree, today));
317 issues.extend(check_broken_links(tree));
318 issues.extend(check_frontmatter(tree));
319
320 CheckReport {
321 docs_checked: tree.all().len(),
322 issues,
323 timestamp: today,
324 }
325}
326
327pub fn format_report(report: &CheckReport) -> String {
333 let mut out = String::new();
334 out.push_str("Document Health Check Report\n");
335 out.push_str("===========================\n");
336 out.push_str(&format!("Checked: {} documents\n", report.docs_checked));
337 out.push_str(&format!(
338 "Errors: {} | Warnings: {} | Info: {}\n",
339 report.error_count(),
340 report.warning_count(),
341 report.info_count()
342 ));
343
344 let errors: Vec<&CheckIssue> = report.issues.iter()
345 .filter(|i| i.severity == Severity::Error).collect();
346 if !errors.is_empty() {
347 out.push_str("\nERRORS:\n");
348 for issue in errors {
349 out.push_str(&format!(
350 " [{}] {}: {}\n",
351 issue.check_type,
352 issue.path.display(),
353 issue.message
354 ));
355 }
356 }
357
358 let warnings: Vec<&CheckIssue> = report.issues.iter()
359 .filter(|i| i.severity == Severity::Warning).collect();
360 if !warnings.is_empty() {
361 out.push_str("\nWARNINGS:\n");
362 for issue in warnings {
363 out.push_str(&format!(
364 " [{}] {}: {}\n",
365 issue.check_type,
366 issue.path.display(),
367 issue.message
368 ));
369 }
370 }
371
372 let infos: Vec<&CheckIssue> = report.issues.iter()
373 .filter(|i| i.severity == Severity::Info).collect();
374 if !infos.is_empty() {
375 out.push_str("\nINFO:\n");
376 for issue in infos {
377 out.push_str(&format!(
378 " [{}] {}: {}\n",
379 issue.check_type,
380 issue.path.display(),
381 issue.message
382 ));
383 }
384 }
385
386 out
387}
388
389#[cfg(test)]
394mod tests {
395 use super::*;
396 use dm_meta::{Document, RawFrontmatter};
397
398 fn fixtures_root() -> PathBuf {
399 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
400 let workspace = manifest.parent().unwrap().parent().unwrap();
401 let root = workspace.join("tests/fixtures/docs");
402 assert!(root.exists(), "fixtures dir not found at {}", root.display());
403 root
404 }
405
406 fn scan_fixtures() -> DocTree {
407 DocTree::scan(&fixtures_root())
408 }
409
410 #[test]
411 fn stale_detects_past_next_review() {
412 let tree = scan_fixtures();
413 let today = NaiveDate::from_ymd_opt(2026, 2, 12).unwrap();
415 let issues = check_stale(&tree, today);
416 assert!(
417 issues.iter().any(|i| i.check_type == CheckType::Stale
418 && i.message.contains("Review overdue")),
419 "should detect overdue review"
420 );
421 }
422
423 #[test]
424 fn stale_detects_old_last_updated() {
425 let tree = scan_fixtures();
426 let today = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
428 let issues = check_stale(&tree, today);
429 assert!(
430 issues.iter().any(|i| i.check_type == CheckType::Stale
431 && i.message.contains("Not updated in over 6 months")),
432 "should detect doc not updated in >180 days"
433 );
434 }
435
436 #[test]
437 fn stale_ignores_future_next_review() {
438 let tree = scan_fixtures();
439 let today = NaiveDate::from_ymd_opt(2026, 2, 12).unwrap();
441 let issues = check_stale(&tree, today);
442 let core_stale = issues.iter().any(|i| {
444 i.path.to_string_lossy().contains("CORE_CONCEPTS")
445 && i.message.contains("Review overdue")
446 });
447 assert!(!core_stale, "should not flag doc with future next_review");
448 }
449
450 #[test]
451 fn orphans_detects_accepted_without_pr() {
452 let tree = scan_fixtures();
453 let issues = check_orphans(&tree);
454 assert!(
456 issues.iter().any(|i| i.check_type == CheckType::Orphan
457 && i.message.contains("no implementation PR")),
458 "should detect accepted design doc without PR"
459 );
460 }
461
462 #[test]
463 fn orphans_ignores_proposed() {
464 let tree = scan_fixtures();
465 let issues = check_orphans(&tree);
466 let proposed_flagged = issues.iter().any(|i| {
468 i.path.to_string_lossy().contains("001-recursive-optimization")
469 && i.check_type == CheckType::Orphan
470 });
471 assert!(!proposed_flagged, "should not flag proposed design docs");
472 }
473
474 #[test]
475 fn broken_links_detects_nonexistent() {
476 let tree = DocTree {
478 docs: vec![Document {
479 path: PathBuf::from("/tmp/test/active/x.md"),
480 frontmatter: RawFrontmatter {
481 title: Some("X".into()),
482 related_docs: Some(vec!["nonexistent/foo.md".into()]),
483 ..Default::default()
484 },
485 category: Category::Active,
486 body: String::new(),
487 }],
488 errors: vec![],
489 root: PathBuf::from("/tmp/test"),
490 };
491 let issues = check_broken_links(&tree);
492 assert!(
493 issues.iter().any(|i| i.check_type == CheckType::BrokenLink
494 && i.message.contains("does not exist")),
495 "should detect broken link"
496 );
497 }
498
499 #[test]
500 fn broken_links_passes_when_valid() {
501 let tree = scan_fixtures();
502 let issues = check_broken_links(&tree);
503 let broken = issues.iter().filter(|i| i.check_type == CheckType::BrokenLink).count();
506 assert_eq!(broken, 0, "fixture cross-refs should all resolve, got {broken} broken");
508 }
509
510 #[test]
511 fn frontmatter_detects_missing_title() {
512 let tree = DocTree {
513 docs: vec![Document {
514 path: PathBuf::from("/tmp/test/active/notitle.md"),
515 frontmatter: RawFrontmatter {
516 author: Some("a".into()),
517 status: Some("active".into()),
518 created: Some(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()),
519 ..Default::default()
520 },
521 category: Category::Active,
522 body: "some body".into(),
523 }],
524 errors: vec![],
525 root: PathBuf::from("/tmp/test"),
526 };
527 let issues = check_frontmatter(&tree);
528 assert!(
529 issues.iter().any(|i| i.message.contains("missing title")),
530 "should detect missing title"
531 );
532 }
533
534 #[test]
535 fn run_all_checks_combines_issues() {
536 let tree = scan_fixtures();
537 let today = NaiveDate::from_ymd_opt(2026, 2, 12).unwrap();
538 let report = run_all_checks_with_date(&tree, today);
539 assert!(report.docs_checked >= 9);
540 assert!(!report.issues.is_empty(), "should find at least one issue");
542 }
543
544 #[test]
545 fn report_counts_correct() {
546 let report = CheckReport {
547 issues: vec![
548 CheckIssue {
549 path: PathBuf::from("a.md"),
550 check_type: CheckType::Stale,
551 severity: Severity::Error,
552 message: "err".into(),
553 },
554 CheckIssue {
555 path: PathBuf::from("b.md"),
556 check_type: CheckType::Orphan,
557 severity: Severity::Warning,
558 message: "warn".into(),
559 },
560 CheckIssue {
561 path: PathBuf::from("c.md"),
562 check_type: CheckType::Stale,
563 severity: Severity::Warning,
564 message: "warn2".into(),
565 },
566 CheckIssue {
567 path: PathBuf::from("d.md"),
568 check_type: CheckType::Stale,
569 severity: Severity::Info,
570 message: "info".into(),
571 },
572 ],
573 docs_checked: 4,
574 timestamp: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
575 };
576 assert_eq!(report.error_count(), 1);
577 assert_eq!(report.warning_count(), 2);
578 assert_eq!(report.info_count(), 1);
579 assert!(report.has_errors());
580 assert!(report.has_warnings());
581 }
582
583 #[test]
584 fn format_report_structure() {
585 let report = CheckReport {
586 issues: vec![
587 CheckIssue {
588 path: PathBuf::from("test.md"),
589 check_type: CheckType::Stale,
590 severity: Severity::Error,
591 message: "test error".into(),
592 },
593 CheckIssue {
594 path: PathBuf::from("test2.md"),
595 check_type: CheckType::Orphan,
596 severity: Severity::Warning,
597 message: "test warning".into(),
598 },
599 ],
600 docs_checked: 5,
601 timestamp: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
602 };
603 let output = format_report(&report);
604 assert!(output.contains("Document Health Check Report"));
605 assert!(output.contains("Checked: 5 documents"));
606 assert!(output.contains("Errors: 1"));
607 assert!(output.contains("Warnings: 1"));
608 assert!(output.contains("ERRORS:"));
609 assert!(output.contains("[stale] test.md: test error"));
610 assert!(output.contains("WARNINGS:"));
611 assert!(output.contains("[orphan] test2.md: test warning"));
612 }
613}