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 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 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}