docpact 0.1.9

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

use crate::AppExit;
use crate::cli::{ListRulesArgs, ListRulesOutputFormat};
use crate::config::{load_impact_files, resolve_rule_path, root_dir_from_option};
use crate::reporters::OutputWarning;
use crate::rules::RequiredDocMode;

pub const LIST_RULES_SCHEMA_VERSION: &str = "docpact.list-rules.v1";

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ListRulesReport {
    pub schema_version: String,
    pub tool_name: String,
    pub tool_version: String,
    pub command: String,
    pub warnings: Vec<OutputWarning>,
    pub rule_count: usize,
    pub rules: Vec<ListedRule>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ListedRule {
    pub id: String,
    pub scope: String,
    pub repo: String,
    pub description: String,
    pub rule_source: String,
    pub config_source: String,
    pub provenance_kind: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub workspace_profile: Option<String>,
    pub base_dir: String,
    pub trigger_count: usize,
    pub required_doc_count: usize,
    pub triggers: Vec<ListedTrigger>,
    pub required_docs: Vec<ListedRequiredDoc>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ListedTrigger {
    pub original_path: String,
    pub path: String,
    pub kind: Option<String>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ListedRequiredDoc {
    pub original_path: String,
    pub path: String,
    pub mode: String,
}

pub fn run(args: ListRulesArgs) -> Result<AppExit> {
    let report = execute(&args)?;
    emit_report(&report, args.format);
    Ok(AppExit::Success)
}

pub fn execute(args: &ListRulesArgs) -> Result<ListRulesReport> {
    let root_dir = root_dir_from_option(args.root.as_deref())?;
    let loaded_rules = load_impact_files(&root_dir, args.config.as_deref())?;
    let rules = loaded_rules
        .into_iter()
        .map(|loaded| {
            let triggers = loaded
                .rule
                .triggers
                .iter()
                .map(|trigger| ListedTrigger {
                    original_path: trigger.path.clone(),
                    path: resolve_rule_path(&loaded.base_dir, &trigger.path),
                    kind: trigger.kind.clone(),
                })
                .collect::<Vec<_>>();
            let required_docs = loaded
                .rule
                .required_docs
                .iter()
                .map(|doc| ListedRequiredDoc {
                    original_path: doc.path.clone(),
                    path: resolve_rule_path(&loaded.base_dir, &doc.path),
                    mode: RequiredDocMode::from_option(doc.mode.as_deref())
                        .as_str()
                        .to_string(),
                })
                .collect::<Vec<_>>();

            ListedRule {
                id: loaded.rule.id,
                scope: loaded.rule.scope,
                repo: loaded.rule.repo,
                description: loaded.rule.reason,
                rule_source: loaded.source,
                config_source: loaded.config_source,
                provenance_kind: loaded.provenance.origin_kind.as_str().into(),
                workspace_profile: loaded.provenance.workspace_profile,
                base_dir: loaded.base_dir,
                trigger_count: triggers.len(),
                required_doc_count: required_docs.len(),
                triggers,
                required_docs,
            }
        })
        .collect::<Vec<_>>();

    Ok(ListRulesReport {
        schema_version: LIST_RULES_SCHEMA_VERSION.into(),
        tool_name: env!("CARGO_PKG_NAME").into(),
        tool_version: env!("CARGO_PKG_VERSION").into(),
        command: "list-rules".into(),
        warnings: Vec::new(),
        rule_count: rules.len(),
        rules,
    })
}

fn emit_report(report: &ListRulesReport, format: ListRulesOutputFormat) {
    match format {
        ListRulesOutputFormat::Text => emit_text_report(report),
        ListRulesOutputFormat::Json => println!(
            "{}",
            serde_json::to_string_pretty(report).expect("list-rules report should serialize")
        ),
    }
}

fn emit_text_report(report: &ListRulesReport) {
    println!(
        "Docpact list-rules: {} effective rule(s).",
        report.rule_count
    );

    if report.rules.is_empty() {
        println!("Rules:");
        println!("- none");
        println!(
            "Next: add rules to `.docpact/config.yaml`, or run `docpact doctor --root .` for onboarding diagnostics."
        );
        return;
    }

    println!("Rules:");
    for rule in &report.rules {
        println!(
            "- {}: {} trigger(s) -> {} required doc(s) (source: {}, origin: {}, scope: {}, repo: {})",
            rule.id,
            rule.trigger_count,
            rule.required_doc_count,
            rule.rule_source,
            rule.provenance_kind,
            rule.scope,
            rule.repo,
        );
        println!("  description: {}", rule.description);
        if let Some(workspace_profile) = &rule.workspace_profile {
            println!("  workspace profile: {workspace_profile}");
        }
        if rule.triggers.is_empty() {
            println!("  triggers: none");
        } else {
            for trigger in &rule.triggers {
                match &trigger.kind {
                    Some(kind) => println!(
                        "  trigger: {} (from {}; kind: {})",
                        trigger.path, trigger.original_path, kind
                    ),
                    None => println!(
                        "  trigger: {} (from {})",
                        trigger.path, trigger.original_path
                    ),
                }
            }
        }

        if rule.required_docs.is_empty() {
            println!("  required docs: none");
        } else {
            for required_doc in &rule.required_docs {
                println!(
                    "  expects: {} (from {}; mode: {})",
                    required_doc.path, required_doc.original_path, required_doc.mode
                );
            }
        }
    }
    println!(
        "Next: use `docpact explain <path> --root .` to inspect one path against these rules."
    );
}

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

    use super::{LIST_RULES_SCHEMA_VERSION, execute};
    use crate::cli::{ListRulesArgs, ListRulesOutputFormat};

    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 base_args(root: &std::path::Path) -> ListRulesArgs {
        ListRulesArgs {
            root: Some(root.to_path_buf()),
            config: None,
            format: ListRulesOutputFormat::Json,
        }
    }

    #[test]
    fn list_rules_reports_repo_rules_with_resolved_paths() {
        let root = temp_dir("docpact-list-rules-repo");
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::write(
            root.join(".docpact/config.yaml"),
            r#"version: 1
layout: repo
rules:
  - id: api-docs
    scope: repo
    repo: root
    triggers:
      - path: src/api/**
        kind: code
    requiredDocs:
      - path: docs/api.md
        mode: metadata_refresh_required
    reason: Keep API docs aligned.
"#,
        )
        .expect("config should be written");

        let report = execute(&base_args(&root)).expect("list-rules should execute");

        assert_eq!(report.schema_version, LIST_RULES_SCHEMA_VERSION);
        assert_eq!(report.command, "list-rules");
        assert!(report.warnings.is_empty());
        assert_eq!(report.rule_count, 1);
        let rule = &report.rules[0];
        assert_eq!(rule.id, "api-docs");
        assert_eq!(rule.description, "Keep API docs aligned.");
        assert_eq!(rule.rule_source, ".docpact/config.yaml");
        assert_eq!(rule.config_source, ".docpact/config.yaml");
        assert_eq!(rule.provenance_kind, "root-local");
        assert_eq!(rule.workspace_profile, None);
        assert_eq!(rule.triggers[0].path, "src/api/**");
        assert_eq!(rule.triggers[0].original_path, "src/api/**");
        assert_eq!(rule.required_docs[0].path, "docs/api.md");
        assert_eq!(rule.required_docs[0].mode, "metadata_refresh_required");
    }

    #[test]
    fn list_rules_reports_workspace_rules_with_repo_relative_paths() {
        let root = temp_dir("docpact-list-rules-workspace");
        fs::create_dir_all(root.join(".docpact")).expect("doc root should exist");
        fs::create_dir_all(root.join("service/.docpact")).expect("workspace doc root should exist");
        fs::write(
            root.join(".docpact/config.yaml"),
            r#"version: 1
layout: workspace
workspace:
  name: demo
  profiles:
    default:
      rules:
        - id: service-docs
          scope: workspace
          repo: workspace
          triggers:
            - path: src/**
              kind: code
          requiredDocs:
            - path: docs/service.md
          reason: Keep service docs aligned.
rules:
  - id: root-only
    scope: workspace
    repo: workspace
    triggers:
      - path: AGENTS.md
        kind: doc
    requiredDocs:
      - path: .docpact/config.yaml
    reason: Root rule.
"#,
        )
        .expect("root config should be written");
        fs::write(
            root.join("service/.docpact/config.yaml"),
            r#"version: 1
layout: repo
inherit:
  workspace_profile: default
overrides:
  rules:
    replace:
      - id: service-docs
        scope: repo
        repo: service
        triggers:
          - path: src/**
            kind: code
        requiredDocs:
          - path: docs/service.md
        reason: Keep service docs aligned.
"#,
        )
        .expect("workspace config should be written");

        let report = execute(&base_args(&root)).expect("list-rules should execute");

        assert_eq!(report.rule_count, 2);
        let inherited_rule = report
            .rules
            .iter()
            .find(|rule| rule.id == "service-docs")
            .expect("inherited rule should exist");
        assert_eq!(
            inherited_rule.rule_source,
            "service/.docpact/config.yaml#overrides.rules.replace.service-docs"
        );
        assert_eq!(inherited_rule.config_source, "service/.docpact/config.yaml");
        assert_eq!(inherited_rule.provenance_kind, "override-replace");
        assert_eq!(inherited_rule.workspace_profile.as_deref(), Some("default"));
        assert_eq!(inherited_rule.base_dir, "service");
        assert_eq!(inherited_rule.triggers[0].path, "service/src/**");
        assert_eq!(
            inherited_rule.required_docs[0].path,
            "service/docs/service.md"
        );
        assert_eq!(inherited_rule.required_docs[0].mode, "review_or_update");

        let root_rule = report
            .rules
            .iter()
            .find(|rule| rule.id == "root-only")
            .expect("root rule should exist");
        assert_eq!(root_rule.rule_source, ".docpact/config.yaml");
        assert_eq!(root_rule.provenance_kind, "root-local");
    }
}