provenance-cli 0.2.1

Command-line interface for Provenance requirements traceability
use crate::output::{self, OutputFormat};
use camino::Utf8Path;
use provenance_core::coverage::{EvidenceDiffReport, EvidenceDiffState, EvidenceSiteKind};
use provenance_core::ScopeId;
use provenance_store::{cache, layout::ProvenanceLayout};
use std::fmt::Write;

pub(super) use provenance_store::stale::{gate, git};

pub(super) fn handle(
    repo: &Utf8Path,
    scope: String,
    base: Option<String>,
    head: Option<String>,
    since: Option<String>,
    strict: bool,
    format: OutputFormat,
) -> anyhow::Result<()> {
    let (base, head) = git::resolve_range(repo, base, head, since)?;
    let graph = cache::graph_evidence(&ProvenanceLayout::new(repo), &ScopeId::new(scope)?, false)?;
    let base_files = git::revision_files(repo, &base)?;
    let head_files = git::revision_files(repo, &head)?;
    let changes = git::changed_files(repo, &base, &head)?;
    let report = gate::report(repo, base, head, base_files, head_files, &changes, &graph);
    match format {
        OutputFormat::Json | OutputFormat::Jsonl => output::print(format, &report)?,
        OutputFormat::Markdown | OutputFormat::Table | OutputFormat::Toon => {
            print!("{}", render_human(&report)?);
        }
    }
    if strict && (report.summary.touched > 0 || report.summary.gone > 0) {
        anyhow::bail!(
            "evidence diff found {} touched and {} gone site(s); rerun without --strict to inspect",
            report.summary.touched,
            report.summary.gone
        );
    }
    Ok(())
}

fn render_human(report: &EvidenceDiffReport) -> anyhow::Result<String> {
    let mut output = String::from("# Evidence Diff\n\n");
    writeln!(output, "- Range: `{}`..`{}`", report.base, report.head)?;
    writeln!(output, "- Files changed: {}", report.files_changed)?;
    writeln!(output, "- Evidence sites: {}", report.summary.total_sites)?;
    writeln!(output, "- Untouched: {}", report.summary.untouched)?;
    writeln!(output, "- Touched: {}", report.summary.touched)?;
    writeln!(output, "- Moved: {}", report.summary.moved)?;
    writeln!(output, "- Gone: {}\n", report.summary.gone)?;
    for site in &report.sites {
        let coordinate = site.line.map_or_else(
            || format!("`{}`", site.file_path),
            |line| format!("`{}`:{line}", site.file_path),
        );
        let original = match (&site.original_file_path, site.original_line) {
            (Some(path), Some(line)) => format!(" from `{path}`:{line}"),
            (Some(path), None) => format!(" from `{path}`"),
            (None, Some(line)) => format!(" from line {line}"),
            (None, None) => String::new(),
        };
        writeln!(
            output,
            "- **{}** {} `{}` at {}{}",
            state_word(site.state),
            kind_word(site.kind),
            site.subject_id,
            coordinate,
            original
        )?;
    }
    Ok(output)
}

const fn state_word(state: EvidenceDiffState) -> &'static str {
    match state {
        EvidenceDiffState::Untouched => "untouched",
        EvidenceDiffState::Touched => "touched (re-verify wanted)",
        EvidenceDiffState::Moved => "moved",
        EvidenceDiffState::Gone => "gone",
    }
}

const fn kind_word(kind: EvidenceSiteKind) -> &'static str {
    match kind {
        EvidenceSiteKind::RuleBinding => "rule binding",
        EvidenceSiteKind::Verification => "verification",
        EvidenceSiteKind::Annotation => "annotation",
        EvidenceSiteKind::SourceReference => "source reference",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use provenance_core::coverage::{EvidenceDiffSite, EvidenceDiffSummary};

    #[test]
    fn human_report_names_reverification_without_firing_it() {
        let report = EvidenceDiffReport {
            base: "base".into(),
            head: "head".into(),
            files_changed: 1,
            summary: EvidenceDiffSummary {
                total_sites: 1,
                touched: 1,
                ..EvidenceDiffSummary::default()
            },
            sites: vec![EvidenceDiffSite {
                kind: EvidenceSiteKind::RuleBinding,
                subject_id: "rule_guard".into(),
                file_path: "src/guard.rs".into(),
                line: Some(4),
                end_line: Some(8),
                state: EvidenceDiffState::Touched,
                original_file_path: None,
                original_line: None,
            }],
        };

        let rendered = render_human(&report).unwrap();
        assert!(rendered.contains("touched (re-verify wanted)"));
        assert!(!rendered.contains("agent"));
    }
}