parlov-output 0.8.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
use parlov_core::EndpointVerdict;
use serde::Serialize;

use crate::json::{build_finding, format_severity, FindingOutput, SCHEMA_VERSION};
use crate::ScanFinding;

/// Top-level JSON envelope for a scan result with endpoint-level verdict.
#[derive(Serialize)]
struct ScanWithVerdictOutput<'a> {
    schema_version: &'static str,
    target_url: &'a str,
    endpoint_verdict: EndpointVerdictOutput<'a>,
    findings: Vec<FindingOutput<'a>>,
}

/// JSON-safe mirror of `EndpointVerdict`.
#[derive(Serialize)]
struct EndpointVerdictOutput<'a> {
    oracle_class: String,
    verdict: String,
    posterior_probability: f64,
    severity: String,
    strategies_run: usize,
    strategies_total: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    stop_reason: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    first_threshold_crossed_by: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    final_confirming_strategy: Option<String>,
    contributing_findings: Vec<ContributingFindingOutput<'a>>,
    observability_status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    block_summary: Option<BlockSummaryOutput>,
}

/// JSON-safe mirror of `BlockSummary`.
#[derive(Serialize)]
struct BlockSummaryOutput {
    expected_observation_opportunities: usize,
    blocked_before_oracle_layer: usize,
    blocked_fraction: f64,
    dominant_block_family: String,
    dominant_block_reasons: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    operator_action: Option<String>,
}

#[derive(Serialize)]
struct ContributingFindingOutput<'a> {
    strategy_id: &'a str,
    strategy_name: &'a str,
    outcome_kind: String,
    log_odds_contribution: f64,
    #[serde(skip_serializing_if = "Option::is_none")]
    block_family: Option<String>,
}

/// Renders a scan result with endpoint-level verdict as nested JSON.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails (cannot occur for well-formed inputs).
pub fn render_endpoint_verdict_json(
    target_url: &str,
    verdict: &EndpointVerdict,
    findings: &[ScanFinding],
) -> Result<String, serde_json::Error> {
    let ev = build_endpoint_verdict_output(verdict);
    let items = findings
        .iter()
        .map(|f| {
            build_finding(
                target_url,
                &f.result,
                &f.strategy_id,
                &f.strategy_name,
                &f.method,
                f.repro.as_ref(),
                Some(&f.probe),
                Some(&f.exchange),
                f.chain_provenance.as_ref(),
            )
        })
        .collect();
    let output = ScanWithVerdictOutput {
        schema_version: SCHEMA_VERSION,
        target_url,
        endpoint_verdict: ev,
        findings: items,
    };
    serde_json::to_string_pretty(&output)
}

fn build_endpoint_verdict_output(verdict: &EndpointVerdict) -> EndpointVerdictOutput<'_> {
    let contributing = verdict
        .contributing_findings
        .iter()
        .map(|cf| ContributingFindingOutput {
            strategy_id: &cf.strategy_id,
            strategy_name: &cf.strategy_name,
            outcome_kind: cf.outcome_kind.to_string(),
            log_odds_contribution: cf.log_odds_contribution,
            block_family: cf.block_family.map(|f| f.to_string()),
        })
        .collect();

    let block_summary = verdict.block_summary.as_ref().map(|bs| BlockSummaryOutput {
        expected_observation_opportunities: bs.expected_observation_opportunities,
        blocked_before_oracle_layer: bs.blocked_before_oracle_layer,
        blocked_fraction: bs.blocked_fraction,
        dominant_block_family: bs.dominant_block_family.to_string(),
        dominant_block_reasons: bs.dominant_block_reasons.clone(),
        operator_action: bs.operator_action.clone(),
    });

    EndpointVerdictOutput {
        oracle_class: verdict.oracle_class.to_string(),
        verdict: verdict.verdict.to_string(),
        posterior_probability: verdict.posterior_probability,
        severity: format_severity(verdict.severity.as_ref()),
        strategies_run: verdict.strategies_run,
        strategies_total: verdict.strategies_total,
        stop_reason: verdict
            .stop_reason
            .as_ref()
            .map(std::string::ToString::to_string),
        first_threshold_crossed_by: verdict.first_threshold_crossed_by.clone(),
        final_confirming_strategy: verdict.final_confirming_strategy.clone(),
        contributing_findings: contributing,
        observability_status: verdict.observability_status.to_string(),
        block_summary,
    }
}

#[cfg(test)]
mod tests {
    use parlov_core::StrategyOutcomeKind;

    #[test]
    fn outcome_kind_to_string_matches_display() {
        assert_eq!(StrategyOutcomeKind::Positive.to_string(), "Positive");
        assert_eq!(StrategyOutcomeKind::NoSignal.to_string(), "NoSignal");
        assert_eq!(
            StrategyOutcomeKind::Contradictory.to_string(),
            "Contradictory"
        );
        assert_eq!(
            StrategyOutcomeKind::Inapplicable.to_string(),
            "Inapplicable"
        );
    }
}