1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Deserialize, Serialize)]
21#[serde(default)]
22pub struct Verdict {
23 pub verdict_id: String,
24 pub verdict: String,
27 pub source: String,
28 pub confidence: f64,
29 pub composite_score: u8,
30 pub reasoning: String,
31 pub similar_to: Vec<String>,
32 pub evidence_gaps: Vec<String>,
33 pub suggested_actions: Vec<String>,
34 pub data_freshness_at: Option<String>,
35 pub data_oldest_signal_at: Option<String>,
36 pub stale_since_at: Option<String>,
37 pub staleness_reason: Option<String>,
38 pub computed_at: Option<String>,
39 pub severity: Option<String>,
43 pub decision: Option<String>,
47}
48
49impl Default for Verdict {
50 fn default() -> Self {
51 Self {
52 verdict_id: String::new(),
53 verdict: String::new(),
54 source: String::new(),
55 confidence: 0.0,
56 composite_score: 0,
57 reasoning: String::new(),
58 similar_to: Vec::new(),
59 evidence_gaps: Vec::new(),
60 suggested_actions: Vec::new(),
61 data_freshness_at: None,
62 data_oldest_signal_at: None,
63 stale_since_at: None,
64 staleness_reason: None,
65 computed_at: None,
66 severity: None,
67 decision: None,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct PackageRef {
75 pub ecosystem: String,
76 pub name: String,
77 pub version: String,
78}
79
80#[derive(Debug, Clone, Serialize)]
84pub struct PolicyPreviewRequest {
85 pub packages: Vec<PackageRef>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub policy: Option<serde_json::Value>,
88}
89
90#[derive(Debug, Clone, Deserialize, Serialize, Default)]
93#[serde(default)]
94pub struct PolicyDecision {
95 pub ecosystem: String,
96 pub package: String,
97 pub version: String,
98 pub decision: String,
100 pub reason: String,
101 pub verdict_id: Option<String>,
102 pub policy_rule_id: Option<String>,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize, Default)]
107#[serde(default)]
108pub struct PolicyPreviewResponse {
109 pub decisions: Vec<PolicyDecision>,
110}
111
112#[derive(Debug, Clone, Deserialize, Serialize, Default)]
114#[serde(default)]
115pub struct AuditEntry {
116 pub request_id: String,
117 pub at: String,
118 pub ecosystem: String,
119 pub package: String,
120 pub version: String,
121 pub decision: String,
122 pub reason: String,
123 pub verdict_id: Option<String>,
124}
125
126#[derive(Debug, Clone, Deserialize, Serialize, Default)]
128#[serde(default)]
129pub struct AuditResponse {
130 pub entries: Vec<AuditEntry>,
131 pub next_cursor: Option<String>,
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn parses_minimal_verdict() {
140 let json = r#"{
141 "verdict_id": "01JBYK000",
142 "verdict": "ALLOWED_NO_FINDINGS",
143 "source": "ALLOWED_NO_FINDINGS"
144 }"#;
145 let v: Verdict = serde_json::from_str(json).unwrap();
146 assert_eq!(v.verdict_id, "01JBYK000");
147 assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
148 assert_eq!(v.confidence, 0.0);
149 assert!(v.similar_to.is_empty());
150 }
151
152 #[test]
153 fn parses_full_verdict() {
154 let json = r#"{
155 "verdict_id": "01JBYK001",
156 "verdict": "VECTOR_VERDICT",
157 "source": "VECTOR_VERDICT",
158 "confidence": 0.98,
159 "composite_score": 92,
160 "reasoning": "Confirmed malware",
161 "similar_to": ["01JBYK999"],
162 "evidence_gaps": [],
163 "suggested_actions": ["DENY across customers"],
164 "data_freshness_at": "2026-05-21T10:00:00Z",
165 "computed_at": "2026-05-21T10:01:00Z"
166 }"#;
167 let v: Verdict = serde_json::from_str(json).unwrap();
168 assert_eq!(v.composite_score, 92);
169 assert_eq!(v.confidence, 0.98);
170 assert_eq!(v.similar_to.len(), 1);
171 assert_eq!(v.suggested_actions[0], "DENY across customers");
172 }
173
174 #[test]
175 fn parses_policy_preview_response() {
176 let json = r#"{
177 "decisions": [
178 {"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
179 {"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
180 ]
181 }"#;
182 let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
183 assert_eq!(resp.decisions.len(), 2);
184 assert_eq!(resp.decisions[0].decision, "ALLOW");
185 assert_eq!(resp.decisions[1].decision, "DENY");
186 assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
187 }
188
189 #[test]
190 fn parses_audit_response_with_cursor() {
191 let json = r#"{
192 "entries": [
193 {"request_id":"req-1","at":"2026-05-22T10:00:00Z","ecosystem":"npm","package":"lodash","version":"4.17.21","decision":"ALLOW","reason":"ok"}
194 ],
195 "next_cursor": "abc123"
196 }"#;
197 let resp: AuditResponse = serde_json::from_str(json).unwrap();
198 assert_eq!(resp.entries.len(), 1);
199 assert_eq!(resp.next_cursor.as_deref(), Some("abc123"));
200 }
201
202 #[test]
203 fn empty_audit_response_is_valid() {
204 let json = r#"{"entries": []}"#;
205 let resp: AuditResponse = serde_json::from_str(json).unwrap();
206 assert!(resp.entries.is_empty());
207 assert!(resp.next_cursor.is_none());
208 }
209
210 #[test]
211 fn policy_preview_request_omits_none_policy() {
212 let req = PolicyPreviewRequest {
213 packages: vec![PackageRef {
214 ecosystem: "npm".to_string(),
215 name: "lodash".to_string(),
216 version: "4.17.21".to_string(),
217 }],
218 policy: None,
219 };
220 let json = serde_json::to_string(&req).unwrap();
221 assert!(!json.contains("policy"));
223 assert!(json.contains("lodash"));
224 }
225
226 #[test]
227 fn policy_preview_request_emits_policy_when_some() {
228 let req = PolicyPreviewRequest {
229 packages: vec![],
230 policy: Some(serde_json::json!({"rules": []})),
231 };
232 let json = serde_json::to_string(&req).unwrap();
233 assert!(json.contains("\"policy\""));
234 assert!(json.contains("\"rules\""));
235 }
236
237 #[test]
238 fn round_trips_via_json() {
239 let v = Verdict {
240 verdict_id: "01JBYK002".to_string(),
241 verdict: "INSUFFICIENT_DATA".to_string(),
242 source: "INSUFFICIENT_DATA".to_string(),
243 stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
244 staleness_reason: Some("upstream silent >30d".to_string()),
245 ..Default::default()
246 };
247 let s = serde_json::to_string(&v).unwrap();
248 let parsed: Verdict = serde_json::from_str(&s).unwrap();
249 assert_eq!(parsed.verdict_id, "01JBYK002");
250 assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
251 }
252
253 #[test]
256 fn verdict_round_trips_severity_and_decision() {
257 let v = Verdict {
258 verdict_id: "01JM1S001".to_string(),
259 verdict: "VECTOR_VERDICT".to_string(),
260 source: "VECTOR_VERDICT".to_string(),
261 severity: Some("HIGH".to_string()),
262 decision: Some("DENY".to_string()),
263 ..Default::default()
264 };
265 let s = serde_json::to_string(&v).unwrap();
266 let parsed: Verdict = serde_json::from_str(&s).unwrap();
267 assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
268 assert_eq!(parsed.decision.as_deref(), Some("DENY"));
269 }
270
271 #[test]
272 fn verdict_tolerates_missing_severity_and_decision() {
273 let pre_m1_json = r#"{
276 "verdict_id": "01JM1S002",
277 "verdict": "ALLOWED_NO_FINDINGS",
278 "source": "ALLOWED_NO_FINDINGS",
279 "confidence": 0.95,
280 "composite_score": 8,
281 "reasoning": "",
282 "similar_to": [],
283 "evidence_gaps": [],
284 "suggested_actions": []
285 }"#;
286 let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
287 assert!(v.severity.is_none());
288 assert!(v.decision.is_none());
289 }
290
291 #[test]
292 fn verdict_decision_canonical_values_match_js_py_go() {
293 for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
296 let v = Verdict {
297 decision: Some(d.to_string()),
298 ..Default::default()
299 };
300 let s = serde_json::to_string(&v).unwrap();
301 assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
302 }
303 }
304
305 #[test]
306 fn verdict_severity_canonical_values_match_cleanlib_core() {
307 for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
309 let v = Verdict {
310 severity: Some(sev.to_string()),
311 ..Default::default()
312 };
313 let s = serde_json::to_string(&v).unwrap();
314 assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
315 }
316 }
317}