parlov-output 0.5.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
//! Human-readable terminal table rendering for oracle results.
//!
//! Uses `comfy-table` with ANSI color coding for verdict and severity.

use comfy_table::{Cell, Color, Table};
use parlov_core::{ImpactClass, OracleResult, OracleVerdict, Severity};

use crate::ScanFinding;

/// Renders an `OracleResult` as a human-readable terminal table.
///
/// Columns: `Oracle`, `Verdict`, `Severity`, `Confidence`, `Evidence`. Evidence is derived
/// from the primary signal. When present, label, leaks, and RFC basis are appended as
/// labeled detail rows.
#[must_use]
pub fn render_table(result: &OracleResult) -> String {
    let mut table = Table::new();
    table.set_header(vec!["Oracle", "Verdict", "Severity", "Confidence", "Evidence"]);

    let verdict_cell = verdict_cell(result.verdict);
    let sev_cell = severity_cell(result.severity.as_ref());
    let conf_cell = confidence_cell(result.confidence, result.impact_class);
    let oracle_label = format!("{:?}", result.class);
    let evidence = result.primary_evidence();

    table.add_row(vec![
        Cell::new(&oracle_label),
        verdict_cell,
        sev_cell,
        conf_cell,
        Cell::new(evidence),
    ]);
    add_metadata_rows(&mut table, result);

    table.to_string()
}

fn add_metadata_rows(table: &mut Table, result: &OracleResult) {
    if let Some(label) = &result.label {
        add_detail_row(table, "Label", label);
    }
    if let Some(leaks) = &result.leaks {
        add_detail_row(table, "Leaks", leaks);
    }
    if let Some(rfc_basis) = &result.rfc_basis {
        add_detail_row(table, "RFC Basis", rfc_basis);
    }
}

fn add_detail_row(table: &mut Table, key: &str, value: &str) {
    table.add_row(vec![
        Cell::new(""),
        Cell::new(""),
        Cell::new(key),
        Cell::new(value),
    ]);
}

/// Renders a slice of scan findings as a summary terminal table.
///
/// Columns: Strategy | Method | Verdict | Severity | Confidence | Evidence.
/// One row per finding, using the primary signal evidence. Verdict and severity
/// cells are ANSI-colored using the same scheme as [`render_table`].
#[must_use]
pub fn render_scan_table(findings: &[ScanFinding]) -> String {
    let mut table = Table::new();
    table.set_header(vec![
        "Strategy", "Method", "Verdict", "Severity", "Confidence", "Evidence",
    ]);

    if findings.is_empty() {
        return table.to_string();
    }

    for f in findings {
        let evidence = f.result.primary_evidence();
        table.add_row(vec![
            Cell::new(&f.strategy_name),
            Cell::new(&f.method),
            verdict_cell(f.result.verdict),
            severity_cell(f.result.severity.as_ref()),
            confidence_cell(f.result.confidence, f.result.impact_class),
            Cell::new(evidence),
        ]);
    }

    table.to_string()
}

fn verdict_cell(verdict: OracleVerdict) -> Cell {
    let (label, color) = match verdict {
        OracleVerdict::Confirmed => ("Confirmed", Color::Red),
        OracleVerdict::Likely => ("Likely", Color::Yellow),
        OracleVerdict::Inconclusive => ("Inconclusive", Color::Blue),
        OracleVerdict::NotPresent => ("NotPresent", Color::Green),
    };
    Cell::new(label).fg(color)
}

fn severity_cell(severity: Option<&Severity>) -> Cell {
    match severity {
        Some(Severity::High) => Cell::new("High").fg(Color::Red),
        Some(Severity::Medium) => Cell::new("Medium").fg(Color::Yellow),
        Some(Severity::Low) => Cell::new("Low").fg(Color::Cyan),
        None => Cell::new("\u{2014}"),
    }
}

/// Renders a confidence score cell with optional impact class suffix.
///
/// Shows `"88 (High)"` when impact is present, plain `"88"` when not, and dash for zero.
fn confidence_cell(confidence: u8, impact_class: Option<ImpactClass>) -> Cell {
    if confidence == 0 {
        return Cell::new("\u{2014}");
    }
    let label = match impact_class {
        Some(ic) => format!("{confidence} ({})", impact_class_label(ic)),
        None => format!("{confidence}"),
    };
    Cell::new(label)
}

fn impact_class_label(ic: ImpactClass) -> &'static str {
    match ic {
        ImpactClass::High => "High",
        ImpactClass::Medium => "Medium",
        ImpactClass::Low => "Low",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use parlov_core::{OracleClass, OracleResult, OracleVerdict, Signal, SignalKind};

    fn confirmed_with_metadata() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::Confirmed,
            severity: Some(Severity::High),
            confidence: 0,
            impact_class: None,
            reasons: vec![],
            signals: vec![Signal {
                kind: SignalKind::StatusCodeDiff,
                evidence: "403 (baseline) vs 404 (probe)".into(),
                rfc_basis: None,
            }],
            technique_id: None,
            vector: None,
            normative_strength: None,
            label: Some("Authorization-based differential".into()),
            leaks: Some("Resource existence confirmed".into()),
            rfc_basis: Some("RFC 9110 \u{00a7}15.5.4".into()),
        }
    }

    fn not_present_no_metadata() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::NotPresent,
            severity: None,
            confidence: 0,
            impact_class: None,
            reasons: vec![],
            signals: vec![Signal {
                kind: SignalKind::StatusCodeDiff,
                evidence: "404 (baseline) vs 404 (probe)".into(),
                rfc_basis: None,
            }],
            technique_id: None,
            vector: None,
            normative_strength: None,
            label: None,
            leaks: None,
            rfc_basis: None,
        }
    }

    #[test]
    fn table_includes_label_when_present() {
        let table = render_table(&confirmed_with_metadata());
        assert!(table.contains("Authorization-based differential"));
    }

    #[test]
    fn table_includes_leaks_when_present() {
        let table = render_table(&confirmed_with_metadata());
        assert!(table.contains("Resource existence confirmed"));
    }

    #[test]
    fn table_includes_rfc_basis_when_present() {
        let table = render_table(&confirmed_with_metadata());
        assert!(table.contains("RFC 9110 \u{00a7}15.5.4"));
    }

    #[test]
    fn table_omits_label_row_when_none() {
        let table = render_table(&not_present_no_metadata());
        assert!(!table.contains("Label"));
    }

    #[test]
    fn table_omits_leaks_row_when_none() {
        let table = render_table(&not_present_no_metadata());
        assert!(!table.contains("Leaks"));
    }

    #[test]
    fn table_omits_rfc_basis_row_when_none() {
        let table = render_table(&not_present_no_metadata());
        assert!(!table.contains("RFC Basis"));
    }

    #[test]
    fn table_shows_primary_evidence_from_signals() {
        let table = render_table(&confirmed_with_metadata());
        assert!(table.contains("403 (baseline) vs 404 (probe)"));
    }

    #[test]
    fn scan_table_contains_strategy_name_and_method() {
        let findings = vec![ScanFinding {
            target_url: "https://api.example.com/users/1".to_owned(),
            strategy_id: "accept-elicit".to_owned(),
            strategy_name: "Accept header elicitation".to_owned(),
            method: "GET".to_owned(),
            result: not_present_no_metadata(),
        }];
        let table = render_scan_table(&findings);
        assert!(table.contains("Accept header elicitation"));
        assert!(table.contains("GET"));
    }

    #[test]
    fn scan_table_confirmed_verdict_appears() {
        let findings = vec![ScanFinding {
            target_url: "https://api.example.com/users/1".to_owned(),
            strategy_id: "s1".to_owned(),
            strategy_name: "test".to_owned(),
            method: "GET".to_owned(),
            result: confirmed_with_metadata(),
        }];
        let table = render_scan_table(&findings);
        assert!(table.contains("Confirmed"));
    }

    #[test]
    fn scan_table_empty_findings_no_panic() {
        let table = render_scan_table(&[]);
        assert!(!table.is_empty());
    }
}