Skip to main content

thirdpass_core/
schema.rs

1//! Wire-format types shared by Thirdpass clients and the server API.
2//!
3//! These structures are serialized as JSON for review requests, review
4//! assignments, review submissions, and review records. They intentionally keep
5//! fields simple and explicit so API consumers can construct or inspect payloads
6//! without depending on CLI-only state.
7
8use serde::{Deserialize, Serialize};
9use std::{fmt, str::FromStr};
10
11/// Package release that a review or assignment refers to.
12#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
13pub struct ReviewTarget {
14    /// Registry host that identifies the package ecosystem.
15    pub registry_host: String,
16    /// Package name inside the registry.
17    pub package_name: String,
18    /// Package version inside the registry.
19    pub package_version: String,
20    /// Content hash for the package source artifact.
21    pub package_hash: String,
22}
23
24/// Review output for a single package-relative file.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ReviewFile {
27    /// Path of the reviewed file relative to the package root.
28    pub file_path: String,
29    /// Content hash for the reviewed file, when the client can compute it.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub file_hash: Option<FileHash>,
32    /// Agent-written summary for this individual file review.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub summary: Option<String>,
35    /// Security severity for this individual file review.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub security_summary: Option<SecuritySummary>,
38    /// Agent confidence for this individual file review.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub confidence: Option<ReviewConfidence>,
41    /// Specific comments reported for the reviewed file.
42    pub comments: Vec<ReviewComment>,
43}
44
45/// File inventory for a package archive.
46#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
47pub struct PackageManifest {
48    /// Regular files found in the extracted package archive.
49    pub files: Vec<PackageManifestFile>,
50}
51
52/// Metadata for a regular file in a package archive.
53#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
54pub struct PackageManifestFile {
55    /// Path of the file relative to the package root.
56    pub path: String,
57    /// Size of the file contents in bytes.
58    pub size_bytes: u64,
59}
60
61/// Content hash for a file included in a review.
62#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)]
63pub struct FileHash {
64    /// Algorithm used to produce the hash digest.
65    pub algorithm: FileHashAlgorithm,
66    /// Lowercase hexadecimal hash digest.
67    pub value: String,
68}
69
70impl FileHash {
71    /// Build a Blake3 file hash from a lowercase hexadecimal digest.
72    pub fn blake3(value: impl Into<String>) -> Self {
73        Self {
74            algorithm: FileHashAlgorithm::Blake3,
75            value: value.into(),
76        }
77    }
78}
79
80/// Supported content hash algorithms for reviewed files.
81#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Hash)]
82#[serde(rename_all = "lowercase")]
83pub enum FileHashAlgorithm {
84    /// The Blake3 cryptographic hash algorithm.
85    Blake3,
86}
87
88/// Comment or finding reported during a file review.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ReviewComment {
91    /// Human-readable comment text.
92    pub comment: String,
93    /// Security priority assigned to the comment.
94    pub security: Priority,
95    /// Complexity priority assigned to the comment.
96    pub complexity: Priority,
97    /// Optional source selection associated with the comment.
98    #[serde(default)]
99    pub selection: Option<Selection>,
100}
101
102/// Source range selected by a review comment.
103#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd)]
104pub struct Selection {
105    /// Inclusive start position.
106    pub start: Position,
107    /// Exclusive end position.
108    pub end: Position,
109}
110
111/// Zero-based line and character position within a file.
112#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd)]
113pub struct Position {
114    /// Zero-based line number.
115    pub line: i64,
116    /// Zero-based character offset within the line.
117    pub character: i64,
118}
119
120/// Review submission sent by a client to the Thirdpass server.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ReviewSubmission {
123    /// Package release under review.
124    pub target: ReviewTarget,
125    /// Client and agent metadata for the reviewer.
126    pub reviewer_details: ReviewerDetails,
127    /// Files covered by this submission.
128    pub files: Vec<ReviewFile>,
129    /// Authoritative package file inventory, when supplied by trusted tooling.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub package_manifest: Option<PackageManifest>,
132    /// Overall security summary across reviewed files.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub overall_security_summary: Option<SecuritySummary>,
135    /// Agent confidence in the overall security summary.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub overall_security_confidence: Option<ReviewConfidence>,
138    /// Agent-written package-level summary.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub agent_summary: Option<String>,
141}
142
143/// Approved review record returned by the server API.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ReviewRecord {
146    /// Server-assigned review identifier.
147    pub id: String,
148    /// Package release that was reviewed.
149    pub target: ReviewTarget,
150    /// Client and agent metadata for the reviewer.
151    pub reviewer_details: ReviewerDetails,
152    /// Files covered by this review.
153    pub files: Vec<ReviewFile>,
154    /// Agent-written package-level summary.
155    #[serde(default)]
156    pub agent_summary: Option<String>,
157    /// Overall security summary across reviewed files.
158    pub overall_security_summary: SecuritySummary,
159    /// Agent confidence in the overall security summary.
160    #[serde(default)]
161    pub overall_security_confidence: Option<ReviewConfidence>,
162}
163
164/// Metadata describing the client and agent that produced a review.
165#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq, Hash)]
166pub struct ReviewerDetails {
167    /// Public reviewer identifier shown by the website.
168    pub public_user_id: String,
169    /// Review agent executable or provider name.
170    pub agent_name: String,
171    /// Review agent model identifier.
172    pub agent_model: String,
173    /// Review agent reasoning effort or equivalent setting.
174    pub agent_reasoning_effort: String,
175    /// Strategy identifier used to produce the review.
176    pub review_strategy: String,
177    /// Scope of files covered by the review.
178    pub review_scope: ReviewScope,
179    /// Review creation timestamp serialized by the client.
180    pub created_at: String,
181    /// Thirdpass client version that produced the review.
182    pub thirdpass_version: String,
183}
184
185/// Scope of source coverage represented by a review.
186#[derive(
187    Debug, Clone, Copy, Default, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd,
188)]
189#[serde(rename_all = "snake_case")]
190pub enum ReviewScope {
191    /// The review covers the full target file.
192    TargetFileFull,
193    /// The review covers only part of the target file.
194    #[default]
195    TargetFilePartial,
196}
197
198impl ReviewScope {
199    /// Return the serialized snake-case value for this review scope.
200    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    /// Parse a review scope, defaulting unknown values to partial coverage.
208    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/// Coarse priority for a finding or comment.
226#[derive(
227    Debug, Clone, Copy, Default, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd,
228)]
229#[serde(rename_all = "lowercase")]
230pub enum Priority {
231    /// Critical priority.
232    Critical,
233    /// Medium priority.
234    #[default]
235    Medium,
236    /// Low priority.
237    Low,
238}
239
240/// Overall security outcome for a file or package review.
241#[derive(
242    Debug, Clone, Copy, Default, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd,
243)]
244#[serde(rename_all = "lowercase")]
245pub enum SecuritySummary {
246    /// Critical security concern found.
247    Critical,
248    /// Medium security concern found.
249    Medium,
250    /// Low security concern found.
251    Low,
252    /// No security concern found.
253    #[default]
254    None,
255}
256
257/// Confidence level assigned by a review agent.
258#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash, Ord, PartialOrd)]
259#[serde(rename_all = "lowercase")]
260pub enum ReviewConfidence {
261    /// High confidence.
262    High,
263    /// Medium confidence.
264    Medium,
265    /// Low confidence.
266    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/// Query parameters for filtering review records.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct ReviewQuery {
290    /// Optional registry host filter.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub registry_host: Option<String>,
293    /// Optional package name filter.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub package_name: Option<String>,
296    /// Optional package version filter.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub package_version: Option<String>,
299    /// Optional package-relative file path filter.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub file_path: Option<String>,
302}
303
304/// Request body used by a client when asking the server for review work.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct ReviewRequest {
307    /// Explicit candidate targets the client can choose from.
308    pub candidates: Vec<ReviewCandidate>,
309    /// Registry hosts supported by the requesting client.
310    pub supported_registry_hosts: Vec<String>,
311    /// Automatic target selection policies for supported registry hosts.
312    #[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/// Server assignment response for one review request.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ReviewAssignment {
320    /// Assigned target, or `None` when no target is available.
321    pub target: Option<ReviewCandidate>,
322}
323
324/// Candidate package files that a client can review.
325#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
326pub struct ReviewCandidate {
327    /// Registry host that identifies the package ecosystem.
328    pub registry_host: String,
329    /// Package name to review.
330    pub package_name: String,
331    /// Package version to review.
332    pub package_version: String,
333    /// Primary file path for single-file targets.
334    pub file_path: String,
335    /// Full file list for bundled targets.
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub file_paths: Vec<String>,
338    /// Package content hash for the selected release archive.
339    pub package_hash: String,
340}
341
342impl ReviewCandidate {
343    /// Return the files included in this assignment.
344    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}