1use serde::{Deserialize, Serialize};
9use std::{fmt, str::FromStr};
10
11#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
13pub struct ReviewTarget {
14 pub registry_host: String,
16 pub package_name: String,
18 pub package_version: String,
20 pub package_hash: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ReviewFile {
27 pub file_path: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub file_hash: Option<FileHash>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub summary: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub security_summary: Option<SecuritySummary>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub confidence: Option<ReviewConfidence>,
41 pub comments: Vec<ReviewComment>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
47pub struct PackageManifest {
48 pub files: Vec<PackageManifestFile>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
54pub struct PackageManifestFile {
55 pub path: String,
57 pub size_bytes: u64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)]
63pub struct FileHash {
64 pub algorithm: FileHashAlgorithm,
66 pub value: String,
68}
69
70impl FileHash {
71 pub fn blake3(value: impl Into<String>) -> Self {
73 Self {
74 algorithm: FileHashAlgorithm::Blake3,
75 value: value.into(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)]
82#[serde(rename_all = "lowercase")]
83pub enum FileHashAlgorithm {
84 Blake3,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ReviewComment {
91 pub comment: String,
93 pub security: Priority,
95 pub complexity: Priority,
97 #[serde(default)]
99 pub selection: Option<Selection>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd)]
104pub struct Selection {
105 pub start: Position,
107 pub end: Position,
109}
110
111#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd)]
113pub struct Position {
114 pub line: i64,
116 pub character: i64,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ReviewSubmission {
123 pub target: ReviewTarget,
125 pub reviewer_details: ReviewerDetails,
127 pub files: Vec<ReviewFile>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub package_manifest: Option<PackageManifest>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub overall_security_summary: Option<SecuritySummary>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub overall_security_confidence: Option<ReviewConfidence>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub agent_summary: Option<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ReviewRecord {
146 pub id: String,
148 pub target: ReviewTarget,
150 pub reviewer_details: ReviewerDetails,
152 pub files: Vec<ReviewFile>,
154 #[serde(default)]
156 pub agent_summary: Option<String>,
157 pub overall_security_summary: SecuritySummary,
159 #[serde(default)]
161 pub overall_security_confidence: Option<ReviewConfidence>,
162}
163
164#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq, Hash)]
166pub struct ReviewerDetails {
167 pub public_user_id: String,
169 pub agent_name: String,
171 pub agent_model: String,
173 pub agent_reasoning_effort: String,
175 pub review_strategy: String,
177 pub review_scope: ReviewScope,
179 pub created_at: String,
181 pub thirdpass_version: String,
183}
184
185#[derive(
187 Debug, Clone, Copy, Default, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd,
188)]
189#[serde(rename_all = "snake_case")]
190pub enum ReviewScope {
191 TargetFileFull,
193 #[default]
195 TargetFilePartial,
196}
197
198impl ReviewScope {
199 pub fn as_str(&self) -> &'static str {
201 match self {
202 ReviewScope::TargetFileFull => "target_file_full",
203 ReviewScope::TargetFilePartial => "target_file_partial",
204 }
205 }
206
207 pub fn parse_or_partial(value: &str) -> Self {
209 match value {
210 "target_file_full" => ReviewScope::TargetFileFull,
211 "target_file_partial" => ReviewScope::TargetFilePartial,
212 _ => ReviewScope::TargetFilePartial,
213 }
214 }
215}
216
217impl FromStr for ReviewScope {
218 type Err = ();
219
220 fn from_str(value: &str) -> Result<Self, Self::Err> {
221 Ok(Self::parse_or_partial(value))
222 }
223}
224
225#[derive(
227 Debug, Clone, Copy, Default, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd,
228)]
229#[serde(rename_all = "lowercase")]
230pub enum Priority {
231 Critical,
233 #[default]
235 Medium,
236 Low,
238}
239
240#[derive(
242 Debug, Clone, Copy, Default, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd,
243)]
244#[serde(rename_all = "lowercase")]
245pub enum SecuritySummary {
246 Critical,
248 Medium,
250 Low,
252 #[default]
254 None,
255}
256
257#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd)]
259#[serde(rename_all = "lowercase")]
260pub enum ReviewConfidence {
261 High,
263 Medium,
265 Low,
267}
268
269impl fmt::Display for Priority {
270 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271 write!(f, "{}", format!("{:?}", self).to_lowercase())
272 }
273}
274
275impl fmt::Display for SecuritySummary {
276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277 write!(f, "{}", format!("{:?}", self).to_lowercase())
278 }
279}
280
281impl fmt::Display for ReviewConfidence {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 write!(f, "{}", format!("{:?}", self).to_lowercase())
284 }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct ReviewQuery {
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub registry_host: Option<String>,
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub package_name: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub package_version: Option<String>,
299 #[serde(skip_serializing_if = "Option::is_none")]
301 pub file_path: Option<String>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct ReviewRequest {
307 pub candidates: Vec<ReviewCandidate>,
309 pub supported_registry_hosts: Vec<String>,
311 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
313 pub review_target_policies:
314 std::collections::BTreeMap<String, crate::extension::ReviewTargetPolicy>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ReviewAssignment {
320 pub target: Option<ReviewCandidate>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
326pub struct ReviewCandidate {
327 pub registry_host: String,
329 pub package_name: String,
331 pub package_version: String,
333 pub file_path: String,
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
337 pub file_paths: Vec<String>,
338 pub package_hash: String,
340}
341
342impl ReviewCandidate {
343 pub fn target_file_paths(&self) -> Vec<String> {
345 if self.file_paths.is_empty() {
346 vec![self.file_path.clone()]
347 } else {
348 self.file_paths.clone()
349 }
350 }
351}
352
353impl FromStr for Priority {
354 type Err = ();
355
356 fn from_str(input: &str) -> Result<Self, Self::Err> {
357 match input.to_lowercase().as_str() {
358 "critical" => Ok(Priority::Critical),
359 "medium" => Ok(Priority::Medium),
360 "low" => Ok(Priority::Low),
361 _ => Err(()),
362 }
363 }
364}
365
366impl FromStr for SecuritySummary {
367 type Err = ();
368
369 fn from_str(input: &str) -> Result<Self, Self::Err> {
370 match input.to_lowercase().as_str() {
371 "critical" => Ok(SecuritySummary::Critical),
372 "medium" => Ok(SecuritySummary::Medium),
373 "low" => Ok(SecuritySummary::Low),
374 "none" => Ok(SecuritySummary::None),
375 _ => Err(()),
376 }
377 }
378}
379
380impl FromStr for ReviewConfidence {
381 type Err = ();
382
383 fn from_str(input: &str) -> Result<Self, Self::Err> {
384 match input.to_lowercase().as_str() {
385 "high" => Ok(ReviewConfidence::High),
386 "medium" => Ok(ReviewConfidence::Medium),
387 "low" => Ok(ReviewConfidence::Low),
388 _ => Err(()),
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use serde_json::json;
397
398 #[test]
399 fn review_file_serializes_blake3_hash_metadata() {
400 let file = ReviewFile {
401 file_path: "src/index.js".to_string(),
402 file_hash: Some(FileHash::blake3("abc123")),
403 summary: Some("Reviewed the entrypoint.".to_string()),
404 security_summary: Some(SecuritySummary::Low),
405 confidence: Some(ReviewConfidence::High),
406 comments: vec![],
407 };
408
409 let value = serde_json::to_value(file).expect("failed to serialize review file");
410
411 assert_eq!(
412 value,
413 json!({
414 "file_path": "src/index.js",
415 "file_hash": {
416 "algorithm": "blake3",
417 "value": "abc123"
418 },
419 "summary": "Reviewed the entrypoint.",
420 "security_summary": "low",
421 "confidence": "high",
422 "comments": []
423 })
424 );
425 }
426
427 #[test]
428 fn review_file_defaults_missing_file_hash() {
429 let file: ReviewFile = serde_json::from_value(json!({
430 "file_path": "src/index.js",
431 "comments": []
432 }))
433 .expect("failed to deserialize review file");
434
435 assert_eq!(file.file_hash, None);
436 assert_eq!(file.summary, None);
437 assert_eq!(file.security_summary, None);
438 assert_eq!(file.confidence, None);
439 }
440
441 #[test]
442 fn review_submission_defaults_missing_package_manifest() {
443 let submission: ReviewSubmission = serde_json::from_value(json!({
444 "target": {
445 "registry_host": "npmjs.com",
446 "package_name": "axios",
447 "package_version": "1.6.8",
448 "package_hash": "sha256:abc"
449 },
450 "reviewer_details": {
451 "public_user_id": "user-1",
452 "agent_name": "codex",
453 "agent_model": "gpt-5.5",
454 "agent_reasoning_effort": "high",
455 "review_strategy": "package-release/v1",
456 "review_scope": "target_file_full",
457 "created_at": "2026-05-04T00:00:00Z",
458 "thirdpass_version": "0.3.2"
459 },
460 "files": []
461 }))
462 .expect("failed to deserialize review submission");
463
464 assert_eq!(submission.package_manifest, None);
465 }
466
467 #[test]
468 fn review_request_carries_supported_registry_hosts() {
469 let request: ReviewRequest = serde_json::from_value(json!({
470 "candidates": [],
471 "supported_registry_hosts": ["crates.io", "npmjs.com"]
472 }))
473 .expect("failed to deserialize review request");
474
475 assert!(request.candidates.is_empty());
476 assert_eq!(
477 request.supported_registry_hosts,
478 vec!["crates.io", "npmjs.com"]
479 );
480 assert!(request.review_target_policies.is_empty());
481 }
482
483 #[test]
484 fn review_candidate_defaults_to_single_file_target() {
485 let candidate: ReviewCandidate = serde_json::from_value(json!({
486 "registry_host": "crates.io",
487 "package_name": "hashbrown",
488 "package_version": "0.17.1",
489 "file_path": "src/map.rs",
490 "package_hash": "hash"
491 }))
492 .expect("failed to deserialize review candidate");
493
494 assert_eq!(candidate.target_file_paths(), vec!["src/map.rs"]);
495 }
496
497 #[test]
498 fn review_candidate_can_include_bundled_file_targets() {
499 let candidate: ReviewCandidate = serde_json::from_value(json!({
500 "registry_host": "crates.io",
501 "package_name": "hashbrown",
502 "package_version": "0.17.1",
503 "file_path": "src/map.rs",
504 "file_paths": ["src/map.rs", "src/raw.rs"],
505 "package_hash": "hash"
506 }))
507 .expect("failed to deserialize review candidate");
508
509 assert_eq!(candidate.file_path, "src/map.rs");
510 assert_eq!(
511 candidate.target_file_paths(),
512 vec!["src/map.rs", "src/raw.rs"]
513 );
514 }
515}