govctl 0.19.0

Project governance CLI for RFC, ADR, and Work Item management
use crate::config::Config;
use crate::diagnostic::DiagnosticResult;
use crate::model::ProjectIndex;
use crate::reference_pattern;
use regex::Regex;

pub(super) fn clause_deletion_referrers(
    config: &Config,
    index: &ProjectIndex,
    clause_id: &str,
) -> DiagnosticResult<Vec<String>> {
    let mut referrers = project_referrers(index, clause_id, None, false);
    referrers.extend(clause_supersession_referrers(index, clause_id));
    let inline_re = reference_pattern::compile(
        &config.source_scan.pattern,
        config
            .display_path(&config.gov_root.join("config.toml"))
            .display()
            .to_string(),
    )?;
    referrers.extend(inline_clause_referrers(index, &inline_re, clause_id));
    referrers.extend(guard_referrers(config, clause_id)?);
    referrers.extend(
        crate::parse::load_conformance_cases(config)?
            .into_iter()
            .filter(|case| {
                case.spec
                    .case
                    .requirements
                    .iter()
                    .any(|requirement| requirement.clause_ref == clause_id)
            })
            .map(|case| case.meta().id.clone()),
    );
    referrers.sort();
    referrers.dedup();
    Ok(referrers)
}

fn clause_supersession_referrers(index: &ProjectIndex, target_id: &str) -> Vec<String> {
    index
        .rfcs
        .iter()
        .flat_map(|rfc| {
            rfc.clauses.iter().filter_map(move |clause| {
                let replacement = clause.spec.superseded_by.as_deref()?;
                let qualified = if replacement.contains(':') {
                    replacement.to_string()
                } else {
                    format!("{}:{replacement}", rfc.rfc.rfc_id)
                };
                (qualified == target_id)
                    .then(|| format!("{}:{}", rfc.rfc.rfc_id, clause.spec.clause_id))
            })
        })
        .collect()
}

fn inline_clause_referrers(
    index: &ProjectIndex,
    inline_re: &Regex,
    target_id: &str,
) -> Vec<String> {
    let mut referrers = Vec::new();

    for rfc in &index.rfcs {
        for clause in &rfc.clauses {
            let source_id = format!("{}:{}", rfc.rfc.rfc_id, clause.spec.clause_id);
            if source_id != target_id && text_references(inline_re, &clause.spec.text, target_id) {
                referrers.push(source_id);
            }
        }
        if rfc.rfc.changelog.iter().any(|entry| {
            entry
                .notes
                .as_deref()
                .is_some_and(|text| text_references(inline_re, text, target_id))
                || [
                    &entry.added,
                    &entry.changed,
                    &entry.deprecated,
                    &entry.removed,
                    &entry.fixed,
                    &entry.security,
                ]
                .into_iter()
                .flatten()
                .any(|text| text_references(inline_re, text, target_id))
        }) {
            referrers.push(rfc.rfc.rfc_id.clone());
        }
    }

    for adr in &index.adrs {
        let content = &adr.spec.content;
        let direct_content = [&content.context, &content.decision, &content.consequences];
        let alternative_content = content.alternatives.iter().flat_map(|alternative| {
            std::iter::once(&alternative.text)
                .chain(alternative.pros.iter())
                .chain(alternative.cons.iter())
                .chain(alternative.rejection_reason.iter())
        });
        if direct_content
            .into_iter()
            .chain(alternative_content)
            .any(|text| text_references(inline_re, text, target_id))
        {
            referrers.push(adr.spec.govctl.id.clone());
        }
    }

    for work in &index.work_items {
        let content = &work.spec.content;
        let mut direct_content = std::iter::once(&content.description)
            .chain(content.acceptance_criteria.iter().map(|item| &item.text))
            .chain(content.notes.iter());
        if direct_content.any(|text| text_references(inline_re, text, target_id)) {
            referrers.push(work.spec.govctl.id.clone());
        }
    }

    referrers
}

fn text_references(inline_re: &Regex, text: &str, target_id: &str) -> bool {
    inline_re
        .captures_iter(text)
        .filter_map(|captures| reference_pattern::target_capture(&captures).ok())
        .any(|target| target.as_str() == target_id)
}

pub(super) fn work_item_deletion_referrers(index: &ProjectIndex, work_id: &str) -> Vec<String> {
    project_referrers(index, work_id, Some(work_id), true)
}

fn project_referrers(
    index: &ProjectIndex,
    target_id: &str,
    skip_work_id: Option<&str>,
    include_work_dependencies: bool,
) -> Vec<String> {
    let mut referenced_by = Vec::new();

    for rfc in &index.rfcs {
        if rfc.rfc.refs.iter().any(|ref_id| ref_id == target_id) {
            referenced_by.push(rfc.rfc.rfc_id.clone());
        }
    }

    for adr in &index.adrs {
        if adr
            .spec
            .govctl
            .refs
            .iter()
            .any(|ref_id| ref_id == target_id)
        {
            referenced_by.push(adr.spec.govctl.id.clone());
        }
    }

    for work in &index.work_items {
        if skip_work_id == Some(work.spec.govctl.id.as_str()) {
            continue;
        }

        let has_ref = work
            .spec
            .govctl
            .refs
            .iter()
            .any(|ref_id| ref_id == target_id);
        let has_dependency = include_work_dependencies
            && work
                .spec
                .govctl
                .depends_on
                .iter()
                .any(|dep_id| dep_id == target_id);

        if has_ref || has_dependency {
            referenced_by.push(work.spec.govctl.id.clone());
        }
    }

    referenced_by
}

fn guard_referrers(config: &Config, target_id: &str) -> DiagnosticResult<Vec<String>> {
    Ok(crate::parse::load_guards(config)?
        .into_iter()
        .filter(|guard| guard.meta().refs.iter().any(|ref_id| ref_id == target_id))
        .map(|guard| guard.meta().id.clone())
        .collect())
}