1use serde::{Deserialize, Serialize};
2use std::str::FromStr;
3
4#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
5pub struct ReviewTarget {
6 pub registry_host: String,
7 pub package_name: String,
8 pub package_version: String,
9 pub package_hash: String,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ReviewFile {
14 pub file_path: String,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub file_hash: Option<FileHash>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub summary: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub security_summary: Option<SecuritySummary>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub confidence: Option<ReviewConfidence>,
28 pub comments: Vec<ReviewComment>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
34pub struct PackageManifest {
35 pub files: Vec<PackageManifestFile>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
41pub struct PackageManifestFile {
42 pub path: String,
44 pub size_bytes: u64,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)]
50pub struct FileHash {
51 pub algorithm: FileHashAlgorithm,
53 pub value: String,
55}
56
57impl FileHash {
58 pub fn blake3(value: impl Into<String>) -> Self {
60 Self {
61 algorithm: FileHashAlgorithm::Blake3,
62 value: value.into(),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)]
69#[serde(rename_all = "lowercase")]
70pub enum FileHashAlgorithm {
71 Blake3,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ReviewComment {
77 pub comment: String,
78 pub security: Priority,
79 pub complexity: Priority,
80 #[serde(default)]
81 pub selection: Option<Selection>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Selection {
86 pub start: Position,
87 pub end: Position,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Position {
92 pub line: i64,
93 pub character: i64,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ReviewSubmission {
98 pub target: ReviewTarget,
99 #[serde(alias = "metadata")]
100 pub reviewer_details: ReviewerDetails,
101 pub files: Vec<ReviewFile>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub package_manifest: Option<PackageManifest>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub overall_security_summary: Option<SecuritySummary>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub overall_security_confidence: Option<ReviewConfidence>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub agent_summary: Option<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ReviewRecord {
114 pub id: String,
115 pub target: ReviewTarget,
116 pub reviewer_details: ReviewerDetails,
117 pub files: Vec<ReviewFile>,
118 #[serde(default)]
119 pub agent_summary: Option<String>,
120 pub overall_security_summary: SecuritySummary,
121 #[serde(default)]
122 pub overall_security_confidence: Option<ReviewConfidence>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ReviewerDetails {
127 pub public_user_id: String,
128 pub agent_name: String,
129 pub agent_model: String,
130 pub agent_reasoning_effort: String,
131 pub review_strategy: String,
132 pub review_scope: ReviewScope,
133 pub created_at: String,
134 #[serde(alias = "tool_version")]
135 pub thirdpass_version: String,
136}
137
138#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum ReviewScope {
141 TargetFileFull,
142 TargetFilePartial,
143}
144
145impl ReviewScope {
146 pub fn as_str(&self) -> &'static str {
147 match self {
148 ReviewScope::TargetFileFull => "target_file_full",
149 ReviewScope::TargetFilePartial => "target_file_partial",
150 }
151 }
152
153 pub fn parse_or_partial(value: &str) -> Self {
154 match value {
155 "target_file_full" => ReviewScope::TargetFileFull,
156 "target_file_partial" => ReviewScope::TargetFilePartial,
157 _ => ReviewScope::TargetFilePartial,
158 }
159 }
160}
161
162impl FromStr for ReviewScope {
163 type Err = ();
164
165 fn from_str(value: &str) -> Result<Self, Self::Err> {
166 Ok(Self::parse_or_partial(value))
167 }
168}
169
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
171#[serde(rename_all = "lowercase")]
172pub enum Priority {
173 Critical,
174 Medium,
175 Low,
176}
177
178#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
179#[serde(rename_all = "lowercase")]
180pub enum SecuritySummary {
181 Critical,
182 Medium,
183 Low,
184 None,
185}
186
187#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
188#[serde(rename_all = "lowercase")]
189pub enum ReviewConfidence {
190 High,
191 Medium,
192 Low,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ReviewQuery {
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub registry_host: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub package_name: Option<String>,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub package_version: Option<String>,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub file_path: Option<String>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ReviewRequest {
209 pub candidates: Vec<ReviewCandidate>,
211 pub supported_registry_hosts: Vec<String>,
213 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
215 pub review_target_policies:
216 std::collections::BTreeMap<String, crate::extension::ReviewTargetPolicy>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct ReviewAssignment {
221 pub target: Option<ReviewCandidate>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
225pub struct ReviewCandidate {
226 pub registry_host: String,
228 pub package_name: String,
230 pub package_version: String,
232 pub file_path: String,
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub file_paths: Vec<String>,
237 pub package_hash: String,
239}
240
241impl ReviewCandidate {
242 pub fn target_file_paths(&self) -> Vec<String> {
244 if self.file_paths.is_empty() {
245 vec![self.file_path.clone()]
246 } else {
247 self.file_paths.clone()
248 }
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct ReviewBatchRequest {
254 pub targets: Vec<ReviewTarget>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct ReviewBatchResponse {
259 pub reviews: Vec<ReviewRecord>,
260}
261
262impl FromStr for Priority {
263 type Err = ();
264
265 fn from_str(input: &str) -> Result<Self, Self::Err> {
266 match input.to_lowercase().as_str() {
267 "critical" => Ok(Priority::Critical),
268 "medium" => Ok(Priority::Medium),
269 "low" => Ok(Priority::Low),
270 _ => Err(()),
271 }
272 }
273}
274
275impl FromStr for SecuritySummary {
276 type Err = ();
277
278 fn from_str(input: &str) -> Result<Self, Self::Err> {
279 match input.to_lowercase().as_str() {
280 "critical" => Ok(SecuritySummary::Critical),
281 "medium" => Ok(SecuritySummary::Medium),
282 "low" => Ok(SecuritySummary::Low),
283 "none" => Ok(SecuritySummary::None),
284 _ => Err(()),
285 }
286 }
287}
288
289impl FromStr for ReviewConfidence {
290 type Err = ();
291
292 fn from_str(input: &str) -> Result<Self, Self::Err> {
293 match input.to_lowercase().as_str() {
294 "high" => Ok(ReviewConfidence::High),
295 "medium" => Ok(ReviewConfidence::Medium),
296 "low" => Ok(ReviewConfidence::Low),
297 _ => Err(()),
298 }
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use serde_json::json;
306
307 #[test]
308 fn review_file_serializes_blake3_hash_metadata() {
309 let file = ReviewFile {
310 file_path: "src/index.js".to_string(),
311 file_hash: Some(FileHash::blake3("abc123")),
312 summary: Some("Reviewed the entrypoint.".to_string()),
313 security_summary: Some(SecuritySummary::Low),
314 confidence: Some(ReviewConfidence::High),
315 comments: vec![],
316 };
317
318 let value = serde_json::to_value(file).expect("failed to serialize review file");
319
320 assert_eq!(
321 value,
322 json!({
323 "file_path": "src/index.js",
324 "file_hash": {
325 "algorithm": "blake3",
326 "value": "abc123"
327 },
328 "summary": "Reviewed the entrypoint.",
329 "security_summary": "low",
330 "confidence": "high",
331 "comments": []
332 })
333 );
334 }
335
336 #[test]
337 fn review_file_defaults_missing_file_hash() {
338 let file: ReviewFile = serde_json::from_value(json!({
339 "file_path": "src/index.js",
340 "comments": []
341 }))
342 .expect("failed to deserialize review file");
343
344 assert_eq!(file.file_hash, None);
345 assert_eq!(file.summary, None);
346 assert_eq!(file.security_summary, None);
347 assert_eq!(file.confidence, None);
348 }
349
350 #[test]
351 fn review_submission_defaults_missing_package_manifest() {
352 let submission: ReviewSubmission = serde_json::from_value(json!({
353 "target": {
354 "registry_host": "npmjs.com",
355 "package_name": "axios",
356 "package_version": "1.6.8",
357 "package_hash": "sha256:abc"
358 },
359 "reviewer_details": {
360 "public_user_id": "user-1",
361 "agent_name": "codex",
362 "agent_model": "gpt-5.5",
363 "agent_reasoning_effort": "high",
364 "review_strategy": "package-release/v1",
365 "review_scope": "target_file_full",
366 "created_at": "2026-05-04T00:00:00Z",
367 "thirdpass_version": "0.3.2"
368 },
369 "files": []
370 }))
371 .expect("failed to deserialize review submission");
372
373 assert_eq!(submission.package_manifest, None);
374 }
375
376 #[test]
377 fn review_request_carries_supported_registry_hosts() {
378 let request: ReviewRequest = serde_json::from_value(json!({
379 "candidates": [],
380 "supported_registry_hosts": ["crates.io", "npmjs.com"]
381 }))
382 .expect("failed to deserialize review request");
383
384 assert!(request.candidates.is_empty());
385 assert_eq!(
386 request.supported_registry_hosts,
387 vec!["crates.io", "npmjs.com"]
388 );
389 assert!(request.review_target_policies.is_empty());
390 }
391
392 #[test]
393 fn review_candidate_defaults_to_single_file_target() {
394 let candidate: ReviewCandidate = serde_json::from_value(json!({
395 "registry_host": "crates.io",
396 "package_name": "hashbrown",
397 "package_version": "0.17.1",
398 "file_path": "src/map.rs",
399 "package_hash": "hash"
400 }))
401 .expect("failed to deserialize review candidate");
402
403 assert_eq!(candidate.target_file_paths(), vec!["src/map.rs"]);
404 }
405
406 #[test]
407 fn review_candidate_can_include_bundled_file_targets() {
408 let candidate: ReviewCandidate = serde_json::from_value(json!({
409 "registry_host": "crates.io",
410 "package_name": "hashbrown",
411 "package_version": "0.17.1",
412 "file_path": "src/map.rs",
413 "file_paths": ["src/map.rs", "src/raw.rs"],
414 "package_hash": "hash"
415 }))
416 .expect("failed to deserialize review candidate");
417
418 assert_eq!(candidate.file_path, "src/map.rs");
419 assert_eq!(
420 candidate.target_file_paths(),
421 vec!["src/map.rs", "src/raw.rs"]
422 );
423 }
424}