use serde::{Deserialize, Serialize};
use cleanlib_client::types::{Verdict,PreviousVerdict,AvailabilityBlock};
use cleanlib_client::CustomerState;
use super::sanitize::{mask_engine_tag, mask_fixture_label};
use super::output::decision_tier_str_with_severity;
#[derive(Debug, Clone, Copy,Default)]
#[allow(dead_code)]
pub struct RenderOpts {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerdictResponse {
pub verdict_id: String,
pub verdict: String,
pub source: String,
pub confidence: f64,
pub composite_score: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<String>,
pub state: String,
pub state_label: String,
pub reasoning: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub similar_to: Option<Vec<String>>,
pub evidence_gaps: Vec<String>,
pub suggested_actions: Vec<String>,
pub severity: Option<String>,
pub data_freshness_at: Option<String>,
pub data_oldest_signal_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stale_since_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub staleness_reason: Option<String>,
pub computed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_verdict: Option<PreviousVerdict>,
#[serde(default, skip_serializing_if = "availability_block_is_default")]
pub availability: AvailabilityBlock,
}
fn availability_block_is_default(a: &AvailabilityBlock) -> bool {
*a == AvailabilityBlock::default()
}
impl From<&Verdict> for VerdictResponse {
fn from(v: &Verdict) -> Self {
let state = CustomerState::from_wire(&v.source);
VerdictResponse {
verdict_id: v.verdict_id.clone(),
verdict: mask_engine_tag(&v.verdict),
source: mask_fixture_label(&mask_engine_tag(&v.source)),
confidence: v.confidence.clone(),
composite_score: v.composite_score.clone(),
decision: Some(decision_tier_str_with_severity(&v.verdict, v.severity.as_deref(), v.composite_score).to_string()),
state: state.as_str().to_string(),
state_label: state.label().to_string(),
reasoning: v.reasoning.clone(),
suggested_actions: v.suggested_actions.clone(),
similar_to: (!v.similar_to.is_empty()).then(|| v.similar_to.clone()),
evidence_gaps: v.evidence_gaps.clone(),
severity: v.severity.clone(),
data_freshness_at: v.data_freshness_at.clone(),
data_oldest_signal_at: v.data_oldest_signal_at.clone(),
stale_since_at: v.stale_since_at.clone(),
staleness_reason: v.staleness_reason.clone(),
computed_at: v.computed_at.clone(),
previous_verdict: v.previous_verdict.clone(),
availability: v.availability.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
pub fn render_verdict(verdict: &Verdict, _opts: &RenderOpts) {
let response = VerdictResponse::from(verdict);
let result: Result<String, serde_json::Error> = serde_json::to_string_pretty(&response);
match result {
Ok(json) => {println!("{}", json)},
_ => {},
}
}
#[cfg(test)]
mod tests {
use super::*;
use cleanlib_client::types::{Verdict, PreviousVerdict, AvailabilityBlock};
fn make_verdict(verdict_label: &str, composite_score: u8) -> Verdict {
Verdict {
verdict_id: "vrd-test-001".to_string(),
verdict: verdict_label.to_string(),
source: "npm".to_string(),
confidence: 0.85,
composite_score,
reasoning: "Test reasoning.".to_string(),
similar_to: vec![],
evidence_gaps: vec![],
suggested_actions: vec![],
data_freshness_at: None,
data_oldest_signal_at: None,
stale_since_at: None,
staleness_reason: None,
computed_at: None,
severity: None,
decision: None,
previous_verdict: None,
availability: AvailabilityBlock::default(),
..Default::default()
}
}
#[test]
fn decision_derived_from_verdict_label_not_sdk_field() {
let v = make_verdict("ALLOWED_NO_FINDINGS", 30);
let r = VerdictResponse::from(&v);
assert_eq!(r.decision, Some("ALLOW".to_string()));
}
#[test]
fn sdk_decision_none_does_not_propagate_to_response() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.decision = None;
let r = VerdictResponse::from(&v);
assert!(r.decision.is_some(), "decision must never be None in VerdictResponse");
}
#[test]
fn allowed_no_findings_maps_to_allow() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 20));
assert_eq!(r.decision.as_deref(), Some("ALLOW"));
}
#[test]
fn vector_verdict_without_severity_maps_to_warn() {
let r = VerdictResponse::from(&make_verdict("VECTOR_VERDICT", 85));
assert_eq!(r.decision.as_deref(), Some("WARN"));
}
#[test]
fn vector_verdict_high_severity_maps_to_deny() {
let mut v = make_verdict("VECTOR_VERDICT", 85);
v.severity = Some("HIGH".into());
let r = VerdictResponse::from(&v);
assert_eq!(r.decision.as_deref(), Some("DENY"));
}
#[test]
fn dm_threshold_block_maps_to_deny() {
let r = VerdictResponse::from(&make_verdict("DM_THRESHOLD_BLOCK", 90));
assert_eq!(r.decision.as_deref(), Some("DENY"));
}
#[test]
fn risk_acceptance_required_maps_to_warn() {
let r = VerdictResponse::from(&make_verdict("RISK_ACCEPTANCE_REQUIRED", 55));
assert_eq!(r.decision.as_deref(), Some("WARN"));
}
#[test]
fn unknown_label_high_score_maps_to_deny() {
let r = VerdictResponse::from(&make_verdict("INSUFFICIENT_DATA", 70));
assert_eq!(r.decision.as_deref(), Some("DENY"));
}
#[test]
fn unknown_label_low_score_maps_to_warn() {
let r = VerdictResponse::from(&make_verdict("INSUFFICIENT_DATA", 69));
assert_eq!(r.decision.as_deref(), Some("WARN"));
}
#[test]
fn verdict_field_engine_tag_is_masked() {
let r = VerdictResponse::from(&make_verdict("VECTOR_VERDICT", 85));
assert_eq!(r.verdict, "Engine signal");
}
#[test]
fn source_with_engine_tag_is_masked() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.source = "VECTOR_VERDICT".to_string();
let r = VerdictResponse::from(&v);
assert_eq!(r.source, "Engine signal");
}
#[test]
fn source_with_fixture_prefix_is_hidden() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.source = "mock_npm_fixture".to_string();
let r = VerdictResponse::from(&v);
assert_eq!(r.source, "");
}
#[test]
fn customer_facing_source_passes_through() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.source = "Cleanstart-curated catalog".to_string();
let r = VerdictResponse::from(&v);
assert_eq!(r.source, "Cleanstart-curated catalog");
}
#[test]
fn similar_to_empty_coerced_to_none() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.similar_to = vec![];
let r = VerdictResponse::from(&v);
assert!(r.similar_to.is_none());
}
#[test]
fn similar_to_nonempty_preserved() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.similar_to = vec!["vrd-abc".to_string(), "vrd-def".to_string()];
let r = VerdictResponse::from(&v);
assert_eq!(r.similar_to, Some(vec!["vrd-abc".to_string(), "vrd-def".to_string()]));
}
#[test]
fn decision_always_present_in_json() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("decision").is_some(), "decision key must be in JSON output");
assert!(!json["decision"].is_null(), "decision value must not be null");
}
#[test]
fn similar_to_omitted_from_json_when_empty_input() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("similar_to").is_none(), "similar_to must be absent from JSON when empty");
}
#[test]
fn similar_to_present_in_json_when_nonempty() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.similar_to = vec!["vrd-xyz".to_string()];
let r = VerdictResponse::from(&v);
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("similar_to").is_some());
}
#[test]
fn stale_since_at_omitted_from_json_when_none() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("stale_since_at").is_none());
}
#[test]
fn stale_since_at_present_in_json_when_set() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.stale_since_at = Some("2026-01-01T00:00:00Z".to_string());
let r = VerdictResponse::from(&v);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["stale_since_at"], "2026-01-01T00:00:00Z");
}
#[test]
fn staleness_reason_omitted_from_json_when_none() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("staleness_reason").is_none());
}
#[test]
fn previous_verdict_omitted_from_json_when_none() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("previous_verdict").is_none());
}
#[test]
fn previous_verdict_present_in_json_when_set() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.previous_verdict = Some(PreviousVerdict {
verdict_id: "vrd-prev".to_string(),
verdict: "ALLOWED_NO_FINDINGS".to_string(),
computed_at: "2026-01-01T00:00:00Z".to_string(),
diff: "no change".to_string(),
});
let r = VerdictResponse::from(&v);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["previous_verdict"]["verdict_id"], "vrd-prev");
}
#[test]
fn availability_omitted_from_json_when_default() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("availability").is_none());
}
#[test]
fn availability_present_in_json_when_set() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.availability = AvailabilityBlock {
degraded_stale: true,
..AvailabilityBlock::default()
};
let r = VerdictResponse::from(&v);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["availability"]["degraded_stale"], true);
}
#[test]
fn availability_default_deserialize_on_missing_key() {
let raw = r#"{
"verdict_id":"vrd-test-noavail",
"verdict":"ALLOWED_NO_FINDINGS",
"source":"npm",
"confidence":1.0,
"composite_score":0,
"reasoning":"no block on wire",
"similar_to":[],
"evidence_gaps":[],
"suggested_actions":[],
"data_freshness_at":null,
"data_oldest_signal_at":null,
"stale_since_at":null,
"staleness_reason":null,
"computed_at":null,
"severity":null,
"decision":null,
"previous_verdict":null
}"#;
let v: Verdict = serde_json::from_str(raw).expect("deserialize succeeds");
assert_eq!(v.availability, AvailabilityBlock::default());
assert_eq!(v.availability.degraded_stale, false);
}
#[test]
fn scalar_fields_pass_through_unchanged() {
let v = make_verdict("ALLOWED_NO_FINDINGS", 42);
let r = VerdictResponse::from(&v);
assert_eq!(r.verdict_id, "vrd-test-001");
assert_eq!(r.confidence, 0.85);
assert_eq!(r.composite_score, 42);
assert_eq!(r.reasoning, "Test reasoning.");
}
fn make_verdict_with_source(source: &str, verdict_label: &str) -> Verdict {
let mut v = make_verdict(verdict_label, 30);
v.source = source.to_string();
v
}
#[test]
fn state_field_derived_from_wire_source_insufficient_data() {
let r = VerdictResponse::from(&make_verdict_with_source(
"INSUFFICIENT_DATA",
"INSUFFICIENT_DATA",
));
assert_eq!(r.state, "not_yet_assessed");
assert_eq!(r.state_label, "Not yet assessed");
}
#[test]
fn state_field_derived_from_wire_source_clean() {
let r = VerdictResponse::from(&make_verdict_with_source(
"ALLOWED_NO_FINDINGS",
"ALLOWED_NO_FINDINGS",
));
assert_eq!(r.state, "clean");
assert_eq!(r.state_label, "Clean");
}
#[test]
fn state_field_derived_from_wire_source_vulnerable() {
let r = VerdictResponse::from(&make_verdict_with_source("CVE_FINDING", "VECTOR_VERDICT"));
assert_eq!(r.state, "vulnerable");
assert_eq!(r.state_label, "Vulnerable");
}
#[test]
fn state_field_derived_from_wire_source_actively_exploited() {
let r = VerdictResponse::from(&make_verdict_with_source(
"CVE_FINDING_ON_KEV",
"VECTOR_VERDICT",
));
assert_eq!(r.state, "actively_exploited");
assert_eq!(r.state_label, "Actively exploited");
}
#[test]
fn state_field_fails_closed_to_not_yet_assessed_on_unknown_wire_source() {
let r = VerdictResponse::from(&make_verdict_with_source(
"SOME_FUTURE_WIRE_TAG",
"SOME_FUTURE_WIRE_TAG",
));
assert_eq!(r.state, "not_yet_assessed");
assert_ne!(r.state, "clean");
}
#[test]
fn state_field_always_present_in_json_output() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("state").is_some(), "state key must be present");
assert!(!json["state"].is_null(), "state must not be null");
assert!(
json.get("state_label").is_some(),
"state_label key must be present"
);
assert!(
!json["state_label"].is_null(),
"state_label must not be null"
);
}
#[test]
fn verdict_field_no_longer_leaks_insufficient_data_codename() {
let r = VerdictResponse::from(&make_verdict("INSUFFICIENT_DATA", 30));
assert!(
!r.verdict.contains("INSUFFICIENT_DATA"),
"raw wire codename leaked in verdict field: {}",
r.verdict
);
assert_eq!(r.verdict, "Not yet assessed");
}
}