1use crate::contracts::REPORT_ARTIFACT;
2use crate::json::{
3 option_json, push_json_artifact_header, push_json_artifact_source_context,
4 push_json_status_fields, render_match_outcome_json_compact,
5};
6use crate::{
7 DiffReport, REPORT_COMMAND_DIFF, REPORT_COMMANDS, ReportContext, ReviewSignals, Summary,
8 render_count_fields_with_policy_context,
9};
10use allow_core::{Finding, MatchOutcome, MatchStatus, json_escape, normalize_path};
11
12pub fn render_json(
13 command: &str,
14 findings: &[Finding],
15 outcomes: &[MatchOutcome],
16 failed: bool,
17) -> String {
18 render_json_with_context(
19 command,
20 findings,
21 outcomes,
22 failed,
23 ReportContext::default(),
24 )
25}
26
27pub fn render_json_with_context(
28 command: &str,
29 findings: &[Finding],
30 outcomes: &[MatchOutcome],
31 failed: bool,
32 context: ReportContext<'_>,
33) -> String {
34 render_json_report(command, findings, outcomes, failed, context, None)
35}
36
37pub fn render_json_with_context_and_diff(
38 command: &str,
39 findings: &[Finding],
40 outcomes: &[MatchOutcome],
41 failed: bool,
42 context: ReportContext<'_>,
43 diff: DiffReport<'_>,
44) -> String {
45 assert_eq!(
46 command, REPORT_COMMAND_DIFF,
47 "diff report artifacts support only diff command"
48 );
49 render_json_report(command, findings, outcomes, failed, context, Some(diff))
50}
51
52fn render_json_report(
53 command: &str,
54 findings: &[Finding],
55 outcomes: &[MatchOutcome],
56 failed: bool,
57 context: ReportContext<'_>,
58 diff: Option<DiffReport<'_>>,
59) -> String {
60 assert!(
61 REPORT_COMMANDS.contains(&command),
62 "report artifacts support only audit, check, or diff commands"
63 );
64 let summary = Summary::from_outcomes(outcomes);
65 let mut out = String::new();
66 out.push_str("{\n");
67 push_json_artifact_header(&mut out, REPORT_ARTIFACT, command);
68 push_json_status_fields(&mut out, failed);
69 push_json_artifact_source_context(&mut out, context.into());
70 out.push_str(" \"summary\": {\n");
71 out.push_str(&format!(" \"findings\": {},\n", findings.len()));
72 out.push_str(&format!(" \"outcomes\": {},\n", summary.total));
73 out.push_str(&render_count_fields_with_policy_context(
74 &summary,
75 context.baseline_debt_entries,
76 context.policy_missing_evidence_entries,
77 context.broken_evidence_links,
78 context.weak_evidence_references,
79 " ",
80 ));
81 out.push_str(" },\n");
82 out.push_str(" \"trend\": {\n");
83 out.push_str(&render_trend_fields(&summary, context, " "));
84 out.push_str(" },\n");
85 if let Some(source_inventory) = crate::render_source_inventory_json(findings, outcomes, " ") {
86 out.push_str(" \"source_inventory\": ");
87 out.push_str(&source_inventory);
88 out.push_str(",\n");
89 }
90 out.push_str(" \"outcomes\": [\n");
91 for (i, outcome) in outcomes.iter().enumerate() {
92 if i > 0 {
93 out.push_str(",\n");
94 }
95 out.push_str(" ");
96 out.push_str(&render_match_outcome_json_compact(outcome));
97 }
98 out.push_str("\n ],\n");
99 out.push_str(" \"findings\": [\n");
100 for (i, finding) in findings.iter().enumerate() {
101 if i > 0 {
102 out.push_str(",\n");
103 }
104 out.push_str(" {");
105 out.push_str(&format!("\"kind\": \"{}\", ", finding.kind.as_str()));
106 out.push_str(&format!(
107 "\"family\": {}, ",
108 option_json(finding.family.as_deref())
109 ));
110 out.push_str(&format!(
111 "\"path\": \"{}\", ",
112 json_escape(&normalize_path(&finding.path))
113 ));
114 out.push_str(&format!(
115 "\"line\": {}, ",
116 finding
117 .span
118 .as_ref()
119 .map(|s| s.line.to_string())
120 .unwrap_or_else(|| "null".to_string())
121 ));
122 out.push_str(&format!(
123 "\"container\": {}, ",
124 option_json(finding.identity.container.as_deref())
125 ));
126 out.push_str(&format!(
127 "\"source_package\": {}, ",
128 option_json(finding.identity.crate_name.as_deref())
129 ));
130 out.push_str(&format!(
131 "\"ast_kind\": \"{}\"",
132 json_escape(&finding.identity.ast_kind)
133 ));
134 out.push('}');
135 }
136 match diff {
137 Some(diff) => {
138 out.push_str("\n ],\n");
139 out.push_str(" \"diff\": ");
140 out.push_str(
141 &crate::diff_json::render_diff_posture_json_with_evidence_health(
142 diff,
143 context.broken_evidence_links.unwrap_or(0),
144 context.weak_evidence_references.unwrap_or(0),
145 ),
146 );
147 out.push_str("\n}\n");
148 }
149 None => out.push_str("\n ]\n}"),
150 }
151 out
152}
153
154fn render_trend_fields(summary: &Summary, context: ReportContext<'_>, indent: &str) -> String {
155 let signals = ReviewSignals::from_summary(summary, context);
156 let mut fields = vec![
157 ("review_items", signals.review_items),
158 ("new", summary.count(MatchStatus::New)),
159 ("expired", summary.count(MatchStatus::Expired)),
160 ("review_due", summary.count(MatchStatus::ReviewDue)),
161 ("stale", summary.count(MatchStatus::Stale)),
162 ("ambiguous", summary.count(MatchStatus::Ambiguous)),
163 (
164 "invalid_selector",
165 summary.count(MatchStatus::InvalidSelector),
166 ),
167 (
168 "missing_required_field",
169 summary.count(MatchStatus::MissingRequiredField),
170 ),
171 (
172 "evidence_missing",
173 summary.count(MatchStatus::EvidenceMissing),
174 ),
175 ("baseline_debt", signals.baseline_debt),
176 ];
177 if signals.policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing) {
178 fields.push(("policy_missing_evidence", signals.policy_missing_evidence));
179 }
180 if signals.broken_evidence_links > 0 {
181 fields.push(("broken_evidence_links", signals.broken_evidence_links));
182 }
183 if signals.weak_evidence_references > 0 {
184 fields.push(("weak_evidence_references", signals.weak_evidence_references));
185 }
186 fields
187 .iter()
188 .enumerate()
189 .map(|(idx, (name, value))| {
190 let comma = if idx + 1 == fields.len() { "" } else { "," };
191 format!("{indent}\"{name}\": {value}{comma}\n")
192 })
193 .collect()
194}