Skip to main content

libverify_output/
matrix.rs

1use std::collections::BTreeMap;
2
3use anyhow::Result;
4use libverify_core::assessment::{BatchReport, VerificationResult};
5use libverify_core::profile::GateDecision;
6
7fn decision_icon(decision: GateDecision) -> &'static str {
8    match decision {
9        GateDecision::Pass => "✅",
10        GateDecision::Review => "⚠️",
11        GateDecision::Fail => "❌",
12    }
13}
14
15fn sort_key(decision: GateDecision) -> u8 {
16    match decision {
17        GateDecision::Fail => 0,
18        GateDecision::Review => 1,
19        GateDecision::Pass => 2,
20    }
21}
22
23pub fn render(result: &VerificationResult, only_failures: bool) -> Result<String> {
24    let report = &result.report;
25
26    let (mut pass, mut review, mut fail) = (0usize, 0usize, 0usize);
27    for o in &report.outcomes {
28        match o.decision {
29            GateDecision::Pass => pass += 1,
30            GateDecision::Review => review += 1,
31            GateDecision::Fail => fail += 1,
32        }
33    }
34
35    let mut outcomes: Vec<_> = report
36        .outcomes
37        .iter()
38        .filter(|o| !only_failures || o.decision == GateDecision::Fail)
39        .collect();
40    outcomes.sort_by_key(|o| sort_key(o.decision));
41
42    let mut out = String::new();
43    out.push_str(&format!(
44        "# Compliance Matrix — {}\n\n",
45        report.profile_name
46    ));
47    out.push_str(&format!(
48        "**Summary:** ✅ {} pass, ⚠️ {} review, ❌ {} fail\n\n",
49        pass, review, fail
50    ));
51
52    out.push_str("| Control | Status | Decision | Framework Ref | Rationale |\n");
53    out.push_str("|---------|--------|----------|---------------|-----------|\n");
54
55    for outcome in outcomes {
56        let status = report.severity_labels.label_for(outcome.severity);
57        let icon = decision_icon(outcome.decision);
58        let framework_ref = outcome
59            .annotations
60            .get("framework_ref")
61            .map(|s| s.as_str())
62            .unwrap_or("");
63        let rationale = outcome.rationale.replace('|', "\\|");
64        out.push_str(&format!(
65            "| {} | {} | {} {} | {} | {} |\n",
66            outcome.control_id,
67            status,
68            icon,
69            outcome.decision,
70            framework_ref,
71            rationale,
72        ));
73    }
74
75    Ok(out)
76}
77
78pub fn render_batch(batch: &BatchReport, only_failures: bool) -> Result<String> {
79    let mut out = String::new();
80    out.push_str("# Compliance Matrix — Batch Report\n\n");
81    out.push_str(&format!(
82        "**Summary:** {} subjects, ✅ {} pass, ⚠️ {} review, ❌ {} fail\n\n",
83        batch.reports.len(),
84        batch.total_pass,
85        batch.total_review,
86        batch.total_fail
87    ));
88
89    // Track worst decision per control across all subjects in one pass.
90    let mut worst: BTreeMap<String, GateDecision> = BTreeMap::new();
91    for entry in &batch.reports {
92        for outcome in &entry.result.report.outcomes {
93            let id = outcome.control_id.to_string();
94            worst
95                .entry(id)
96                .and_modify(|d| {
97                    if sort_key(outcome.decision) < sort_key(*d) {
98                        *d = outcome.decision;
99                    }
100                })
101                .or_insert(outcome.decision);
102        }
103    }
104    // Filter by worst decision, then sort: Fail first, Review, Pass last.
105    let mut control_ids: Vec<String> = worst
106        .iter()
107        .filter(|(_, d)| !only_failures || **d == GateDecision::Fail)
108        .map(|(id, _)| id.clone())
109        .collect();
110    control_ids.sort_by_key(|id| sort_key(*worst.get(id).unwrap()));
111
112    let subject_ids: Vec<&str> = batch.reports.iter().map(|e| e.subject_id.as_str()).collect();
113
114    out.push_str("| Control");
115    for sid in &subject_ids {
116        out.push_str(" | ");
117        out.push_str(sid);
118    }
119    out.push_str(" |\n");
120
121    out.push_str("|--------");
122    for _ in &subject_ids {
123        out.push_str("|--------");
124    }
125    out.push_str("|\n");
126
127    for control_id in &control_ids {
128        out.push_str(&format!("| {}", control_id));
129        for entry in &batch.reports {
130            let icon = entry
131                .result
132                .report
133                .outcomes
134                .iter()
135                .find(|o| o.control_id.as_str() == control_id)
136                .map(|o| decision_icon(o.decision))
137                .unwrap_or("—");
138            out.push_str(&format!(" | {}", icon));
139        }
140        out.push_str(" |\n");
141    }
142
143    if !batch.skipped.is_empty() {
144        out.push('\n');
145        out.push_str("## Skipped\n\n");
146        out.push_str("| Subject | Reason |\n");
147        out.push_str("|---------|--------|\n");
148        for s in &batch.skipped {
149            out.push_str(&format!("| {} | {} |\n", s.subject_id, s.reason));
150        }
151    }
152
153    Ok(out)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use libverify_core::assessment::{AssessmentReport, BatchReport};
160    use libverify_core::control::builtin;
161    use libverify_core::profile::{FindingSeverity, GateDecision, ProfileOutcome, SeverityLabels};
162
163    fn outcome(id: &str, decision: GateDecision) -> ProfileOutcome {
164        ProfileOutcome {
165            control_id: builtin::id(id),
166            severity: FindingSeverity::Info,
167            decision,
168            rationale: "test rationale".to_string(),
169            annotations: Default::default(),
170        }
171    }
172
173    fn result_with_outcomes(outcomes: Vec<ProfileOutcome>) -> VerificationResult {
174        VerificationResult {
175            report: AssessmentReport {
176                profile_name: "test-profile".to_string(),
177                findings: vec![],
178                outcomes,
179                severity_labels: SeverityLabels::default(),
180            },
181            evidence: None,
182        }
183    }
184
185    #[test]
186    fn renders_header_and_summary() {
187        let result = result_with_outcomes(vec![
188            outcome(builtin::REVIEW_INDEPENDENCE, GateDecision::Pass),
189            outcome(builtin::TWO_PARTY_REVIEW, GateDecision::Fail),
190        ]);
191        let output = render(&result, false).unwrap();
192        assert!(output.contains("# Compliance Matrix — test-profile"));
193        assert!(output.contains("1 pass"));
194        assert!(output.contains("1 fail"));
195    }
196
197    #[test]
198    fn only_failures_filters_rows() {
199        let result = result_with_outcomes(vec![
200            outcome(builtin::REVIEW_INDEPENDENCE, GateDecision::Pass),
201            outcome(builtin::TWO_PARTY_REVIEW, GateDecision::Fail),
202        ]);
203        let output = render(&result, true).unwrap();
204        assert!(output.contains(builtin::TWO_PARTY_REVIEW));
205        assert!(!output.contains(builtin::REVIEW_INDEPENDENCE));
206    }
207
208    #[test]
209    fn fail_rows_sorted_first() {
210        let result = result_with_outcomes(vec![
211            outcome(builtin::REVIEW_INDEPENDENCE, GateDecision::Pass),
212            outcome(builtin::TWO_PARTY_REVIEW, GateDecision::Fail),
213        ]);
214        let output = render(&result, false).unwrap();
215        let fail_pos = output.find(builtin::TWO_PARTY_REVIEW).unwrap();
216        let pass_pos = output.find(builtin::REVIEW_INDEPENDENCE).unwrap();
217        assert!(fail_pos < pass_pos);
218    }
219
220    #[test]
221    fn batch_render_cross_subject_matrix() {
222        let entry1 = libverify_core::assessment::BatchEntry {
223            subject_id: "repo-a".to_string(),
224            result: result_with_outcomes(vec![outcome(
225                builtin::REVIEW_INDEPENDENCE,
226                GateDecision::Pass,
227            )]),
228        };
229        let entry2 = libverify_core::assessment::BatchEntry {
230            subject_id: "repo-b".to_string(),
231            result: result_with_outcomes(vec![outcome(
232                builtin::REVIEW_INDEPENDENCE,
233                GateDecision::Fail,
234            )]),
235        };
236        let batch = BatchReport {
237            reports: vec![entry1, entry2],
238            total_pass: 1,
239            total_review: 0,
240            total_fail: 1,
241            skipped: vec![],
242        };
243        let output = render_batch(&batch, false).unwrap();
244        assert!(output.contains("repo-a"));
245        assert!(output.contains("repo-b"));
246        assert!(output.contains(builtin::REVIEW_INDEPENDENCE));
247    }
248}