docpact 0.1.2

Deterministic documentation governance CLI for AI-assisted software teams.
Documentation
use miette::Result;

use crate::AppExit;
use crate::cli::ValidateConfigArgs;
use crate::config::{
    load_catalog_configs, load_coverage_configs, load_doc_inventory_configs,
    load_freshness_configs, load_impact_files, load_ownership_configs, load_routing_configs,
    root_dir_from_option, validate_config_graph, validate_loaded_catalog_configs,
    validate_loaded_coverage_configs, validate_loaded_doc_inventory_configs,
    validate_loaded_freshness_configs, validate_loaded_ownership_configs,
    validate_loaded_routing_configs, validate_loaded_rules, validate_ownership_path_conflicts,
};
use crate::git::get_tracked_paths;

pub fn run(args: ValidateConfigArgs) -> Result<AppExit> {
    let root_dir = root_dir_from_option(args.root.as_deref())?;
    let rules = load_impact_files(&root_dir, args.config.as_deref())?;
    let coverage_configs = load_coverage_configs(&root_dir, args.config.as_deref())?;
    let freshness_configs = load_freshness_configs(&root_dir, args.config.as_deref())?;
    let routing_configs = load_routing_configs(&root_dir, args.config.as_deref())?;
    let doc_inventory_configs = load_doc_inventory_configs(&root_dir, args.config.as_deref())?;
    let catalog_configs = load_catalog_configs(&root_dir, args.config.as_deref())?;
    let ownership_configs = load_ownership_configs(&root_dir, args.config.as_deref())?;

    if !args.strict {
        println!(
            "Docpact config loaded successfully: {} rule(s).",
            rules.len()
        );
        return Ok(AppExit::Success);
    }

    let mut problems = validate_config_graph(&root_dir, args.config.as_deref())?;
    problems.extend(validate_loaded_rules(&rules));
    problems.extend(validate_loaded_coverage_configs(&coverage_configs));
    problems.extend(validate_loaded_freshness_configs(&freshness_configs));
    problems.extend(validate_loaded_routing_configs(&routing_configs));
    problems.extend(validate_loaded_doc_inventory_configs(
        &doc_inventory_configs,
    ));
    problems.extend(validate_loaded_catalog_configs(&catalog_configs));
    problems.extend(validate_loaded_ownership_configs(
        &ownership_configs,
        &catalog_configs,
    ));
    let ownership_domain_count = ownership_configs
        .iter()
        .map(|config| config.ownership.domains.len())
        .sum::<usize>();
    if ownership_domain_count > 0 {
        let tracked_paths = get_tracked_paths(&root_dir)?;
        let analysis = crate::config::analyze_ownership_paths(&tracked_paths, &ownership_configs);
        problems.extend(validate_ownership_path_conflicts(&analysis));
    }
    problems.sort_by(|left, right| {
        (&left.source, &left.rule_id, &left.message).cmp(&(
            &right.source,
            &right.rule_id,
            &right.message,
        ))
    });
    if problems.is_empty() {
        println!(
            "Docpact strict config validation passed: {} rule(s).",
            rules.len()
        );
        return Ok(AppExit::Success);
    }

    println!("Docpact found invalid config definitions:");
    for problem in problems {
        match problem.rule_id {
            Some(rule_id) => {
                println!(
                    "- [invalid-config] {} (rule `{}`): {}",
                    problem.source, rule_id, problem.message
                );
            }
            None => {
                println!("- [invalid-config] {}: {}", problem.source, problem.message);
            }
        }
    }

    Ok(AppExit::LintFailure)
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;
    use std::process::Command;
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::run;
    use crate::AppExit;
    use crate::cli::ValidateConfigArgs;
    use crate::config::{CONFIG_FILE, DOC_ROOT_DIR};

    fn temp_dir(prefix: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be valid")
            .as_nanos();
        let path = std::env::temp_dir().join(format!("{prefix}-{nanos}-{}", std::process::id()));
        fs::create_dir_all(&path).expect("temp dir should be created");
        path
    }

    fn git(root: &std::path::Path, args: &[&str]) -> String {
        let output = Command::new("git")
            .args(args)
            .current_dir(root)
            .output()
            .expect("git command should run");
        assert!(
            output.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout)
            .expect("git stdout should be utf-8")
            .trim()
            .to_string()
    }

    fn init_git_repo(root: &std::path::Path) {
        fs::create_dir_all(root).expect("repo root should exist");
        git(root, &["init"]);
        git(root, &["config", "user.name", "Docpact Tests"]);
        git(root, &["config", "user.email", "docpact@example.com"]);
    }

    #[test]
    fn strict_validate_config_returns_lint_failure_for_invalid_rules() {
        let root = temp_dir("docpact-validate-config");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root should exist");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: repo
lastReviewedAt: "2026-04-21"
lastReviewedCommit: "abc"
repo:
  id: example
rules:
  - id: duplicate-rule
    scope: repo
    repo: example
    triggers:
      - path: src/***
        kind: code
    requiredDocs:
      - path: docs/*.md
        mode: invalid_mode
    reason: example
  - id: duplicate-rule
    scope: repo
    repo: example
    triggers: []
    requiredDocs: []
    reason: second
"#,
        )
        .expect("config should be written");

        let exit = run(ValidateConfigArgs {
            root: Some(root),
            config: None,
            strict: true,
        })
        .expect("strict validation should execute");

        assert_eq!(exit, AppExit::LintFailure);
    }

    #[test]
    fn non_strict_validate_config_remains_compatible() {
        let root = temp_dir("docpact-validate-config-compat");
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root should exist");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: repo
lastReviewedAt: "2026-04-21"
lastReviewedCommit: "abc"
repo:
  id: example
rules:
  - id: compatibility-check
    scope: repo
    repo: example
    triggers:
      - path: src/***
        kind: code
    requiredDocs:
      - path: docs/*.md
        mode: invalid_mode
    reason: example
"#,
        )
        .expect("config should be written");

        let exit = run(ValidateConfigArgs {
            root: Some(root),
            config: None,
            strict: false,
        })
        .expect("non-strict validation should execute");

        assert_eq!(exit, AppExit::Success);
    }

    #[test]
    fn strict_validate_config_fails_for_tracked_path_ownership_conflicts() {
        let root = temp_dir("docpact-validate-ownership-conflict");
        init_git_repo(&root);
        fs::create_dir_all(root.join(DOC_ROOT_DIR)).expect("doc root should exist");
        fs::create_dir_all(root.join("src/conflict")).expect("src dir should exist");

        fs::write(
            root.join(CONFIG_FILE),
            r#"
version: 1
layout: repo
catalog:
  repos:
    - id: app
      path: .
    - id: edge
      path: edge
ownership:
  domains:
    - id: app-domain
      paths:
        include:
          - src/**
      ownerRepo: app
    - id: edge-domain
      paths:
        include:
          - src/conflict/**
      ownerRepo: edge
rules: []
"#,
        )
        .expect("config should be written");
        fs::write(root.join("src/conflict/index.ts"), "export const x = 1;\n")
            .expect("tracked file should be written");
        git(&root, &["add", "."]);
        git(&root, &["commit", "-m", "Add conflict sample"]);

        let exit = run(ValidateConfigArgs {
            root: Some(root),
            config: None,
            strict: true,
        })
        .expect("strict validation should execute");

        assert_eq!(exit, AppExit::LintFailure);
    }
}