1use crate::contracts::WORKLIST_ARTIFACT;
2use crate::json::{bool_json, json_string_array, option_json, push_json_fixed_artifact_preamble};
3use crate::worklist_summary::{
4 worklist_difficulty_count, worklist_kind_counts, worklist_risk_count,
5};
6use crate::{InventoryContext, WorklistFilters, WorklistItem};
7use allow_core::json_escape;
8
9pub fn render_worklist_json(
10 items: &[WorklistItem<'_>],
11 filters: WorklistFilters<'_>,
12 inventory: InventoryContext<'_>,
13) -> String {
14 let mut out = String::new();
15 out.push_str("{\n");
16 push_json_fixed_artifact_preamble(&mut out, WORKLIST_ARTIFACT, inventory);
17 out.push_str(" \"filters\": ");
18 out.push_str(&render_worklist_filters_json(filters, " "));
19 out.push_str(",\n");
20 out.push_str(" \"summary\": {\n");
21 out.push_str(&format!(" \"work_items\": {},\n", items.len()));
22 out.push_str(&format!(
23 " \"high\": {},\n",
24 worklist_risk_count(items, "high")
25 ));
26 out.push_str(&format!(
27 " \"medium\": {},\n",
28 worklist_risk_count(items, "medium")
29 ));
30 out.push_str(&format!(
31 " \"low\": {},\n",
32 worklist_risk_count(items, "low")
33 ));
34 out.push_str(&format!(
35 " \"small_difficulty\": {},\n",
36 worklist_difficulty_count(items, "small")
37 ));
38 out.push_str(&format!(
39 " \"medium_difficulty\": {}",
40 worklist_difficulty_count(items, "medium")
41 ));
42 let kind_counts = worklist_kind_counts(items);
43 if kind_counts.is_empty() {
44 out.push('\n');
45 } else {
46 out.push_str(",\n");
47 out.push_str(" \"item_kinds\": {\n");
48 for (index, (kind, count)) in kind_counts.iter().enumerate() {
49 if index > 0 {
50 out.push_str(",\n");
51 }
52 out.push_str(&format!(" \"{}\": {}", json_escape(kind), count));
53 }
54 out.push_str("\n }\n");
55 }
56 out.push_str(" },\n");
57 out.push_str(" \"work_items\": [\n");
58 for (index, item) in items.iter().enumerate() {
59 if index > 0 {
60 out.push_str(",\n");
61 }
62 out.push_str(&render_work_item_json(item));
63 }
64 out.push_str("\n ]\n");
65 out.push_str("}\n");
66 out
67}
68
69fn render_work_item_json(item: &WorklistItem<'_>) -> String {
70 let mut out = String::new();
71 out.push_str(" {\n");
72 out.push_str(&format!(" \"id\": \"{}\",\n", json_escape(item.id)));
73 out.push_str(&format!(
74 " \"kind\": \"{}\",\n",
75 json_escape(item.kind)
76 ));
77 out.push_str(&format!(
78 " \"exception_kind\": {},\n",
79 option_json(item.exception_kind)
80 ));
81 out.push_str(&format!(
82 " \"family\": {},\n",
83 option_json(item.family)
84 ));
85 out.push_str(&format!(" \"owner\": {},\n", option_json(item.owner)));
86 out.push_str(&format!(
87 " \"classification\": {},\n",
88 option_json(item.classification)
89 ));
90 out.push_str(&format!(
91 " \"reason\": {},\n",
92 option_json(item.reason)
93 ));
94 out.push_str(&format!(
95 " \"created\": {},\n",
96 option_json(item.created)
97 ));
98 out.push_str(&format!(
99 " \"review_after\": {},\n",
100 option_json(item.review_after)
101 ));
102 out.push_str(&format!(
103 " \"expires\": {},\n",
104 option_json(item.expires)
105 ));
106 out.push_str(&format!(
107 " \"evidence_count\": {},\n",
108 item.evidence_count
109 .map(|count| count.to_string())
110 .unwrap_or_else(|| "null".to_string())
111 ));
112 if let Some(selector_precision) = item.selector_precision {
113 out.push_str(&format!(
114 " \"selector_precision\": {selector_precision},\n"
115 ));
116 }
117 out.push_str(&format!(
118 " \"risk\": \"{}\",\n",
119 json_escape(item.risk)
120 ));
121 out.push_str(&format!(
122 " \"difficulty\": \"{}\",\n",
123 json_escape(item.difficulty)
124 ));
125 out.push_str(&format!(
126 " \"status\": \"{}\",\n",
127 json_escape(item.status)
128 ));
129 out.push_str(&format!(
130 " \"allow_id\": {},\n",
131 option_json(item.allow_id)
132 ));
133 out.push_str(&format!(
134 " \"finding_index\": {},\n",
135 item.finding_index
136 .map(|index| index.to_string())
137 .unwrap_or_else(|| "null".to_string())
138 ));
139 out.push_str(&format!(" \"path\": {},\n", option_json(item.path)));
140 if let Some(reference) = item.evidence_reference.as_ref() {
141 out.push_str(" \"evidence_reference\": ");
142 out.push_str(&render_worklist_evidence_reference_json(reference));
143 out.push_str(",\n");
144 }
145 out.push_str(&format!(
146 " \"source_package\": {},\n",
147 option_json(item.source_package)
148 ));
149 out.push_str(&format!(
150 " \"message\": \"{}\",\n",
151 json_escape(item.message)
152 ));
153 out.push_str(&format!(
154 " \"suggested_actions\": {},\n",
155 json_string_array(item.suggested_actions)
156 ));
157 out.push_str(&format!(
158 " \"proof_commands\": {}\n",
159 json_string_array(item.proof_commands)
160 ));
161 out.push_str(" }");
162 out
163}
164
165fn render_worklist_evidence_reference_json(reference: &crate::EvidenceReference<'_>) -> String {
166 format!(
167 "{{\n \"raw\": \"{}\",\n \"prefix\": {},\n \"target\": {},\n \"status\": \"{}\",\n \"category\": \"{}\",\n \"message\": \"{}\"\n }}",
168 json_escape(reference.raw),
169 option_json(reference.prefix),
170 option_json(reference.target),
171 json_escape(reference.status),
172 json_escape(reference.category),
173 json_escape(reference.message)
174 )
175}
176
177fn render_worklist_filters_json(filters: WorklistFilters<'_>, indent: &str) -> String {
178 let mut out = String::new();
179 out.push_str("{\n");
180 out.push_str(&format!(
181 "{indent} \"kind\": {},\n",
182 option_json(filters.kind)
183 ));
184 out.push_str(&format!(
185 "{indent} \"family\": {},\n",
186 option_json(filters.family)
187 ));
188 out.push_str(&format!(
189 "{indent} \"item_kind\": {},\n",
190 option_json(filters.item_kind)
191 ));
192 out.push_str(&format!(
193 "{indent} \"status\": {},\n",
194 option_json(filters.status)
195 ));
196 out.push_str(&format!(
197 "{indent} \"allow_id\": {},\n",
198 option_json(filters.allow_id)
199 ));
200 out.push_str(&format!(
201 "{indent} \"path\": {},\n",
202 option_json(filters.path)
203 ));
204 out.push_str(&format!(
205 "{indent} \"source_package\": {},\n",
206 option_json(filters.source_package)
207 ));
208 out.push_str(&format!(
209 "{indent} \"owner\": {},\n",
210 option_json(filters.owner)
211 ));
212 out.push_str(&format!(
213 "{indent} \"classification\": {},\n",
214 option_json(filters.classification)
215 ));
216 out.push_str(&format!(
217 "{indent} \"baseline_debt\": {},\n",
218 bool_json(filters.baseline_debt)
219 ));
220 out.push_str(&format!(
221 "{indent} \"broad_scope\": {},\n",
222 bool_json(filters.broad_scope)
223 ));
224 out.push_str(&format!(
225 "{indent} \"risk\": {},\n",
226 option_json(filters.risk)
227 ));
228 out.push_str(&format!(
229 "{indent} \"difficulty\": {},\n",
230 option_json(filters.difficulty)
231 ));
232 out.push_str(&format!(
233 "{indent} \"missing_evidence\": {},\n",
234 bool_json(filters.missing_evidence)
235 ));
236 out.push_str(&format!(
237 "{indent} \"broken_evidence\": {},\n",
238 bool_json(filters.broken_evidence)
239 ));
240 out.push_str(&format!(
241 "{indent} \"weak_evidence\": {}\n",
242 bool_json(filters.weak_evidence)
243 ));
244 out.push_str(&format!("{indent}}}"));
245 out
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn work_item<'a>(
253 suggested_actions: &'a [String],
254 proof_commands: &'a [String],
255 ) -> WorklistItem<'a> {
256 WorklistItem {
257 id: "work-0001",
258 kind: "missing_evidence",
259 exception_kind: Some("unsafe"),
260 family: Some("unsafe_block"),
261 owner: Some("runtime"),
262 classification: Some("reviewed"),
263 reason: Some("fixture reason"),
264 created: Some("2026-06-01"),
265 review_after: Some("2026-07-01"),
266 expires: Some("2026-08-01"),
267 evidence_count: Some(2),
268 selector_precision: Some(9),
269 risk: "high",
270 difficulty: "small",
271 status: "evidence_missing",
272 allow_id: Some("allow-0001"),
273 finding_index: Some(3),
274 path: Some("src/lib.rs"),
275 evidence_reference: Some(crate::EvidenceReference {
276 raw: "test:unsafe_fixture",
277 prefix: Some("test"),
278 target: Some("unsafe_fixture"),
279 status: "present",
280 category: "typed",
281 message: "evidence exists",
282 }),
283 source_package: Some("allow-report"),
284 message: "missing evidence",
285 suggested_actions,
286 proof_commands,
287 }
288 }
289
290 #[test]
291 fn work_item_fixture_sets_expected_optional_fields() {
292 let suggested_actions = vec!["add typed evidence".to_string()];
293 let proof_commands = vec!["cargo-allow explain allow-0001".to_string()];
294 let item = work_item(&suggested_actions, &proof_commands);
295
296 assert_eq!(item.classification, Some("reviewed"));
297 assert_eq!(item.reason, Some("fixture reason"));
298 assert_eq!(item.evidence_count, Some(2));
299 assert_eq!(item.selector_precision, Some(9));
300 assert_eq!(item.finding_index, Some(3));
301 let reference = item.evidence_reference.as_ref();
302 assert_eq!(
303 reference.map(|reference| reference.prefix),
304 Some(Some("test"))
305 );
306 assert_eq!(
307 reference.map(|reference| reference.target),
308 Some(Some("unsafe_fixture"))
309 );
310 assert_eq!(reference.map(|reference| reference.category), Some("typed"));
311 }
312
313 #[test]
314 fn render_work_item_json_includes_all_optional_fields_and_arrays() {
315 let suggested_actions = vec![
316 "add typed evidence".to_string(),
317 "narrow the selector".to_string(),
318 ];
319 let proof_commands = vec![
320 "cargo-allow explain allow-0001".to_string(),
321 "cargo-allow check --mode no-new".to_string(),
322 ];
323 let json = render_work_item_json(&work_item(&suggested_actions, &proof_commands));
324
325 for expected in [
326 "\"id\": \"work-0001\"",
327 "\"kind\": \"missing_evidence\"",
328 "\"exception_kind\": \"unsafe\"",
329 "\"family\": \"unsafe_block\"",
330 "\"owner\": \"runtime\"",
331 "\"classification\": \"reviewed\"",
332 "\"reason\": \"fixture reason\"",
333 "\"created\": \"2026-06-01\"",
334 "\"review_after\": \"2026-07-01\"",
335 "\"expires\": \"2026-08-01\"",
336 "\"evidence_count\": 2",
337 "\"selector_precision\": 9",
338 "\"risk\": \"high\"",
339 "\"difficulty\": \"small\"",
340 "\"status\": \"evidence_missing\"",
341 "\"allow_id\": \"allow-0001\"",
342 "\"finding_index\": 3",
343 "\"path\": \"src/lib.rs\"",
344 "\"evidence_reference\": {",
345 "\"raw\": \"test:unsafe_fixture\"",
346 "\"prefix\": \"test\"",
347 "\"target\": \"unsafe_fixture\"",
348 "\"status\": \"present\"",
349 "\"category\": \"typed\"",
350 "\"message\": \"evidence exists\"",
351 "\"source_package\": \"allow-report\"",
352 "\"message\": \"missing evidence\"",
353 "\"suggested_actions\": [\"add typed evidence\", \"narrow the selector\"]",
354 "\"proof_commands\": [\"cargo-allow explain allow-0001\", \"cargo-allow check --mode no-new\"]",
355 ] {
356 assert!(json.contains(expected), "{expected}");
357 }
358 }
359
360 #[test]
361 fn render_work_item_json_uses_nulls_and_omits_absent_optional_objects() {
362 let suggested_actions = Vec::new();
363 let proof_commands = Vec::new();
364 let item = WorklistItem {
365 exception_kind: None,
366 family: None,
367 owner: None,
368 classification: None,
369 reason: None,
370 created: None,
371 review_after: None,
372 expires: None,
373 evidence_count: None,
374 selector_precision: None,
375 allow_id: None,
376 finding_index: None,
377 path: None,
378 evidence_reference: None,
379 source_package: None,
380 suggested_actions: &suggested_actions,
381 proof_commands: &proof_commands,
382 ..work_item(&suggested_actions, &proof_commands)
383 };
384
385 let json = render_work_item_json(&item);
386
387 for expected in [
388 "\"exception_kind\": null",
389 "\"family\": null",
390 "\"owner\": null",
391 "\"classification\": null",
392 "\"reason\": null",
393 "\"created\": null",
394 "\"review_after\": null",
395 "\"expires\": null",
396 "\"evidence_count\": null",
397 "\"allow_id\": null",
398 "\"finding_index\": null",
399 "\"path\": null",
400 "\"source_package\": null",
401 "\"suggested_actions\": []",
402 "\"proof_commands\": []",
403 ] {
404 assert!(json.contains(expected), "{expected}");
405 }
406 assert!(!json.contains("\"selector_precision\""));
407 assert!(!json.contains("\"evidence_reference\""));
408 }
409
410 #[test]
411 fn render_worklist_filters_json_records_every_filter_field() {
412 let json = render_worklist_filters_json(
413 WorklistFilters {
414 kind: Some("unsafe"),
415 family: Some("unsafe_block"),
416 item_kind: Some("missing_evidence"),
417 status: Some("evidence_missing"),
418 allow_id: Some("allow-0001"),
419 path: Some("src/lib.rs"),
420 source_package: Some("allow-report"),
421 owner: Some("runtime"),
422 classification: Some("reviewed"),
423 baseline_debt: true,
424 broad_scope: true,
425 risk: Some("high"),
426 difficulty: Some("small"),
427 missing_evidence: true,
428 broken_evidence: true,
429 weak_evidence: true,
430 },
431 " ",
432 );
433
434 for expected in [
435 "\"kind\": \"unsafe\"",
436 "\"family\": \"unsafe_block\"",
437 "\"item_kind\": \"missing_evidence\"",
438 "\"status\": \"evidence_missing\"",
439 "\"allow_id\": \"allow-0001\"",
440 "\"path\": \"src/lib.rs\"",
441 "\"source_package\": \"allow-report\"",
442 "\"owner\": \"runtime\"",
443 "\"classification\": \"reviewed\"",
444 "\"baseline_debt\": true",
445 "\"broad_scope\": true",
446 "\"risk\": \"high\"",
447 "\"difficulty\": \"small\"",
448 "\"missing_evidence\": true",
449 "\"broken_evidence\": true",
450 "\"weak_evidence\": true",
451 ] {
452 assert!(json.contains(expected), "{expected}");
453 }
454 }
455}