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