Skip to main content

parlov_output/
lib.rs

1//! Output formatters for parlov: terminal table and raw JSON.
2//!
3//! Two formatters are provided:
4//! - [`render_table`]: human-readable terminal table via `comfy-table`.
5//! - [`render_json`]: pretty-printed JSON via `serde_json`.
6
7#![deny(clippy::all)]
8#![warn(clippy::pedantic)]
9#![deny(missing_docs)]
10
11use comfy_table::{Cell, Color, Table};
12use parlov_core::{OracleResult, OracleVerdict, Severity};
13
14/// Renders an `OracleResult` as a human-readable terminal table.
15///
16/// Columns: `Oracle`, `Verdict`, `Severity`, `Evidence`. One row per evidence string.
17/// Verdict and severity cells are ANSI-colored by confidence level. When present,
18/// label, leaks, and RFC basis are appended as labeled detail rows.
19#[must_use]
20pub fn render_table(result: &OracleResult) -> String {
21    let mut table = Table::new();
22    table.set_header(vec!["Oracle", "Verdict", "Severity", "Evidence"]);
23
24    let verdict_cell = verdict_cell(result.verdict);
25    let severity_cell = severity_cell(result.severity.as_ref());
26    let oracle_label = format!("{:?}", result.class);
27
28    add_evidence_rows(&mut table, &oracle_label, verdict_cell, severity_cell, &result.evidence);
29    add_metadata_rows(&mut table, result);
30
31    table.to_string()
32}
33
34fn add_evidence_rows(
35    table: &mut Table,
36    oracle_label: &str,
37    verdict_cell: Cell,
38    severity_cell: Cell,
39    evidence: &[String],
40) {
41    if evidence.is_empty() {
42        table.add_row(vec![
43            Cell::new(oracle_label),
44            verdict_cell,
45            severity_cell,
46            Cell::new("—"),
47        ]);
48        return;
49    }
50
51    for (i, ev) in evidence.iter().enumerate() {
52        if i == 0 {
53            table.add_row(vec![
54                Cell::new(oracle_label),
55                verdict_cell.clone(),
56                severity_cell.clone(),
57                Cell::new(ev.as_str()),
58            ]);
59        } else {
60            table.add_row(vec![
61                Cell::new(""),
62                Cell::new(""),
63                Cell::new(""),
64                Cell::new(ev.as_str()),
65            ]);
66        }
67    }
68}
69
70fn add_metadata_rows(table: &mut Table, result: &OracleResult) {
71    if let Some(label) = &result.label {
72        add_detail_row(table, "Label", label);
73    }
74    if let Some(leaks) = &result.leaks {
75        add_detail_row(table, "Leaks", leaks);
76    }
77    if let Some(rfc_basis) = &result.rfc_basis {
78        add_detail_row(table, "RFC Basis", rfc_basis);
79    }
80}
81
82fn add_detail_row(table: &mut Table, key: &str, value: &str) {
83    table.add_row(vec![
84        Cell::new(""),
85        Cell::new(""),
86        Cell::new(key),
87        Cell::new(value),
88    ]);
89}
90
91/// Renders an `OracleResult` as a pretty-printed JSON string.
92///
93/// # Errors
94///
95/// Returns `Err` if `serde_json` serialization fails, which cannot occur for
96/// well-formed `OracleResult` values in practice.
97pub fn render_json(result: &OracleResult) -> Result<String, serde_json::Error> {
98    serde_json::to_string_pretty(result)
99}
100
101fn verdict_cell(verdict: OracleVerdict) -> Cell {
102    let (label, color) = match verdict {
103        OracleVerdict::Confirmed => ("Confirmed", Color::Red),
104        OracleVerdict::Likely => ("Likely", Color::Yellow),
105        OracleVerdict::Inconclusive => ("Inconclusive", Color::Blue),
106        OracleVerdict::NotPresent => ("NotPresent", Color::Green),
107    };
108    Cell::new(label).fg(color)
109}
110
111fn severity_cell(severity: Option<&Severity>) -> Cell {
112    match severity {
113        Some(Severity::High) => Cell::new("High").fg(Color::Red),
114        Some(Severity::Medium) => Cell::new("Medium").fg(Color::Yellow),
115        Some(Severity::Low) => Cell::new("Low").fg(Color::Cyan),
116        None => Cell::new("—"),
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use parlov_core::{OracleClass, OracleVerdict};
124
125    fn confirmed_with_metadata() -> OracleResult {
126        OracleResult {
127            class: OracleClass::Existence,
128            verdict: OracleVerdict::Confirmed,
129            evidence: vec!["403 (baseline) vs 404 (probe)".into()],
130            severity: Some(Severity::High),
131            label: Some("Authorization-based differential".into()),
132            leaks: Some("Resource existence confirmed".into()),
133            rfc_basis: Some("RFC 9110 §15.5.4".into()),
134        }
135    }
136
137    fn not_present_no_metadata() -> OracleResult {
138        OracleResult {
139            class: OracleClass::Existence,
140            verdict: OracleVerdict::NotPresent,
141            evidence: vec!["404 (baseline) vs 404 (probe)".into()],
142            severity: None,
143            label: None,
144            leaks: None,
145            rfc_basis: None,
146        }
147    }
148
149    #[test]
150    fn table_includes_label_when_present() {
151        let table = render_table(&confirmed_with_metadata());
152        assert!(table.contains("Authorization-based differential"));
153    }
154
155    #[test]
156    fn table_includes_leaks_when_present() {
157        let table = render_table(&confirmed_with_metadata());
158        assert!(table.contains("Resource existence confirmed"));
159    }
160
161    #[test]
162    fn table_includes_rfc_basis_when_present() {
163        let table = render_table(&confirmed_with_metadata());
164        assert!(table.contains("RFC 9110 §15.5.4"));
165    }
166
167    #[test]
168    fn table_omits_label_row_when_none() {
169        let table = render_table(&not_present_no_metadata());
170        assert!(!table.contains("Label"));
171    }
172
173    #[test]
174    fn table_omits_leaks_row_when_none() {
175        let table = render_table(&not_present_no_metadata());
176        assert!(!table.contains("Leaks"));
177    }
178
179    #[test]
180    fn table_omits_rfc_basis_row_when_none() {
181        let table = render_table(&not_present_no_metadata());
182        assert!(!table.contains("RFC Basis"));
183    }
184
185    #[test]
186    fn json_omits_none_metadata_fields() {
187        let result = not_present_no_metadata();
188        let json = render_json(&result).expect("serialization failed");
189        assert!(!json.contains("label"));
190        assert!(!json.contains("leaks"));
191        assert!(!json.contains("rfc_basis"));
192    }
193
194    #[test]
195    fn json_includes_some_metadata_fields() {
196        let result = confirmed_with_metadata();
197        let json = render_json(&result).expect("serialization failed");
198        assert!(json.contains("\"label\""));
199        assert!(json.contains("\"leaks\""));
200        assert!(json.contains("\"rfc_basis\""));
201    }
202}