docgarden 0.1.0-rc0

Mechanical repository-knowledge linter for agentic engineering repositories
Documentation
use std::collections::BTreeSet;

use anyhow::{Context, Result};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use serde::Serialize;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Severity {
    Error,
    Warning,
}

#[derive(Clone, Debug, Serialize)]
pub struct Diagnostic {
    pub file: String,
    pub line: usize,
    pub column: usize,
    pub severity: Severity,
    pub rule: String,
    pub message: String,
    pub fixable: bool,
}

#[derive(Debug, Default)]
pub struct FixSummary {
    pub fixable_count: usize,
    pub fixable_rules: BTreeSet<String>,
}

impl FixSummary {
    pub fn record(&mut self, diagnostic: &Diagnostic) {
        if diagnostic.fixable {
            self.fixable_count += 1;
            self.fixable_rules.insert(diagnostic.rule.clone());
        }
    }
}

pub struct PatternMatcher {
    matcher: Gitignore,
}

impl PatternMatcher {
    pub fn new(patterns: &[String]) -> Result<Self> {
        let mut builder = GitignoreBuilder::new("/");
        for pattern in patterns {
            builder
                .add_line(None, pattern)
                .with_context(|| format!("invalid pattern {pattern}"))?;
        }
        let matcher = builder.build()?;
        Ok(Self { matcher })
    }

    pub fn is_match(&self, path: &str, is_dir: bool) -> bool {
        self.matcher.matched(path, is_dir).is_ignore()
    }
}

#[cfg(test)]
mod tests {
    use super::{Diagnostic, FixSummary, PatternMatcher, Severity};

    #[test]
    fn fix_summary_counts_only_fixable_diagnostics() {
        let mut summary = FixSummary::default();
        summary.record(&Diagnostic {
            file: "docs/guide.md".to_string(),
            line: 1,
            column: 1,
            severity: Severity::Error,
            rule: "prefer-links-for-local-paths".to_string(),
            message: "rewrite".to_string(),
            fixable: true,
        });
        summary.record(&Diagnostic {
            file: "docs/guide.md".to_string(),
            line: 2,
            column: 1,
            severity: Severity::Error,
            rule: "unresolved-link-path".to_string(),
            message: "broken link".to_string(),
            fixable: false,
        });

        assert_eq!(summary.fixable_count, 1);
        assert!(
            summary
                .fixable_rules
                .contains("prefer-links-for-local-paths")
        );
        assert!(!summary.fixable_rules.contains("unresolved-link-path"));
    }

    #[test]
    fn pattern_matcher_uses_gitignore_style_matching() {
        let matcher =
            PatternMatcher::new(&["docs/**/*.md".to_string(), "README.md".to_string()]).unwrap();

        assert!(matcher.is_match("docs/guide/intro.md", false));
        assert!(matcher.is_match("README.md", false));
        assert!(!matcher.is_match("src/main.rs", false));
    }
}