1use crate::audit_remediation::audit_remediation_items;
2use crate::contracts::REPORT_ARTIFACT;
3use crate::evidence_repair::{evidence_repair_queues, push_evidence_repair_queue_json_fields};
4use crate::json::{
5 option_json, push_json_artifact_header, push_json_artifact_source_context,
6 push_json_status_fields, render_match_outcome_json_compact,
7};
8use crate::{
9 DiffReport, REPORT_COMMAND_DIFF, REPORT_COMMANDS, ReportContext, ReviewSignals, Summary,
10 render_count_fields_with_policy_context,
11};
12use allow_core::{Finding, MatchOutcome, MatchStatus, json_escape, normalize_path};
13
14pub fn render_json(
15 command: &str,
16 findings: &[Finding],
17 outcomes: &[MatchOutcome],
18 failed: bool,
19) -> String {
20 render_json_with_context(
21 command,
22 findings,
23 outcomes,
24 failed,
25 ReportContext::default(),
26 )
27}
28
29pub fn render_json_with_context(
30 command: &str,
31 findings: &[Finding],
32 outcomes: &[MatchOutcome],
33 failed: bool,
34 context: ReportContext<'_>,
35) -> String {
36 render_json_report(command, findings, outcomes, failed, context, None)
37}
38
39pub fn render_json_with_context_and_diff(
40 command: &str,
41 findings: &[Finding],
42 outcomes: &[MatchOutcome],
43 failed: bool,
44 context: ReportContext<'_>,
45 diff: DiffReport<'_>,
46) -> String {
47 assert_eq!(
48 command, REPORT_COMMAND_DIFF,
49 "diff report artifacts support only diff command"
50 );
51 render_json_report(command, findings, outcomes, failed, context, Some(diff))
52}
53
54fn render_json_report(
55 command: &str,
56 findings: &[Finding],
57 outcomes: &[MatchOutcome],
58 failed: bool,
59 context: ReportContext<'_>,
60 diff: Option<DiffReport<'_>>,
61) -> String {
62 assert!(
63 REPORT_COMMANDS.contains(&command),
64 "report artifacts support only audit, check, or diff commands"
65 );
66 let summary = Summary::from_outcomes(outcomes);
67 let mut out = String::new();
68 out.push_str("{\n");
69 push_json_artifact_header(&mut out, REPORT_ARTIFACT, command);
70 push_json_status_fields(&mut out, failed);
71 push_json_artifact_source_context(&mut out, context.into());
72 out.push_str(" \"summary\": {\n");
73 out.push_str(&format!(" \"findings\": {},\n", findings.len()));
74 out.push_str(&format!(" \"outcomes\": {},\n", summary.total));
75 out.push_str(&render_count_fields_with_policy_context(
76 &summary,
77 context.baseline_debt_entries,
78 context.policy_missing_evidence_entries,
79 context.broken_evidence_links,
80 context.weak_evidence_references,
81 " ",
82 ));
83 out.push_str(" },\n");
84 out.push_str(" \"trend\": {\n");
85 out.push_str(&render_trend_fields(&summary, context, " "));
86 out.push_str(" },\n");
87 append_audit_remediation_roadmap_json(command, &summary, context, &mut out);
88 append_evidence_repair_queues_json(&summary, context, &mut out);
89 if let Some(source_inventory) = crate::render_source_inventory_json(findings, outcomes, " ") {
90 out.push_str(" \"source_inventory\": ");
91 out.push_str(&source_inventory);
92 out.push_str(",\n");
93 }
94 out.push_str(" \"outcomes\": [\n");
95 for (i, outcome) in outcomes.iter().enumerate() {
96 if i > 0 {
97 out.push_str(",\n");
98 }
99 out.push_str(" ");
100 out.push_str(&render_match_outcome_json_compact(outcome));
101 }
102 out.push_str("\n ],\n");
103 out.push_str(" \"findings\": [\n");
104 for (i, finding) in findings.iter().enumerate() {
105 if i > 0 {
106 out.push_str(",\n");
107 }
108 out.push_str(" {");
109 out.push_str(&format!("\"kind\": \"{}\", ", finding.kind.as_str()));
110 out.push_str(&format!(
111 "\"family\": {}, ",
112 option_json(finding.family.as_deref())
113 ));
114 out.push_str(&format!(
115 "\"path\": \"{}\", ",
116 json_escape(&normalize_path(&finding.path))
117 ));
118 out.push_str(&format!(
119 "\"line\": {}, ",
120 finding
121 .span
122 .as_ref()
123 .map(|s| s.line.to_string())
124 .unwrap_or_else(|| "null".to_string())
125 ));
126 out.push_str(&format!(
127 "\"container\": {}, ",
128 option_json(finding.identity.container.as_deref())
129 ));
130 out.push_str(&format!(
131 "\"source_package\": {}, ",
132 option_json(finding.identity.crate_name.as_deref())
133 ));
134 out.push_str(&format!(
135 "\"ast_kind\": \"{}\"",
136 json_escape(&finding.identity.ast_kind)
137 ));
138 out.push('}');
139 }
140 match diff {
141 Some(diff) => {
142 out.push_str("\n ],\n");
143 out.push_str(" \"diff\": ");
144 out.push_str(
145 &crate::diff_json::render_diff_posture_json_with_evidence_health(
146 diff,
147 context.broken_evidence_links.unwrap_or(0),
148 context
149 .policy_missing_evidence_entries
150 .unwrap_or(0)
151 .max(summary.count(MatchStatus::EvidenceMissing)),
152 context.weak_evidence_references.unwrap_or(0),
153 ),
154 );
155 out.push_str("\n}\n");
156 }
157 None => out.push_str("\n ]\n}"),
158 }
159 out
160}
161
162fn append_evidence_repair_queues_json(
163 summary: &Summary,
164 context: ReportContext<'_>,
165 out: &mut String,
166) {
167 let queues = evidence_repair_queues(summary, ReviewSignals::from_summary(summary, context));
168 if queues.is_empty() {
169 return;
170 }
171
172 out.push_str(" \"evidence_repair_queues\": [\n");
173 for (index, queue) in queues.iter().enumerate() {
174 if index > 0 {
175 out.push_str(",\n");
176 }
177 out.push_str(" {\n");
178 push_evidence_repair_queue_json_fields(out, queue, " ");
179 out.push_str(" }");
180 }
181 out.push_str("\n ],\n");
182}
183
184fn append_audit_remediation_roadmap_json(
185 command: &str,
186 summary: &Summary,
187 context: ReportContext<'_>,
188 out: &mut String,
189) {
190 if command != "audit" {
191 return;
192 }
193 let roadmap = audit_remediation_items(summary, ReviewSignals::from_summary(summary, context));
194 if roadmap.is_empty() {
195 return;
196 }
197
198 out.push_str(" \"audit_remediation_roadmap\": [\n");
199 for (index, item) in roadmap.iter().enumerate() {
200 if index > 0 {
201 out.push_str(",\n");
202 }
203 out.push_str(" {\n");
204 out.push_str(&format!(
205 " \"signal\": \"{}\",\n",
206 json_escape(item.signal)
207 ));
208 out.push_str(&format!(
209 " \"label\": \"{}\",\n",
210 json_escape(item.label)
211 ));
212 out.push_str(&format!(
213 " \"route_kind\": \"{}\",\n",
214 json_escape(item.route.route_kind)
215 ));
216 if let Some(item_kind) = item.route.item_kind {
217 out.push_str(&format!(
218 " \"item_kind\": \"{}\",\n",
219 json_escape(item_kind)
220 ));
221 }
222 if let Some(worklist_status) = item.route.worklist_status {
223 out.push_str(&format!(
224 " \"worklist_status\": \"{}\",\n",
225 json_escape(worklist_status)
226 ));
227 }
228 if let Some(worklist_filter) = item.route.worklist_filter {
229 out.push_str(&format!(
230 " \"worklist_filter\": \"{}\",\n",
231 json_escape(worklist_filter)
232 ));
233 }
234 out.push_str(&format!(" \"count\": {},\n", item.count));
235 out.push_str(&format!(
236 " \"command\": \"{}\"\n",
237 json_escape(item.command)
238 ));
239 out.push_str(" }");
240 }
241 out.push_str("\n ],\n");
242}
243
244fn render_trend_fields(summary: &Summary, context: ReportContext<'_>, indent: &str) -> String {
245 let signals = ReviewSignals::from_summary(summary, context);
246 let mut fields = vec![
247 ("review_items", signals.review_items),
248 ("new", summary.count(MatchStatus::New)),
249 ("expired", summary.count(MatchStatus::Expired)),
250 ("review_due", summary.count(MatchStatus::ReviewDue)),
251 ("stale", summary.count(MatchStatus::Stale)),
252 ("ambiguous", summary.count(MatchStatus::Ambiguous)),
253 (
254 "invalid_selector",
255 summary.count(MatchStatus::InvalidSelector),
256 ),
257 (
258 "missing_required_field",
259 summary.count(MatchStatus::MissingRequiredField),
260 ),
261 (
262 "evidence_missing",
263 summary.count(MatchStatus::EvidenceMissing),
264 ),
265 ("baseline_debt", signals.baseline_debt),
266 ];
267 if signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing) {
268 fields.push(("policy_missing_evidence", signals.policy_missing_evidence));
269 }
270 if signals.broken_evidence_links > 0 {
271 fields.push(("broken_evidence_links", signals.broken_evidence_links));
272 }
273 if signals.weak_evidence_references > 0 {
274 fields.push(("weak_evidence_references", signals.weak_evidence_references));
275 }
276 fields
277 .iter()
278 .enumerate()
279 .map(|(idx, (name, value))| {
280 let comma = if idx + 1 == fields.len() { "" } else { "," };
281 format!("{indent}\"{name}\": {value}{comma}\n")
282 })
283 .collect()
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn audit_remediation_json_returns_for_non_audit_and_clean_audit() {
292 let summary = Summary::from_outcomes(&[test_outcome(MatchStatus::New)]);
293 let mut non_audit = String::new();
294
295 append_audit_remediation_roadmap_json(
296 "check",
297 &summary,
298 ReportContext::default(),
299 &mut non_audit,
300 );
301
302 assert_eq!(non_audit, "");
303
304 let clean_summary = Summary::default();
305 let mut clean_audit = String::new();
306 append_audit_remediation_roadmap_json(
307 "audit",
308 &clean_summary,
309 ReportContext::default(),
310 &mut clean_audit,
311 );
312
313 assert_eq!(clean_audit, "");
314 }
315
316 #[test]
317 fn audit_remediation_json_writes_multiple_route_shapes() {
318 let outcomes = [
319 test_outcome(MatchStatus::New),
320 test_outcome(MatchStatus::Stale),
321 test_outcome(MatchStatus::EvidenceMissing),
322 ];
323 let summary = Summary::from_outcomes(&outcomes);
324 let mut context = ReportContext::source_syntax("git_tracked", None, None, Some(2));
325 context.policy_missing_evidence_entries = Some(4);
326 context.broken_evidence_links = Some(1);
327 context.weak_evidence_references = Some(3);
328 let mut out = String::new();
329
330 append_audit_remediation_roadmap_json("audit", &summary, context, &mut out);
331
332 assert!(out.starts_with(" \"audit_remediation_roadmap\": [\n"));
333 assert!(out.ends_with("\n ],\n"));
334 assert!(out.contains(" },\n {\n"));
335 assert!(out.contains("\"signal\": \"new_unreceipted\""));
336 assert!(out.contains("\"route_kind\": \"worklist_status\""));
337 assert!(out.contains("\"item_kind\": \"new_unreceipted_finding\""));
338 assert!(out.contains("\"worklist_status\": \"new\""));
339 assert!(out.contains("\"signal\": \"stale\""));
340 assert!(out.contains("\"route_kind\": \"prune_stale\""));
341 assert!(out.contains("\"item_kind\": \"stale_allow\""));
342 assert!(out.contains("\"signal\": \"missing_evidence\""));
343 assert!(out.contains("\"route_kind\": \"worklist_filter\""));
344 assert!(out.contains("\"worklist_filter\": \"missing_evidence\""));
345 assert!(out.contains("\"count\": 4"));
346 assert!(out.contains("cargo-allow worklist --missing-evidence --format json"));
347 assert!(out.contains("\"signal\": \"broken_evidence_links\""));
348 assert!(out.contains("\"count\": 1"));
349 assert!(out.contains("\"signal\": \"weak_evidence_references\""));
350 assert!(out.contains("\"count\": 3"));
351 assert!(out.contains("\"signal\": \"baseline_debt\""));
352 assert!(out.contains("\"count\": 2"));
353 }
354
355 #[test]
356 fn trend_fields_include_optional_evidence_signals_when_nonzero() {
357 let outcomes = [
358 test_outcome(MatchStatus::Matched),
359 test_outcome(MatchStatus::New),
360 test_outcome(MatchStatus::Expired),
361 test_outcome(MatchStatus::ReviewDue),
362 test_outcome(MatchStatus::Stale),
363 test_outcome(MatchStatus::Ambiguous),
364 test_outcome(MatchStatus::InvalidSelector),
365 test_outcome(MatchStatus::MissingRequiredField),
366 test_outcome(MatchStatus::EvidenceMissing),
367 test_outcome(MatchStatus::BaselineDebt),
368 ];
369 let summary = Summary::from_outcomes(&outcomes);
370 let mut context = ReportContext::source_syntax("git_tracked", None, None, Some(5));
371 context.policy_missing_evidence_entries = Some(4);
372 context.broken_evidence_links = Some(2);
373 context.weak_evidence_references = Some(3);
374
375 let fields = render_trend_fields(&summary, context, " ");
376
377 for expected in [
378 "\"review_items\": 21,",
379 "\"new\": 1,",
380 "\"expired\": 1,",
381 "\"review_due\": 1,",
382 "\"stale\": 1,",
383 "\"ambiguous\": 1,",
384 "\"invalid_selector\": 1,",
385 "\"missing_required_field\": 1,",
386 "\"evidence_missing\": 1,",
387 "\"baseline_debt\": 5,",
388 "\"policy_missing_evidence\": 4,",
389 "\"broken_evidence_links\": 2,",
390 "\"weak_evidence_references\": 3",
391 ] {
392 assert!(fields.contains(expected), "{expected}\n{fields}");
393 }
394 assert!(!fields.contains("\"weak_evidence_references\": 3,"));
395 }
396
397 fn test_outcome(status: MatchStatus) -> MatchOutcome {
398 MatchOutcome {
399 status,
400 allow_id: None,
401 finding_index: None,
402 message: status.as_str().to_string(),
403 score: 100,
404 }
405 }
406}