Skip to main content

allow_report/
worklist_json.rs

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}