use parlov_core::EndpointVerdict;
use serde::Serialize;
use crate::json::{build_finding, format_severity, FindingOutput, SCHEMA_VERSION};
use crate::ScanFinding;
#[derive(Serialize)]
struct ScanWithVerdictOutput<'a> {
schema_version: &'static str,
target_url: &'a str,
endpoint_verdict: EndpointVerdictOutput<'a>,
findings: Vec<FindingOutput<'a>>,
}
#[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>,
}
#[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>,
}
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"
);
}
}