Skip to main content

thirdpass_core/
schema.rs

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    /// Path of the reviewed file relative to the package root.
15    pub file_path: String,
16    /// Content hash for the reviewed file, when the client can compute it.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub file_hash: Option<FileHash>,
19    /// Agent-written summary for this individual file review.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub summary: Option<String>,
22    /// Security severity for this individual file review.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub security_summary: Option<SecuritySummary>,
25    /// Agent confidence for this individual file review.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub confidence: Option<ReviewConfidence>,
28    /// Specific comments reported for the reviewed file.
29    pub comments: Vec<ReviewComment>,
30}
31
32/// File inventory for a package archive.
33#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
34pub struct PackageManifest {
35    /// Regular files found in the extracted package archive.
36    pub files: Vec<PackageManifestFile>,
37}
38
39/// Metadata for a regular file in a package archive.
40#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
41pub struct PackageManifestFile {
42    /// Path of the file relative to the package root.
43    pub path: String,
44    /// Size of the file contents in bytes.
45    pub size_bytes: u64,
46}
47
48/// Content hash for a file included in a review.
49#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)]
50pub struct FileHash {
51    /// Algorithm used to produce the hash digest.
52    pub algorithm: FileHashAlgorithm,
53    /// Lowercase hexadecimal hash digest.
54    pub value: String,
55}
56
57impl FileHash {
58    /// Build a Blake3 file hash from a lowercase hexadecimal digest.
59    pub fn blake3(value: impl Into<String>) -> Self {
60        Self {
61            algorithm: FileHashAlgorithm::Blake3,
62            value: value.into(),
63        }
64    }
65}
66
67/// Supported content hash algorithms for reviewed files.
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)]
69#[serde(rename_all = "lowercase")]
70pub enum FileHashAlgorithm {
71    /// The Blake3 cryptographic hash algorithm.
72    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    /// Explicit candidate targets the client can choose from.
210    pub candidates: Vec<ReviewCandidate>,
211    /// Registry hosts supported by the requesting client.
212    pub supported_registry_hosts: Vec<String>,
213    /// Automatic target selection policies for supported registry hosts.
214    #[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    /// Registry host that identifies the package ecosystem.
227    pub registry_host: String,
228    /// Package name to review.
229    pub package_name: String,
230    /// Package version to review.
231    pub package_version: String,
232    /// Primary file path for legacy clients and single-file targets.
233    pub file_path: String,
234    /// Full file list for bundled targets.
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub file_paths: Vec<String>,
237    /// Package content hash for the selected release archive.
238    pub package_hash: String,
239}
240
241impl ReviewCandidate {
242    /// Return the files included in this assignment.
243    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}