libverify-output 0.12.0

Output formatters (SARIF, JSON, Matrix, Vanta) for libverify verification results
Documentation
use anyhow::Result;
use libverify_core::assessment::{AssessmentReport, BatchEntry, BatchReport, VerificationResult};
use libverify_core::profile::GateDecision;

pub fn render(result: &VerificationResult, only_failures: bool) -> Result<String> {
    if only_failures {
        let filtered = filter_result(result);
        Ok(serde_json::to_string_pretty(&filtered)?)
    } else {
        Ok(serde_json::to_string_pretty(result)?)
    }
}

pub fn render_batch(batch: &BatchReport, only_failures: bool) -> Result<String> {
    if only_failures {
        let filtered = filter_batch(batch);
        Ok(serde_json::to_string_pretty(&filtered)?)
    } else {
        Ok(serde_json::to_string_pretty(batch)?)
    }
}

fn filter_result(result: &VerificationResult) -> VerificationResult {
    let report = &result.report;
    let mut filtered_findings = Vec::new();
    let mut filtered_outcomes = Vec::new();

    for (finding, outcome) in report.findings.iter().zip(report.outcomes.iter()) {
        if outcome.decision == GateDecision::Fail {
            filtered_findings.push(finding.clone());
            filtered_outcomes.push(outcome.clone());
        }
    }

    VerificationResult {
        report: AssessmentReport {
            profile_name: report.profile_name.clone(),
            findings: filtered_findings,
            outcomes: filtered_outcomes,
            severity_labels: report.severity_labels.clone(),
        },
        evidence: result.evidence.clone(),
    }
}

fn filter_batch(batch: &BatchReport) -> BatchReport {
    BatchReport {
        reports: batch
            .reports
            .iter()
            .map(|entry| BatchEntry {
                subject_id: entry.subject_id.clone(),
                result: filter_result(&entry.result),
            })
            .collect(),
        total_pass: batch.total_pass,
        total_review: batch.total_review,
        total_fail: batch.total_fail,
        skipped: batch.skipped.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use libverify_core::assessment::AssessmentReport;
    use libverify_core::control::{ControlFinding, builtin};
    use libverify_core::profile::{FindingSeverity, ProfileOutcome};

    fn sample_result() -> VerificationResult {
        VerificationResult {
            report: AssessmentReport {
                profile_name: "test".to_string(),
                findings: vec![
                    ControlFinding::satisfied(
                        builtin::id(builtin::REVIEW_INDEPENDENCE),
                        "approved",
                        vec!["pr:1".to_string()],
                    ),
                    ControlFinding::violated(
                        builtin::id(builtin::SOURCE_AUTHENTICITY),
                        "unsigned",
                        vec!["pr:1".to_string()],
                    ),
                ],
                outcomes: vec![
                    ProfileOutcome {
                        control_id: builtin::id(builtin::REVIEW_INDEPENDENCE),
                        severity: FindingSeverity::Info,
                        decision: GateDecision::Pass,
                        rationale: "approved".to_string(),
                        annotations: Default::default(),
                    },
                    ProfileOutcome {
                        control_id: builtin::id(builtin::SOURCE_AUTHENTICITY),
                        severity: FindingSeverity::Error,
                        decision: GateDecision::Fail,
                        rationale: "unsigned".to_string(),
                        annotations: Default::default(),
                    },
                ],
                severity_labels: Default::default(),
            },
            evidence: None,
        }
    }

    #[test]
    fn render_produces_valid_json() {
        let output = render(&sample_result(), false).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        assert_eq!(parsed["profile_name"], "test");
        assert_eq!(parsed["findings"].as_array().unwrap().len(), 2);
    }

    #[test]
    fn render_with_only_failures_filters_to_fail_only() {
        let output = render(&sample_result(), true).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let findings = parsed["findings"].as_array().unwrap();
        assert_eq!(findings.len(), 1);
        let outcomes = parsed["outcomes"].as_array().unwrap();
        assert_eq!(outcomes.len(), 1);
        assert_eq!(outcomes[0]["decision"], "fail");
    }

    #[test]
    fn render_batch_produces_valid_json() {
        let batch = BatchReport {
            reports: vec![BatchEntry {
                subject_id: "owner/repo".to_string(),
                result: sample_result(),
            }],
            total_pass: 1,
            total_review: 0,
            total_fail: 1,
            skipped: vec![],
        };
        let output = render_batch(&batch, false).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        assert_eq!(parsed["reports"].as_array().unwrap().len(), 1);
    }

    #[test]
    fn render_batch_with_only_failures_filters() {
        let batch = BatchReport {
            reports: vec![BatchEntry {
                subject_id: "owner/repo".to_string(),
                result: sample_result(),
            }],
            total_pass: 1,
            total_review: 0,
            total_fail: 1,
            skipped: vec![],
        };
        let output = render_batch(&batch, true).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let outcomes = parsed["reports"][0]["outcomes"].as_array().unwrap();
        assert_eq!(outcomes.len(), 1);
        assert_eq!(outcomes[0]["decision"], "fail");
    }

    #[test]
    fn filter_result_keeps_only_fail_decisions() {
        let filtered = filter_result(&sample_result());
        assert_eq!(filtered.report.findings.len(), 1);
        assert_eq!(filtered.report.outcomes.len(), 1);
        assert_eq!(filtered.report.outcomes[0].decision, GateDecision::Fail);
    }

    #[test]
    fn filter_result_excludes_pass_and_review() {
        let filtered = filter_result(&sample_result());
        for outcome in &filtered.report.outcomes {
            assert_eq!(outcome.decision, GateDecision::Fail);
        }
    }

    #[test]
    fn filter_batch_applies_filter_to_all_entries() {
        let batch = BatchReport {
            reports: vec![
                BatchEntry {
                    subject_id: "repo1".to_string(),
                    result: sample_result(),
                },
                BatchEntry {
                    subject_id: "repo2".to_string(),
                    result: sample_result(),
                },
            ],
            total_pass: 2,
            total_review: 0,
            total_fail: 2,
            skipped: vec![],
        };
        let filtered = filter_batch(&batch);
        assert_eq!(filtered.reports.len(), 2);
        for entry in &filtered.reports {
            assert_eq!(entry.result.report.outcomes.len(), 1);
            assert_eq!(entry.result.report.outcomes[0].decision, GateDecision::Fail);
        }
    }
}