Skip to main content

dm_checks/
lib.rs

1use std::path::PathBuf;
2
3use chrono::NaiveDate;
4use dm_meta::{Category, Severity};
5use dm_scan::DocTree;
6
7// ---------------------------------------------------------------------------
8// Types
9// ---------------------------------------------------------------------------
10
11/// The type of health check that produced an issue.
12#[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/// A single issue found during a health check.
34#[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/// Aggregated results from all health checks.
43#[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    /// Returns true if any issue has Error severity.
52    pub fn has_errors(&self) -> bool {
53        self.issues.iter().any(|i| i.severity == Severity::Error)
54    }
55
56    /// Returns true if any issue has Warning severity.
57    pub fn has_warnings(&self) -> bool {
58        self.issues.iter().any(|i| i.severity == Severity::Warning)
59    }
60
61    /// Count of issues with Error severity.
62    pub fn error_count(&self) -> usize {
63        self.issues.iter().filter(|i| i.severity == Severity::Error).count()
64    }
65
66    /// Count of issues with Warning severity.
67    pub fn warning_count(&self) -> usize {
68        self.issues.iter().filter(|i| i.severity == Severity::Warning).count()
69    }
70
71    /// Count of issues with Info severity.
72    pub fn info_count(&self) -> usize {
73        self.issues.iter().filter(|i| i.severity == Severity::Info).count()
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Staleness detection
79// ---------------------------------------------------------------------------
80
81/// Detect stale documents: overdue reviews, old last-updated dates, missing review dates.
82pub fn check_stale(tree: &DocTree, today: NaiveDate) -> Vec<CheckIssue> {
83    let mut issues = Vec::new();
84
85    for doc in tree.all() {
86        // Review overdue
87        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        // Not updated in >180 days
99        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        // Active docs without next_review
112        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
125// ---------------------------------------------------------------------------
126// Orphan detection
127// ---------------------------------------------------------------------------
128
129/// Detect orphaned design documents: accepted without PRs, stale acceptances.
130pub 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            // Missing implementation PR
144            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            // Accepted >90 days without implementation
154            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            // Check if referenced by any active document
169            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
190// ---------------------------------------------------------------------------
191// Broken link detection
192// ---------------------------------------------------------------------------
193
194/// Detect broken cross-references in related_docs, supersedes, and superseded_by fields.
195pub fn check_broken_links(tree: &DocTree) -> Vec<CheckIssue> {
196    let mut issues = Vec::new();
197
198    // Collect all relative paths present in the tree for lookup.
199    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    // Also check by absolute path existence.
210    let path_exists = |link: &str| -> bool {
211        // Check in known relative paths.
212        if known_paths.iter().any(|p| p == link || p.ends_with(link) || link.ends_with(p.as_str())) {
213            return true;
214        }
215        // Check as path relative to docs root.
216        let candidate = tree.root.join(link);
217        if candidate.exists() {
218            return true;
219        }
220        // Try stripping common prefixes like "docs/"
221        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        // Check related_docs
234        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        // Check supersedes
248        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        // Check superseded_by
260        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
275// ---------------------------------------------------------------------------
276// Frontmatter checks
277// ---------------------------------------------------------------------------
278
279/// Validate frontmatter for all documents and convert issues to CheckIssues.
280pub 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
303// ---------------------------------------------------------------------------
304// Combined check
305// ---------------------------------------------------------------------------
306
307/// Run all health checks and return an aggregated report.
308pub 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
327// ---------------------------------------------------------------------------
328// Report formatting
329// ---------------------------------------------------------------------------
330
331/// Format a check report as human-readable text.
332pub 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// ---------------------------------------------------------------------------
390// Tests
391// ---------------------------------------------------------------------------
392
393#[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        // GETTING_STARTED has next_review: 2026-01-01
414        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        // CLI_REFERENCE last_updated: 2025-09-15, so >180 days from 2026-06-01
427        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        // CORE_CONCEPTS has next_review: 2026-04-15
440        let today = NaiveDate::from_ymd_opt(2026, 2, 12).unwrap();
441        let issues = check_stale(&tree, today);
442        // Should NOT flag CORE_CONCEPTS as overdue
443        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        // 002-context-fidelity is accepted with no implementation_pr
455        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        // 001-recursive-optimization is proposed — should NOT be flagged as orphan
467        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        // Build a minimal DocTree with a broken related_docs link
477        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        // All related_docs in fixtures use "docs/active/..." prefixed paths
504        // which should resolve. Filter only actual broken links.
505        let broken = issues.iter().filter(|i| i.check_type == CheckType::BrokenLink).count();
506        // We expect 0 broken links in the fixtures (all cross-refs are valid)
507        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        // Should have at least one issue (stale review on GETTING_STARTED at minimum)
541        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}