docpact 0.1.0

Deterministic documentation governance CLI for AI-assisted software teams.
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;

use crate::config::{LoadedRule, normalize_path, resolve_rule_path};

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RequiredDocMode {
    ReviewOrUpdate,
    MetadataRefreshRequired,
    BodyUpdateRequired,
    MustExist,
}

impl RequiredDocMode {
    pub fn from_option(value: Option<&str>) -> Self {
        match value {
            Some("metadata_refresh_required") => Self::MetadataRefreshRequired,
            Some("body_update_required") => Self::BodyUpdateRequired,
            Some("must_exist") => Self::MustExist,
            Some("review_or_update") | None | Some(_) => Self::ReviewOrUpdate,
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::ReviewOrUpdate => "review_or_update",
            Self::MetadataRefreshRequired => "metadata_refresh_required",
            Self::BodyUpdateRequired => "body_update_required",
            Self::MustExist => "must_exist",
        }
    }
}

impl fmt::Display for RequiredDocMode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchedRule {
    pub changed_path: String,
    pub source: String,
    pub base_dir: String,
    pub rule: crate::config::Rule,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpectedDoc {
    pub path: String,
    pub rules: BTreeSet<String>,
    pub changed_paths: BTreeSet<String>,
    pub modes: BTreeSet<RequiredDocMode>,
}

pub fn matches_pattern(file_path: &str, pattern: &str) -> bool {
    let file_path = normalize_path(file_path);
    let pattern = normalize_path(pattern);
    let file_bytes = file_path.as_bytes();
    let pattern_bytes = pattern.as_bytes();
    let mut memo = BTreeMap::new();
    matches_recursive(file_bytes, pattern_bytes, 0, 0, &mut memo)
}

fn matches_recursive(
    file: &[u8],
    pattern: &[u8],
    file_index: usize,
    pattern_index: usize,
    memo: &mut BTreeMap<(usize, usize), bool>,
) -> bool {
    if let Some(value) = memo.get(&(file_index, pattern_index)) {
        return *value;
    }

    let matched = if pattern_index == pattern.len() {
        file_index == file.len()
    } else {
        match pattern[pattern_index] {
            b'*' if pattern_index + 1 < pattern.len() && pattern[pattern_index + 1] == b'*' => {
                let next_pattern = pattern_index + 2;
                (file_index..=file.len()).any(|candidate| {
                    matches_recursive(file, pattern, candidate, next_pattern, memo)
                })
            }
            b'*' => {
                let next_pattern = pattern_index + 1;
                let mut candidate = file_index;
                loop {
                    if matches_recursive(file, pattern, candidate, next_pattern, memo) {
                        break true;
                    }
                    if candidate == file.len() || file[candidate] == b'/' {
                        break false;
                    }
                    candidate += 1;
                }
            }
            b'?' => {
                file_index < file.len()
                    && file[file_index] != b'/'
                    && matches_recursive(file, pattern, file_index + 1, pattern_index + 1, memo)
            }
            byte => {
                file_index < file.len()
                    && file[file_index] == byte
                    && matches_recursive(file, pattern, file_index + 1, pattern_index + 1, memo)
            }
        }
    };

    memo.insert((file_index, pattern_index), matched);
    matched
}

pub fn match_rules(changed_paths: &[String], loaded_rules: &[LoadedRule]) -> Vec<MatchedRule> {
    let mut matches = Vec::new();

    for changed_path in changed_paths {
        for loaded in loaded_rules {
            if loaded.rule.triggers.iter().any(|trigger| {
                matches_pattern(
                    changed_path,
                    &resolve_rule_path(&loaded.base_dir, &trigger.path),
                )
            }) {
                matches.push(MatchedRule {
                    changed_path: changed_path.clone(),
                    source: loaded.source.clone(),
                    base_dir: loaded.base_dir.clone(),
                    rule: loaded.rule.clone(),
                });
            }
        }
    }

    matches
}

pub fn collect_expected_docs(matches: &[MatchedRule]) -> BTreeMap<String, ExpectedDoc> {
    let mut expected = BTreeMap::new();

    for matched in matches {
        for doc in &matched.rule.required_docs {
            let full_path = resolve_rule_path(&matched.base_dir, &doc.path);
            let entry = expected
                .entry(full_path.clone())
                .or_insert_with(|| ExpectedDoc {
                    path: full_path,
                    rules: BTreeSet::new(),
                    changed_paths: BTreeSet::new(),
                    modes: BTreeSet::new(),
                });

            entry.rules.insert(matched.rule.id.clone());
            entry.changed_paths.insert(matched.changed_path.clone());
            entry
                .modes
                .insert(RequiredDocMode::from_option(doc.mode.as_deref()));
        }
    }

    expected
}

#[cfg(test)]
mod tests {
    use crate::config::{LoadedRule, RequiredDoc, Rule, Trigger};

    use super::{RequiredDocMode, collect_expected_docs, match_rules, matches_pattern};

    #[test]
    fn glob_matching_supports_repo_relative_paths() {
        assert!(matches_pattern(
            "tiangong-lca-next/config/routes.ts",
            "tiangong-lca-next/**"
        ));
        assert!(matches_pattern(
            ".docpact/quality-rubric.md",
            ".docpact/*.md"
        ));
        assert!(!matches_pattern(".docpact/nested/file.md", ".docpact/*.md"));
    }

    #[test]
    fn matching_and_expected_docs_resolve_repo_paths() {
        let loaded = vec![
            LoadedRule {
                source: ".docpact/config.yaml".into(),
                base_dir: String::new(),
                rule: Rule {
                    id: "root-rule".into(),
                    scope: "workspace".into(),
                    repo: "workspace".into(),
                    triggers: vec![Trigger {
                        path: "AGENTS.md".into(),
                        kind: Some("doc-contract".into()),
                    }],
                    required_docs: vec![RequiredDoc {
                        path: ".docpact/config.yaml".into(),
                        mode: Some("review_or_update".into()),
                    }],
                    reason: "root".into(),
                },
            },
            LoadedRule {
                source: "subrepo/.docpact/config.yaml".into(),
                base_dir: "subrepo".into(),
                rule: Rule {
                    id: "repo-rule".into(),
                    scope: "repo".into(),
                    repo: "subrepo".into(),
                    triggers: vec![Trigger {
                        path: "src/**".into(),
                        kind: Some("code".into()),
                    }],
                    required_docs: vec![RequiredDoc {
                        path: ".docpact/config.yaml".into(),
                        mode: Some("review_or_update".into()),
                    }],
                    reason: "repo".into(),
                },
            },
        ];

        let matches = match_rules(
            &["AGENTS.md".into(), "subrepo/src/index.ts".into()],
            &loaded,
        );
        let expected = collect_expected_docs(&matches);

        assert_eq!(matches.len(), 2);
        assert_eq!(
            expected.keys().cloned().collect::<Vec<_>>(),
            vec![
                ".docpact/config.yaml".to_string(),
                "subrepo/.docpact/config.yaml".to_string()
            ]
        );
        assert_eq!(
            expected[".docpact/config.yaml"].modes,
            [RequiredDocMode::ReviewOrUpdate].into_iter().collect()
        );
    }
}