use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Verdict {
pub verdict_id: String,
pub verdict: String,
pub source: String,
pub confidence: f64,
pub composite_score: u8,
pub reasoning: String,
pub similar_to: Vec<String>,
pub evidence_gaps: Vec<String>,
pub suggested_actions: Vec<String>,
pub data_freshness_at: Option<String>,
pub data_oldest_signal_at: Option<String>,
pub stale_since_at: Option<String>,
pub staleness_reason: Option<String>,
pub computed_at: Option<String>,
pub severity: Option<String>,
pub decision: Option<String>,
}
impl Default for Verdict {
fn default() -> Self {
Self {
verdict_id: String::new(),
verdict: String::new(),
source: String::new(),
confidence: 0.0,
composite_score: 0,
reasoning: String::new(),
similar_to: Vec::new(),
evidence_gaps: Vec::new(),
suggested_actions: Vec::new(),
data_freshness_at: None,
data_oldest_signal_at: None,
stale_since_at: None,
staleness_reason: None,
computed_at: None,
severity: None,
decision: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PackageRef {
pub ecosystem: String,
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PolicyPreviewRequest {
pub packages: Vec<PackageRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct PolicyDecision {
pub ecosystem: String,
pub package: String,
pub version: String,
pub decision: String,
pub reason: String,
pub verdict_id: Option<String>,
pub policy_rule_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct PolicyPreviewResponse {
pub decisions: Vec<PolicyDecision>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct AuditEntry {
pub request_id: String,
pub at: String,
pub ecosystem: String,
pub package: String,
pub version: String,
pub decision: String,
pub reason: String,
pub verdict_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct AuditResponse {
pub entries: Vec<AuditEntry>,
pub next_cursor: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_verdict() {
let json = r#"{
"verdict_id": "01JBYK000",
"verdict": "ALLOWED_NO_FINDINGS",
"source": "ALLOWED_NO_FINDINGS"
}"#;
let v: Verdict = serde_json::from_str(json).unwrap();
assert_eq!(v.verdict_id, "01JBYK000");
assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
assert_eq!(v.confidence, 0.0);
assert!(v.similar_to.is_empty());
}
#[test]
fn parses_full_verdict() {
let json = r#"{
"verdict_id": "01JBYK001",
"verdict": "VECTOR_VERDICT",
"source": "VECTOR_VERDICT",
"confidence": 0.98,
"composite_score": 92,
"reasoning": "Confirmed malware",
"similar_to": ["01JBYK999"],
"evidence_gaps": [],
"suggested_actions": ["DENY across customers"],
"data_freshness_at": "2026-05-21T10:00:00Z",
"computed_at": "2026-05-21T10:01:00Z"
}"#;
let v: Verdict = serde_json::from_str(json).unwrap();
assert_eq!(v.composite_score, 92);
assert_eq!(v.confidence, 0.98);
assert_eq!(v.similar_to.len(), 1);
assert_eq!(v.suggested_actions[0], "DENY across customers");
}
#[test]
fn parses_policy_preview_response() {
let json = r#"{
"decisions": [
{"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
{"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
]
}"#;
let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.decisions.len(), 2);
assert_eq!(resp.decisions[0].decision, "ALLOW");
assert_eq!(resp.decisions[1].decision, "DENY");
assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
}
#[test]
fn parses_audit_response_with_cursor() {
let json = r#"{
"entries": [
{"request_id":"req-1","at":"2026-05-22T10:00:00Z","ecosystem":"npm","package":"lodash","version":"4.17.21","decision":"ALLOW","reason":"ok"}
],
"next_cursor": "abc123"
}"#;
let resp: AuditResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.entries.len(), 1);
assert_eq!(resp.next_cursor.as_deref(), Some("abc123"));
}
#[test]
fn empty_audit_response_is_valid() {
let json = r#"{"entries": []}"#;
let resp: AuditResponse = serde_json::from_str(json).unwrap();
assert!(resp.entries.is_empty());
assert!(resp.next_cursor.is_none());
}
#[test]
fn policy_preview_request_omits_none_policy() {
let req = PolicyPreviewRequest {
packages: vec![PackageRef {
ecosystem: "npm".to_string(),
name: "lodash".to_string(),
version: "4.17.21".to_string(),
}],
policy: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("policy"));
assert!(json.contains("lodash"));
}
#[test]
fn policy_preview_request_emits_policy_when_some() {
let req = PolicyPreviewRequest {
packages: vec![],
policy: Some(serde_json::json!({"rules": []})),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"policy\""));
assert!(json.contains("\"rules\""));
}
#[test]
fn round_trips_via_json() {
let v = Verdict {
verdict_id: "01JBYK002".to_string(),
verdict: "INSUFFICIENT_DATA".to_string(),
source: "INSUFFICIENT_DATA".to_string(),
stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
staleness_reason: Some("upstream silent >30d".to_string()),
..Default::default()
};
let s = serde_json::to_string(&v).unwrap();
let parsed: Verdict = serde_json::from_str(&s).unwrap();
assert_eq!(parsed.verdict_id, "01JBYK002");
assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
}
#[test]
fn verdict_round_trips_severity_and_decision() {
let v = Verdict {
verdict_id: "01JM1S001".to_string(),
verdict: "VECTOR_VERDICT".to_string(),
source: "VECTOR_VERDICT".to_string(),
severity: Some("HIGH".to_string()),
decision: Some("DENY".to_string()),
..Default::default()
};
let s = serde_json::to_string(&v).unwrap();
let parsed: Verdict = serde_json::from_str(&s).unwrap();
assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
assert_eq!(parsed.decision.as_deref(), Some("DENY"));
}
#[test]
fn verdict_tolerates_missing_severity_and_decision() {
let pre_m1_json = r#"{
"verdict_id": "01JM1S002",
"verdict": "ALLOWED_NO_FINDINGS",
"source": "ALLOWED_NO_FINDINGS",
"confidence": 0.95,
"composite_score": 8,
"reasoning": "",
"similar_to": [],
"evidence_gaps": [],
"suggested_actions": []
}"#;
let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
assert!(v.severity.is_none());
assert!(v.decision.is_none());
}
#[test]
fn verdict_decision_canonical_values_match_js_py_go() {
for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
let v = Verdict {
decision: Some(d.to_string()),
..Default::default()
};
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
}
}
#[test]
fn verdict_severity_canonical_values_match_cleanlib_core() {
for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
let v = Verdict {
severity: Some(sev.to_string()),
..Default::default()
};
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
}
}
}