parlov-output 0.4.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
//! Output formatters for parlov: terminal table, raw JSON, and SARIF v2.1.0.
//!
//! Formatters provided:
//! - [`render_table`]: human-readable terminal table via `comfy-table`.
//! - [`render_json`]: pretty-printed JSON via `serde_json`.
//! - [`render_scan_table`]: summary table for multi-strategy scan findings.
//! - [`render_scan_json`]: JSON array of scan findings.
//! - [`render_sarif`]: single `OracleResult` as SARIF v2.1.0.
//! - [`render_scan_sarif`]: `ScanFinding` slice as SARIF v2.1.0.

#![deny(clippy::all)]
#![warn(clippy::pedantic)]
#![deny(missing_docs)]

mod sarif;

pub use sarif::{render_sarif, render_scan_sarif};

use comfy_table::{Cell, Color, Table};
use parlov_core::{OracleResult, OracleVerdict, Severity};
use serde::{Deserialize, Serialize};

/// A single finding from a scan run — one strategy applied to one method.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanFinding {
    /// Fully-qualified URL of the probed endpoint, e.g. `"https://api.example.com/users/1"`.
    pub target_url: String,
    /// Stable machine-readable strategy identifier, e.g. `"existence-get-200-404"`.
    pub strategy_id: String,
    /// Human-readable strategy display name, e.g. `"GET 200/404 existence"`.
    pub strategy_name: String,
    /// HTTP method used, e.g. `"GET"`.
    pub method: String,
    /// The oracle analysis result for this strategy/method combination.
    pub result: OracleResult,
}

/// Renders an `OracleResult` as a human-readable terminal table.
///
/// Columns: `Oracle`, `Verdict`, `Severity`, `Evidence`. One row per evidence string.
/// Verdict and severity cells are ANSI-colored by confidence level. 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", "Evidence"]);

    let verdict_cell = verdict_cell(result.verdict);
    let severity_cell = severity_cell(result.severity.as_ref());
    let oracle_label = format!("{:?}", result.class);

    add_evidence_rows(&mut table, &oracle_label, verdict_cell, severity_cell, &result.evidence);
    add_metadata_rows(&mut table, result);

    table.to_string()
}

fn add_evidence_rows(
    table: &mut Table,
    oracle_label: &str,
    verdict_cell: Cell,
    severity_cell: Cell,
    evidence: &[String],
) {
    if evidence.is_empty() {
        table.add_row(vec![
            Cell::new(oracle_label),
            verdict_cell,
            severity_cell,
            Cell::new(""),
        ]);
        return;
    }

    for (i, ev) in evidence.iter().enumerate() {
        if i == 0 {
            table.add_row(vec![
                Cell::new(oracle_label),
                verdict_cell.clone(),
                severity_cell.clone(),
                Cell::new(ev.as_str()),
            ]);
        } else {
            table.add_row(vec![
                Cell::new(""),
                Cell::new(""),
                Cell::new(""),
                Cell::new(ev.as_str()),
            ]);
        }
    }
}

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 an `OracleResult` as a pretty-printed JSON string.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails, which cannot occur for
/// well-formed `OracleResult` values in practice.
pub fn render_json(result: &OracleResult) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(result)
}

/// Renders a slice of scan findings as a summary terminal table.
///
/// Columns: Strategy | Method | Verdict | Severity | Evidence. One row per finding,
/// using the first evidence string. 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", "Evidence"]);

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

    for f in findings {
        let evidence = f.result.evidence.first().map_or("", String::as_str);
        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()),
            Cell::new(evidence),
        ]);
    }

    table.to_string()
}

/// Renders a slice of scan findings as a pretty-printed JSON array.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails, which cannot occur for
/// well-formed `ScanFinding` slices in practice.
pub fn render_scan_json(findings: &[ScanFinding]) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(findings)
}

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(""),
    }
}

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

    fn confirmed_with_metadata() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::Confirmed,
            evidence: vec!["403 (baseline) vs 404 (probe)".into()],
            severity: Some(Severity::High),
            label: Some("Authorization-based differential".into()),
            leaks: Some("Resource existence confirmed".into()),
            rfc_basis: Some("RFC 9110 §15.5.4".into()),
            baseline_summary: None,
            probe_summary: None,
            header_diffs: vec![],
        }
    }

    fn not_present_no_metadata() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::NotPresent,
            evidence: vec!["404 (baseline) vs 404 (probe)".into()],
            severity: None,
            label: None,
            leaks: None,
            rfc_basis: None,
            baseline_summary: None,
            probe_summary: None,
            header_diffs: vec![],
        }
    }

    #[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 §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 json_omits_none_metadata_fields() {
        let result = not_present_no_metadata();
        let json = render_json(&result).expect("serialization failed");
        assert!(!json.contains("label"));
        assert!(!json.contains("leaks"));
        assert!(!json.contains("rfc_basis"));
    }

    #[test]
    fn json_includes_some_metadata_fields() {
        let result = confirmed_with_metadata();
        let json = render_json(&result).expect("serialization failed");
        assert!(json.contains("\"label\""));
        assert!(json.contains("\"leaks\""));
        assert!(json.contains("\"rfc_basis\""));
    }

    // --- scan output tests ---

    fn scan_finding(verdict: OracleVerdict, severity: Option<Severity>) -> ScanFinding {
        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: OracleResult {
                class: OracleClass::Existence,
                verdict,
                evidence: vec!["406 (baseline) vs 404 (probe)".into()],
                severity,
                label: None,
                leaks: None,
                rfc_basis: None,
                baseline_summary: None,
                probe_summary: None,
                header_diffs: vec![],
            },
        }
    }

    #[test]
    fn render_scan_table_contains_strategy_name_and_method() {
        let findings = vec![scan_finding(OracleVerdict::NotPresent, None)];
        let table = render_scan_table(&findings);
        assert!(table.contains("Accept header elicitation"));
        assert!(table.contains("GET"));
    }

    #[test]
    fn render_scan_table_confirmed_verdict_appears() {
        let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
        let table = render_scan_table(&findings);
        assert!(table.contains("Confirmed"));
    }

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

    #[test]
    fn render_scan_json_produces_valid_json_array() {
        let findings = vec![scan_finding(OracleVerdict::NotPresent, None)];
        let json = render_scan_json(&findings).expect("serialization failed");
        let value: serde_json::Value =
            serde_json::from_str(&json).expect("result is not valid JSON");
        assert!(value.is_array());
    }

    #[test]
    fn render_scan_json_contains_strategy_id() {
        let findings = vec![scan_finding(OracleVerdict::NotPresent, None)];
        let json = render_scan_json(&findings).expect("serialization failed");
        assert!(json.contains("accept-elicit"));
    }
}