1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::path::{Component, Path, PathBuf};
11
12pub const CODE_REVIEW_SERVICE_INTERFACE_ID: &str = "bcode.code_review/v1";
14pub const REVIEW_PUBLISHER_INTERFACE_ID: &str = "bcode.review_publisher/v1";
16pub const REVIEW_IMPORTER_INTERFACE_ID: &str = "bcode.review_importer/v1";
18
19pub const OP_REVIEW_BUNDLE_GET: &str = "review.bundle.get";
21pub const OP_REVIEW_PUBLISHERS_LIST: &str = "review.publishers.list";
23pub const OP_REVIEW_PUBLISH_PREVIEW: &str = "review.publish.preview";
25pub const OP_REVIEW_PUBLISH_SUBMIT: &str = "review.publish.submit";
27pub const OP_REVIEW_PUBLISH_RECORD_SAVE: &str = "review.publish.record.save";
29pub const OP_REVIEW_PUBLISH_RECORDS_LIST: &str = "review.publish.records.list";
31pub const OP_REVIEW_WORKSPACE_LIST: &str = "review.workspace.list";
33pub const OP_REVIEW_WORKSPACE_CREATE: &str = "review.workspace.create";
35pub const OP_REVIEW_WORKSPACE_GET: &str = "review.workspace.get";
37pub const OP_REVIEW_WORKSPACE_UPDATE: &str = "review.workspace.update";
39pub const OP_REVIEW_WORKSPACE_ARCHIVE: &str = "review.workspace.archive";
41pub const OP_REVIEW_WORKSPACE_MATERIALIZE: &str = "review.workspace.materialize";
43pub const OP_REVIEW_REPO_FILE_GET: &str = "review.repo.file.get";
45pub const OP_REVIEW_SUGGESTIONS_LIST: &str = "review.suggestions.list";
47pub const OP_REVIEW_SUGGESTION_SAVE: &str = "review.suggestion.save";
49pub const OP_REVIEW_AI_EXCHANGES_LIST: &str = "review.ai_exchanges.list";
51pub const OP_REVIEW_AI_EXCHANGE_SAVE: &str = "review.ai_exchange.save";
53pub const OP_REVIEW_AI_EXCHANGE_DELETE: &str = "review.ai_exchange.delete";
55pub const REVIEW_AI_EXCHANGE_SCHEMA_VERSION: u16 = 1;
57pub const OP_REVIEW_EXTERNAL_IMPORT_SAVE: &str = "review.external_import.save";
59pub const OP_REVIEW_EXTERNAL_IMPORTS_LIST: &str = "review.external_imports.list";
61pub const OP_REVIEW_PUBLISHER_MANIFEST: &str = "review.publisher.manifest";
63pub const OP_REVIEW_PUBLISHER_PREVIEW: &str = "review.publisher.preview";
65pub const OP_REVIEW_PUBLISHER_SUBMIT: &str = "review.publisher.submit";
67pub const OP_REVIEW_IMPORTER_MANIFEST: &str = "review.importer.manifest";
69pub const OP_REVIEW_IMPORTER_IMPORT: &str = "review.importer.import";
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "kind", rename_all = "snake_case")]
75pub enum ReviewTarget {
76 WorkingTreeUnstaged,
78 IndexStaged,
80 WorkingTreeAndIndex,
82 LastCommit,
84 CommitRange {
86 base: String,
88 head: String,
90 #[serde(default)]
92 merge_base: bool,
93 },
94 BranchCompare {
96 base_branch: String,
98 head_branch: String,
100 #[serde(default = "default_true")]
102 merge_base: bool,
103 },
104 Repository,
106}
107
108pub const REVIEW_WORKSPACE_PRESENTATION_SCHEMA_VERSION: u16 = 1;
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ReviewWorkspacePresentationState {
113 pub schema_version: u16,
115 #[serde(default)]
117 pub selected_path: Option<String>,
118 #[serde(default)]
120 pub selected_thread_key: Option<String>,
121 #[serde(default)]
123 pub selected_line: Option<u32>,
124 #[serde(default)]
126 pub sidebar_mode: String,
127 #[serde(default = "default_true")]
129 pub sidebar_visible: bool,
130 #[serde(default)]
132 pub thread_filter: String,
133 #[serde(default = "default_true")]
135 pub show_resolved_threads: bool,
136 #[serde(default)]
138 pub hide_viewed_files: bool,
139 #[serde(default)]
141 pub collapsed_thread_keys: BTreeSet<String>,
142 #[serde(default)]
144 pub expanded_agent_answer_keys: BTreeSet<String>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct ReviewWorkspace {
150 pub id: String,
152 pub title: String,
154 pub repo_root: PathBuf,
156 pub sources: Vec<ReviewSource>,
158 #[serde(default)]
160 pub created_at_ms: Option<u64>,
161 #[serde(default)]
163 pub updated_at_ms: Option<u64>,
164 #[serde(default)]
166 pub viewed_files: BTreeSet<String>,
167 #[serde(default)]
169 pub archived_at_ms: Option<u64>,
170 #[serde(default)]
172 pub presentation_state: Option<ReviewWorkspacePresentationState>,
173}
174
175impl ReviewWorkspace {
176 #[must_use]
178 pub fn from_target(repo_root: PathBuf, target: ReviewTarget) -> Self {
179 let source_kind = ReviewSourceKind::from(target);
180 let title = source_kind.label();
181 Self {
182 id: "transient-review-workspace".to_string(),
183 title: title.clone(),
184 repo_root,
185 sources: vec![ReviewSource {
186 id: "source-1".to_string(),
187 kind: source_kind,
188 label: title,
189 included: true,
190 }],
191 created_at_ms: None,
192 updated_at_ms: None,
193 viewed_files: BTreeSet::new(),
194 archived_at_ms: None,
195 presentation_state: None,
196 }
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct ReviewSource {
203 pub id: String,
205 pub kind: ReviewSourceKind,
207 pub label: String,
209 pub included: bool,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(tag = "kind", rename_all = "snake_case")]
216pub enum ReviewSourceKind {
217 WorkingTreeUnstaged,
219 IndexStaged,
221 WorkingTreeAndIndex,
223 LastCommit,
225 Commit {
227 rev: String,
229 },
230 CommitRange {
232 base: String,
234 head: String,
236 #[serde(default)]
238 merge_base: bool,
239 },
240 BranchCompare {
242 base_branch: String,
244 head_branch: String,
246 #[serde(default = "default_true")]
248 merge_base: bool,
249 },
250 File {
252 path: String,
254 },
255 FileRange {
257 path: String,
259 start: u32,
261 end: u32,
263 },
264 Repository,
266}
267
268impl ReviewSourceKind {
269 #[must_use]
271 pub fn label(&self) -> String {
272 match self {
273 Self::WorkingTreeUnstaged => "Unstaged changes".to_string(),
274 Self::IndexStaged => "Staged changes".to_string(),
275 Self::WorkingTreeAndIndex => "Working tree and index".to_string(),
276 Self::LastCommit => "Last commit".to_string(),
277 Self::Commit { rev } => format!("Commit {rev}"),
278 Self::CommitRange {
279 base,
280 head,
281 merge_base,
282 } => {
283 let separator = if *merge_base { "..." } else { ".." };
284 format!("Range {base}{separator}{head}")
285 }
286 Self::BranchCompare {
287 base_branch,
288 head_branch,
289 merge_base,
290 } => {
291 let separator = if *merge_base { "..." } else { ".." };
292 format!("Compare {base_branch}{separator}{head_branch}")
293 }
294 Self::File { path } => format!("File {path}"),
295 Self::FileRange { path, start, end } => format!("File {path}:{start}-{end}"),
296 Self::Repository => "Repository browser".to_string(),
297 }
298 }
299}
300
301impl From<ReviewTarget> for ReviewSourceKind {
302 fn from(target: ReviewTarget) -> Self {
303 match target {
304 ReviewTarget::WorkingTreeUnstaged => Self::WorkingTreeUnstaged,
305 ReviewTarget::IndexStaged => Self::IndexStaged,
306 ReviewTarget::WorkingTreeAndIndex => Self::WorkingTreeAndIndex,
307 ReviewTarget::LastCommit => Self::LastCommit,
308 ReviewTarget::CommitRange {
309 base,
310 head,
311 merge_base,
312 } => Self::CommitRange {
313 base,
314 head,
315 merge_base,
316 },
317 ReviewTarget::BranchCompare {
318 base_branch,
319 head_branch,
320 merge_base,
321 } => Self::BranchCompare {
322 base_branch,
323 head_branch,
324 merge_base,
325 },
326 ReviewTarget::Repository => Self::Repository,
327 }
328 }
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(rename_all = "snake_case")]
334pub enum ReviewSurfaceKind {
335 Diff,
337 File,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct ReviewSurface {
344 pub id: String,
346 pub source_id: String,
348 pub path: String,
350 pub kind: ReviewSurfaceKind,
352 #[serde(default)]
354 pub file: Option<ReviewFile>,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360pub enum ReviewSourceDiagnosticSeverity {
361 Info,
363 Warning,
365 Error,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct ReviewSourceDiagnostic {
372 pub source_id: String,
374 pub severity: ReviewSourceDiagnosticSeverity,
376 pub code: String,
378 pub message: String,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct ReviewRepositoryCommit {
385 pub rev: String,
387 pub short_rev: String,
389 pub subject: String,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct ReviewWorkspaceMaterialization {
396 pub workspace: ReviewWorkspace,
398 pub surfaces: Vec<ReviewSurface>,
400 #[serde(default)]
402 pub diagnostics: Vec<ReviewSourceDiagnostic>,
403 #[serde(default)]
405 pub repository_files: Vec<String>,
406 #[serde(default)]
408 pub repository_branches: Vec<String>,
409 #[serde(default)]
411 pub repository_commits: Vec<ReviewRepositoryCommit>,
412 pub additions: u32,
414 pub deletions: u32,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct MaterializeReviewWorkspaceRequest {
421 pub repo_path: PathBuf,
423 pub workspace: ReviewWorkspace,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct MaterializeReviewWorkspaceResponse {
430 pub materialization: ReviewWorkspaceMaterialization,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct ListPublishRecordsRequest {
437 pub repo_path: PathBuf,
439 pub workspace_id: String,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445pub struct ListPublishRecordsResponse {
446 pub records: Vec<ReviewPublishRecord>,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452pub struct ListReviewWorkspacesRequest {
453 pub repo_path: PathBuf,
455 #[serde(default)]
457 pub include_archived: bool,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462pub struct ReviewPublishRecord {
463 pub id: String,
465 #[serde(default)]
467 pub workspace_id: Option<String>,
468 pub review_id: String,
470 pub publisher_id: String,
472 pub submitted: bool,
474 #[serde(default)]
476 pub published_thread_anchors: Vec<DraftAnchor>,
477 #[serde(default)]
479 pub output: Option<String>,
480 pub message: String,
482 pub created_at_ms: u64,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct ReviewWorkspaceListItem {
489 pub workspace: ReviewWorkspace,
491 #[serde(default)]
493 pub thread_count: usize,
494 #[serde(default)]
496 pub draft_count: usize,
497 #[serde(default)]
499 pub last_publish: Option<ReviewPublishRecord>,
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
504pub struct ListReviewWorkspacesResponse {
505 pub workspaces: Vec<ReviewWorkspace>,
507 #[serde(default)]
509 pub items: Vec<ReviewWorkspaceListItem>,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514pub struct CreateReviewWorkspaceRequest {
515 pub repo_path: PathBuf,
517 #[serde(default)]
519 pub title: Option<String>,
520 #[serde(default)]
522 pub sources: Vec<ReviewSource>,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527pub struct CreateReviewWorkspaceResponse {
528 pub workspace: ReviewWorkspace,
530}
531
532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
534pub struct GetReviewWorkspaceRequest {
535 pub repo_path: PathBuf,
537 pub workspace_id: String,
539}
540
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543pub struct GetReviewWorkspaceResponse {
544 pub workspace: Option<ReviewWorkspace>,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550pub struct UpdateReviewWorkspaceRequest {
551 pub repo_path: PathBuf,
553 pub workspace: ReviewWorkspace,
555}
556
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559pub struct UpdateReviewWorkspaceResponse {
560 pub workspace: ReviewWorkspace,
562}
563
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566pub struct ArchiveReviewWorkspaceRequest {
567 pub repo_path: PathBuf,
569 pub workspace_id: String,
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct ArchiveReviewWorkspaceResponse {
576 pub archived: bool,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(tag = "kind", rename_all = "snake_case")]
583pub enum ReviewScope {
584 Target {
586 target: ReviewTarget,
588 },
589 Workspace {
591 workspace_id: String,
593 target: ReviewTarget,
595 },
596}
597
598impl ReviewScope {
599 #[must_use]
601 pub const fn target(&self) -> &ReviewTarget {
602 match self {
603 Self::Target { target } | Self::Workspace { target, .. } => target,
604 }
605 }
606}
607
608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610pub struct ListDraftsRequest {
611 pub repo_path: PathBuf,
613 pub target: ReviewTarget,
615 #[serde(default)]
617 pub scope: Option<ReviewScope>,
618}
619
620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
622pub struct SaveDraftRequest {
623 pub repo_path: PathBuf,
625 pub target: ReviewTarget,
627 #[serde(default)]
629 pub scope: Option<ReviewScope>,
630 pub anchor: DraftAnchor,
632 pub body: String,
634}
635
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
638pub struct DeleteDraftRequest {
639 pub repo_path: PathBuf,
641 pub target: ReviewTarget,
643 #[serde(default)]
645 pub scope: Option<ReviewScope>,
646 pub comment_id: String,
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652pub struct UpdateDraftRequest {
653 pub repo_path: PathBuf,
655 pub target: ReviewTarget,
657 #[serde(default)]
659 pub scope: Option<ReviewScope>,
660 pub comment_id: String,
662 pub body: String,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668pub struct LinkThreadSessionRequest {
669 pub repo_path: PathBuf,
671 pub target: ReviewTarget,
673 #[serde(default)]
675 pub scope: Option<ReviewScope>,
676 pub anchor: DraftAnchor,
678 pub session_id: String,
680}
681
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684pub struct GetReviewThreadRequest {
685 pub repo_path: PathBuf,
687 pub target: ReviewTarget,
689 #[serde(default)]
691 pub scope: Option<ReviewScope>,
692 pub thread_id: Option<String>,
694 pub anchor: Option<DraftAnchor>,
696}
697
698#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
700pub struct ListReviewAiExchangesRequest {
701 pub repo_path: PathBuf,
703 pub target: ReviewTarget,
705 #[serde(default)]
707 pub scope: Option<ReviewScope>,
708}
709
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
712pub struct DeleteReviewAiExchangeRequest {
713 pub repo_path: PathBuf,
715 pub target: ReviewTarget,
717 #[serde(default)]
719 pub scope: Option<ReviewScope>,
720 pub exchange_id: String,
722}
723
724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726pub struct DeleteReviewAiExchangeResponse {
727 pub deleted: bool,
729}
730
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
733pub struct SaveReviewAiExchangeRequest {
734 pub repo_path: PathBuf,
736 pub target: ReviewTarget,
738 #[serde(default)]
740 pub scope: Option<ReviewScope>,
741 pub exchange: ReviewAiExchange,
743}
744
745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
747pub struct ListReviewAiExchangesResponse {
748 pub exchanges: Vec<ReviewAiExchange>,
750}
751
752#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754pub struct SaveReviewAiExchangeResponse {
755 pub exchange: ReviewAiExchange,
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
761#[serde(rename_all = "snake_case")]
762pub enum ReviewAiExchangeStatus {
763 Pending,
765 Linked,
767 Failed,
769}
770
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773pub struct ReviewAiExchange {
774 pub schema_version: u16,
776 pub exchange_id: String,
778 pub anchor: DraftAnchor,
780 pub question: String,
782 #[serde(default)]
784 pub session_id: Option<String>,
785 pub status: ReviewAiExchangeStatus,
787 #[serde(default)]
789 pub error: Option<String>,
790 pub created_at_ms: u64,
792 pub updated_at_ms: u64,
794}
795
796#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
798pub struct ListReviewSuggestionsRequest {
799 pub repo_path: PathBuf,
801 pub target: ReviewTarget,
803 #[serde(default)]
805 pub scope: Option<ReviewScope>,
806}
807
808#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
810pub struct SaveReviewSuggestionRequest {
811 pub repo_path: PathBuf,
813 pub target: ReviewTarget,
815 #[serde(default)]
817 pub scope: Option<ReviewScope>,
818 pub suggestion: ReviewSuggestion,
820}
821
822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
824pub struct ListReviewSuggestionsResponse {
825 pub suggestions: Vec<ReviewSuggestion>,
827}
828
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
831pub struct SaveReviewSuggestionResponse {
832 pub suggestion: ReviewSuggestion,
834}
835
836#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
838#[serde(rename_all = "snake_case")]
839pub enum ReviewSuggestionStatus {
840 Suggested,
842 Refining,
844 Accepted,
846 Rejected,
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
852pub struct ReviewSuggestion {
853 pub suggestion_id: String,
855 pub anchor: DraftAnchor,
857 pub body: String,
859 #[serde(default)]
861 pub session_id: Option<String>,
862 pub status: ReviewSuggestionStatus,
864 #[serde(default)]
866 pub rationale: Option<String>,
867 pub created_at_ms: u64,
869 pub updated_at_ms: u64,
871}
872
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
875pub struct GetReviewDiffRequest {
876 pub repo_path: PathBuf,
878 pub target: ReviewTarget,
880 pub file_path: Option<String>,
882}
883
884#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
886pub struct ListDraftsResponse {
887 pub drafts: Vec<DraftComment>,
889}
890
891#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
893pub struct SaveDraftResponse {
894 pub draft: DraftComment,
896}
897
898#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
900pub struct DeleteDraftResponse {
901 pub deleted: bool,
903}
904
905#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907pub struct UpdateDraftResponse {
908 pub updated: bool,
910 pub updated_at_ms: Option<u64>,
912}
913
914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
916pub struct LinkThreadSessionResponse {
917 pub thread_id: String,
919}
920
921#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
923pub struct ResolveThreadRequest {
924 pub repo_path: PathBuf,
926 pub target: ReviewTarget,
928 #[serde(default)]
930 pub scope: Option<ReviewScope>,
931 pub anchor: DraftAnchor,
933 pub resolved: bool,
935}
936
937#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
939pub struct ResolveThreadResponse {
940 pub thread_id: String,
942 pub resolved_at_ms: Option<u64>,
944}
945
946#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
948pub struct ReviewContextRequest {
949 pub repo_path: PathBuf,
951 pub target: ReviewTarget,
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
957#[serde(rename_all = "snake_case")]
958pub enum ReviewAnchorKind {
959 Review,
961 File,
963 #[default]
965 Range,
966}
967
968#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970pub struct DraftAnchor {
971 #[serde(default)]
973 pub kind: ReviewAnchorKind,
974 pub file_path: String,
976 pub diff_row: u64,
978 #[serde(default)]
980 pub start_diff_row: Option<u64>,
981 #[serde(default)]
983 pub end_diff_row: Option<u64>,
984 #[serde(default)]
986 pub old_start: Option<u32>,
987 #[serde(default)]
989 pub old_end: Option<u32>,
990 #[serde(default)]
992 pub new_start: Option<u32>,
993 #[serde(default)]
995 pub new_end: Option<u32>,
996 #[serde(default)]
998 pub old_line: Option<u32>,
999 #[serde(default)]
1001 pub new_line: Option<u32>,
1002 pub line_kind: ReviewLineKind,
1004 #[serde(default)]
1006 pub is_file_anchor: bool,
1007 #[serde(default)]
1009 pub surface_id: Option<String>,
1010 #[serde(default)]
1012 pub source_id: Option<String>,
1013}
1014
1015#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1017#[serde(rename_all = "snake_case")]
1018pub enum ReviewThreadKind {
1019 #[default]
1021 Note,
1022 Question,
1024 Todo,
1026 Finding,
1028 Summary,
1030}
1031
1032#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1034#[serde(rename_all = "snake_case")]
1035pub enum ReviewThreadSeverity {
1036 #[default]
1038 Info,
1039 Nit,
1041 Warning,
1043 Blocker,
1045}
1046
1047#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1049pub struct DraftComment {
1050 pub comment_id: String,
1052 pub thread_id: String,
1054 pub anchor: DraftAnchor,
1056 pub body: String,
1058 pub created_at_ms: u64,
1060 pub updated_at_ms: u64,
1062 #[serde(default)]
1064 pub session_id: Option<String>,
1065 #[serde(default)]
1067 pub resolved_at_ms: Option<u64>,
1068 #[serde(default)]
1070 pub thread_kind: ReviewThreadKind,
1071 #[serde(default)]
1073 pub severity: ReviewThreadSeverity,
1074}
1075
1076#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1078#[serde(rename_all = "snake_case")]
1079pub enum ReviewFileStatus {
1080 Modified,
1082 Added,
1084 Deleted,
1086 Renamed,
1088 Copied,
1090 Unknown,
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1096pub struct ReviewFileSummary {
1097 pub path: String,
1099 pub status: ReviewFileStatus,
1101 pub additions: u32,
1103 pub deletions: u32,
1105 pub hunks: usize,
1107 pub is_binary: bool,
1109}
1110
1111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1113#[serde(rename_all = "snake_case")]
1114pub enum ReviewLineKind {
1115 Context,
1117 Added,
1119 Removed,
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1125pub struct ReviewHunk {
1126 pub old_start: u32,
1128 pub old_count: u32,
1130 pub new_start: u32,
1132 pub new_count: u32,
1134 pub heading: Option<String>,
1136 pub lines: Vec<ReviewLine>,
1138}
1139
1140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1142pub struct ReviewLine {
1143 pub kind: ReviewLineKind,
1145 pub old_line: Option<u32>,
1147 pub new_line: Option<u32>,
1149 pub content: String,
1151}
1152
1153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1155pub struct RepositoryFileRequest {
1156 pub repo_path: PathBuf,
1158 pub file_path: String,
1160}
1161
1162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1164pub struct RepositoryFileResponse {
1165 pub file_path: String,
1167 pub content: Option<String>,
1169 pub size_bytes: u64,
1171 #[serde(default)]
1173 pub mtime_ms: Option<u64>,
1174 pub is_binary: bool,
1176 #[serde(default)]
1178 pub unavailable_reason: Option<String>,
1179}
1180
1181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1183pub struct ReviewFile {
1184 pub old_path: Option<String>,
1186 pub new_path: Option<String>,
1188 pub status: ReviewFileStatus,
1190 pub additions: u32,
1192 pub deletions: u32,
1194 pub hunks: Vec<ReviewHunk>,
1196 pub is_binary: bool,
1198}
1199
1200impl ReviewFile {
1201 #[must_use]
1203 pub fn display_path(&self) -> &str {
1204 self.new_path
1205 .as_deref()
1206 .or(self.old_path.as_deref())
1207 .unwrap_or("<unknown>")
1208 }
1209}
1210
1211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1213pub struct ReviewBundleLine {
1214 pub file_path: String,
1216 pub kind: ReviewLineKind,
1218 pub old_line: Option<u32>,
1220 pub new_line: Option<u32>,
1222 pub diff_row: u64,
1224 pub content: String,
1226 #[serde(default)]
1228 pub surface_id: Option<String>,
1229 #[serde(default)]
1231 pub source_id: Option<String>,
1232}
1233
1234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1236pub struct ReviewBundle {
1237 pub review_id: String,
1239 pub title: String,
1241 pub repo_root: PathBuf,
1243 pub target: ReviewTarget,
1245 pub files: Vec<ReviewFileSummary>,
1247 #[serde(default)]
1249 pub surfaces: Vec<ReviewSurface>,
1250 pub threads: Vec<ReviewBundleThread>,
1252 pub generated_at_ms: u64,
1254}
1255
1256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1258pub struct ReviewBundleThread {
1259 pub thread_id: String,
1261 pub anchor: DraftAnchor,
1263 pub comments: Vec<DraftComment>,
1265 pub session_id: Option<String>,
1267 #[serde(default)]
1269 pub resolved_at_ms: Option<u64>,
1270 #[serde(default)]
1272 pub selected_lines: Vec<ReviewBundleLine>,
1273 pub selected_diff_lines: Vec<String>,
1275 pub hunk_context: Vec<String>,
1277}
1278
1279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1281#[allow(clippy::struct_excessive_bools)]
1282pub struct ReviewPublisherCapabilities {
1283 pub preview: bool,
1285 pub submit: bool,
1287 pub update_existing: bool,
1289 pub supports_threads: bool,
1291 pub supports_ranges: bool,
1293 pub supports_inline_comments: bool,
1295 pub supports_summary_comment: bool,
1297}
1298
1299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1301pub struct ReviewPublisherRoute {
1302 pub plugin_id: String,
1304 pub interface_id: String,
1306}
1307
1308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1310pub struct ReviewPublisherManifest {
1311 pub id: String,
1313 pub label: String,
1315 pub description: String,
1317 pub capabilities: ReviewPublisherCapabilities,
1319 pub options_schema: serde_json::Value,
1321 #[serde(default)]
1323 pub route: Option<ReviewPublisherRoute>,
1324}
1325
1326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1328pub struct ListReviewPublishersResponse {
1329 pub publishers: Vec<ReviewPublisherManifest>,
1331}
1332
1333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1335pub struct PublishReviewRequest {
1336 pub repo_path: PathBuf,
1338 pub target: ReviewTarget,
1340 #[serde(default)]
1342 pub workspace: Option<ReviewWorkspace>,
1343 #[serde(default)]
1345 pub excluded_thread_anchors: Vec<DraftAnchor>,
1346 pub publisher_id: String,
1348 #[serde(default)]
1350 pub options: serde_json::Value,
1351}
1352
1353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1355pub struct SavePublishRecordRequest {
1356 pub workspace: ReviewWorkspace,
1358 pub review_id: String,
1360 pub publisher_id: String,
1362 pub submitted: bool,
1364 #[serde(default)]
1366 pub published_thread_anchors: Vec<DraftAnchor>,
1367 #[serde(default)]
1369 pub output: Option<String>,
1370 pub message: String,
1372}
1373
1374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1376pub struct SavePublishRecordResponse {
1377 pub record: ReviewPublishRecord,
1379}
1380
1381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1383pub struct ExternalPublishReviewRequest {
1384 pub bundle: ReviewBundle,
1386 #[serde(default)]
1388 pub options: serde_json::Value,
1389}
1390
1391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1393pub struct PublishReviewPreviewResponse {
1394 pub publisher_id: String,
1396 pub preview: String,
1398}
1399
1400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1402#[serde(rename_all = "snake_case")]
1403pub enum ExternalReviewThreadState {
1404 Open,
1406 Resolved,
1408 Outdated,
1410}
1411
1412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1414#[serde(rename_all = "snake_case")]
1415pub enum ExternalAnchorMappingStatus {
1416 Exact,
1418 Approximate,
1420 Unmappable,
1422}
1423
1424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1426pub struct ExternalReviewIdentity {
1427 pub provider_id: String,
1429 pub external_id: String,
1431 #[serde(default)]
1433 pub url: Option<String>,
1434}
1435
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1438pub struct ExternalReviewAnchorMapping {
1439 pub status: ExternalAnchorMappingStatus,
1441 #[serde(default)]
1443 pub anchor: Option<DraftAnchor>,
1444 #[serde(default)]
1446 pub message: Option<String>,
1447}
1448
1449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1451pub struct ExternalReviewComment {
1452 pub identity: ExternalReviewIdentity,
1454 #[serde(default)]
1456 pub author: Option<ExternalReviewIdentity>,
1457 #[serde(default)]
1459 pub author_label: Option<String>,
1460 pub body: String,
1462 #[serde(default)]
1464 pub created_at_ms: Option<u64>,
1465 #[serde(default)]
1467 pub updated_at_ms: Option<u64>,
1468}
1469
1470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1472pub struct ExternalReviewThread {
1473 pub identity: ExternalReviewIdentity,
1475 pub state: ExternalReviewThreadState,
1477 pub mapping: ExternalReviewAnchorMapping,
1479 pub comments: Vec<ExternalReviewComment>,
1481}
1482
1483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1485pub struct ReviewImporterManifest {
1486 pub id: String,
1488 pub label: String,
1490 pub description: String,
1492 pub options_schema: serde_json::Value,
1494}
1495
1496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1498pub struct ExternalReviewImportRequest {
1499 pub repo_root: PathBuf,
1501 #[serde(default)]
1503 pub files: Vec<ReviewFile>,
1504 #[serde(default)]
1506 pub options: serde_json::Value,
1507}
1508
1509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1511pub struct ExternalReviewImportResponse {
1512 pub importer_id: String,
1514 pub threads: Vec<ExternalReviewThread>,
1516 #[serde(default)]
1518 pub warnings: Vec<String>,
1519}
1520
1521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1523pub struct ExternalReviewImportRecord {
1524 pub workspace_id: String,
1526 pub importer_id: String,
1528 pub threads: Vec<ExternalReviewThread>,
1530 #[serde(default)]
1532 pub warnings: Vec<String>,
1533 pub updated_at_ms: u64,
1535}
1536
1537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1539pub struct SaveExternalReviewImportRequest {
1540 pub repo_path: PathBuf,
1542 pub workspace_id: String,
1544 pub import: ExternalReviewImportResponse,
1546}
1547
1548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1550pub struct SaveExternalReviewImportResponse {
1551 pub record: ExternalReviewImportRecord,
1553}
1554
1555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1557pub struct ListExternalReviewImportsRequest {
1558 pub repo_path: PathBuf,
1560 pub workspace_id: String,
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1566pub struct ListExternalReviewImportsResponse {
1567 pub records: Vec<ExternalReviewImportRecord>,
1569}
1570
1571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1573pub struct PublishReviewResponse {
1574 pub publisher_id: String,
1576 pub submitted: bool,
1578 pub output: Option<String>,
1580 pub message: String,
1582}
1583
1584const fn default_true() -> bool {
1585 true
1586}
1587
1588pub const ADVERSARIAL_REVIEW_CONTRACT_VERSION: u32 = 1;
1590pub const MAX_ADVERSARIAL_REVIEWERS: usize = 32;
1592pub const MAX_ADVERSARIAL_REVIEW_FINDINGS: usize = 512;
1594pub const MAX_ADVERSARIAL_REVIEW_TEXT_BYTES: usize = 16_384;
1596pub const MAX_ADVERSARIAL_REVIEW_FACTS: usize = 64;
1598
1599#[derive(Debug, Clone, PartialEq, Eq)]
1601pub struct AdversarialReviewValidationError {
1602 pub path: String,
1604 pub message: String,
1606}
1607
1608impl fmt::Display for AdversarialReviewValidationError {
1609 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1610 write!(
1611 formatter,
1612 "invalid adversarial review at '{}': {}",
1613 self.path, self.message
1614 )
1615 }
1616}
1617
1618impl std::error::Error for AdversarialReviewValidationError {}
1619
1620fn adversarial_review_error(
1621 path: impl Into<String>,
1622 message: impl Into<String>,
1623) -> AdversarialReviewValidationError {
1624 AdversarialReviewValidationError {
1625 path: path.into(),
1626 message: message.into(),
1627 }
1628}
1629
1630fn validate_review_id(path: &str, value: &str) -> Result<(), AdversarialReviewValidationError> {
1631 if value.is_empty()
1632 || value.len() > 256
1633 || !value.bytes().all(|byte| {
1634 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'_' | b'-' | b':')
1635 })
1636 {
1637 return Err(adversarial_review_error(
1638 path,
1639 "identity must contain 1..=256 safe ASCII bytes",
1640 ));
1641 }
1642 Ok(())
1643}
1644
1645fn validate_sha256(path: &str, value: &str) -> Result<(), AdversarialReviewValidationError> {
1646 if value.len() != 64
1647 || !value
1648 .bytes()
1649 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1650 {
1651 return Err(adversarial_review_error(
1652 path,
1653 "digest must be 64 lowercase hexadecimal bytes",
1654 ));
1655 }
1656 Ok(())
1657}
1658
1659fn validate_review_path(path: &str, value: &str) -> Result<(), AdversarialReviewValidationError> {
1660 let candidate = Path::new(value);
1661 if value.is_empty()
1662 || value.len() > 4_096
1663 || candidate.is_absolute()
1664 || candidate
1665 .components()
1666 .any(|component| !matches!(component, Component::Normal(_)))
1667 {
1668 return Err(adversarial_review_error(
1669 path,
1670 "path must be a bounded confined repository-relative path",
1671 ));
1672 }
1673 Ok(())
1674}
1675
1676fn validate_review_text(path: &str, value: &str) -> Result<(), AdversarialReviewValidationError> {
1677 if value.trim().is_empty() || value.len() > MAX_ADVERSARIAL_REVIEW_TEXT_BYTES {
1678 return Err(adversarial_review_error(
1679 path,
1680 format!("text must contain 1..={MAX_ADVERSARIAL_REVIEW_TEXT_BYTES} UTF-8 bytes"),
1681 ));
1682 }
1683 Ok(())
1684}
1685
1686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1688#[serde(deny_unknown_fields)]
1689pub struct ReviewRepositorySnapshot {
1690 pub version: u32,
1692 pub repository_identity_sha256: String,
1694 pub head_object_id: String,
1696 pub aggregate_sha256: String,
1698 pub project_instruction_fingerprint_sha256: String,
1700}
1701
1702impl ReviewRepositorySnapshot {
1703 pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1709 if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
1710 return Err(adversarial_review_error(
1711 "snapshot.version",
1712 "unsupported version",
1713 ));
1714 }
1715 validate_sha256(
1716 "snapshot.repository_identity_sha256",
1717 &self.repository_identity_sha256,
1718 )?;
1719 validate_review_id("snapshot.head_object_id", &self.head_object_id)?;
1720 validate_sha256("snapshot.aggregate_sha256", &self.aggregate_sha256)?;
1721 validate_sha256(
1722 "snapshot.project_instruction_fingerprint_sha256",
1723 &self.project_instruction_fingerprint_sha256,
1724 )
1725 }
1726}
1727
1728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1730#[serde(deny_unknown_fields)]
1731pub struct ReviewAssignment {
1732 pub version: u32,
1733 pub assignment_id: String,
1734 pub reviewer_id: String,
1735 pub snapshot: ReviewRepositorySnapshot,
1736 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1738 pub include_paths: Vec<String>,
1739 pub policy_id: String,
1741}
1742
1743#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1745#[serde(deny_unknown_fields)]
1746pub struct ReviewPanelAssignmentRequest {
1747 pub version: u32,
1748 pub round_id: String,
1750 pub snapshot: ReviewRepositorySnapshot,
1751 pub reviewer_ids: Vec<String>,
1753 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1754 pub include_paths: Vec<String>,
1755 pub policy_id: String,
1756}
1757
1758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1760#[serde(deny_unknown_fields)]
1761pub struct ReviewPanelAssignments {
1762 pub version: u32,
1763 pub round_id: String,
1764 pub snapshot_sha256: String,
1765 pub assignments: Vec<ReviewAssignment>,
1766}
1767
1768impl ReviewPanelAssignmentRequest {
1769 pub fn materialize(&self) -> Result<ReviewPanelAssignments, AdversarialReviewValidationError> {
1776 if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
1777 return Err(adversarial_review_error(
1778 "panel_assignment.version",
1779 "unsupported version",
1780 ));
1781 }
1782 validate_review_id("panel_assignment.round_id", &self.round_id)?;
1783 validate_review_id("panel_assignment.policy_id", &self.policy_id)?;
1784 self.snapshot.validate()?;
1785 if self.reviewer_ids.len() < 2 || self.reviewer_ids.len() > MAX_ADVERSARIAL_REVIEWERS {
1786 return Err(adversarial_review_error(
1787 "panel_assignment.reviewer_ids",
1788 format!("panel requires 2..={MAX_ADVERSARIAL_REVIEWERS} reviewers"),
1789 ));
1790 }
1791 if self.include_paths.len() > MAX_ADVERSARIAL_REVIEW_FINDINGS {
1792 return Err(adversarial_review_error(
1793 "panel_assignment.include_paths",
1794 "selected paths exceed the review bound",
1795 ));
1796 }
1797 let mut previous = None;
1798 for reviewer_id in &self.reviewer_ids {
1799 validate_review_id("panel_assignment.reviewer_ids", reviewer_id)?;
1800 if previous.is_some_and(|identity: &str| identity >= reviewer_id.as_str()) {
1801 return Err(adversarial_review_error(
1802 "panel_assignment.reviewer_ids",
1803 "reviewer identities must be strictly ordered and unique",
1804 ));
1805 }
1806 previous = Some(reviewer_id.as_str());
1807 }
1808 let mut paths = BTreeSet::new();
1809 for path in &self.include_paths {
1810 validate_review_path("panel_assignment.include_paths", path)?;
1811 if !paths.insert(path) {
1812 return Err(adversarial_review_error(
1813 "panel_assignment.include_paths",
1814 "selected paths must be unique",
1815 ));
1816 }
1817 }
1818 let assignments = self
1819 .reviewer_ids
1820 .iter()
1821 .map(|reviewer_id| ReviewAssignment {
1822 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
1823 assignment_id: format!("{}:{reviewer_id}", self.round_id),
1824 reviewer_id: reviewer_id.clone(),
1825 snapshot: self.snapshot.clone(),
1826 include_paths: self.include_paths.clone(),
1827 policy_id: self.policy_id.clone(),
1828 })
1829 .collect::<Vec<_>>();
1830 for assignment in &assignments {
1831 assignment.validate()?;
1832 }
1833 Ok(ReviewPanelAssignments {
1834 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
1835 round_id: self.round_id.clone(),
1836 snapshot_sha256: self.snapshot.aggregate_sha256.clone(),
1837 assignments,
1838 })
1839 }
1840}
1841
1842impl ReviewAssignment {
1843 pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1850 if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
1851 return Err(adversarial_review_error(
1852 "assignment.version",
1853 "unsupported version",
1854 ));
1855 }
1856 validate_review_id("assignment.assignment_id", &self.assignment_id)?;
1857 validate_review_id("assignment.reviewer_id", &self.reviewer_id)?;
1858 validate_review_id("assignment.policy_id", &self.policy_id)?;
1859 self.snapshot.validate()?;
1860 if self.include_paths.len() > MAX_ADVERSARIAL_REVIEW_FINDINGS {
1861 return Err(adversarial_review_error(
1862 "assignment.include_paths",
1863 "selected paths exceed the review bound",
1864 ));
1865 }
1866 let mut paths = BTreeSet::new();
1867 for path in &self.include_paths {
1868 validate_review_path("assignment.include_paths", path)?;
1869 if !paths.insert(path) {
1870 return Err(adversarial_review_error(
1871 "assignment.include_paths",
1872 "selected paths must be unique",
1873 ));
1874 }
1875 }
1876 Ok(())
1877 }
1878}
1879
1880#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1882#[serde(rename_all = "snake_case")]
1883pub enum ReviewFindingSeverity {
1884 Advisory,
1885 Minor,
1886 Major,
1887 Critical,
1888}
1889
1890#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1892#[serde(rename_all = "snake_case")]
1893pub enum ReviewFindingDisposition {
1894 Required,
1895 Advisory,
1896}
1897
1898#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1900#[serde(deny_unknown_fields)]
1901pub struct ReviewSourceRange {
1902 pub path: String,
1903 pub start_line: u32,
1904 #[serde(default, skip_serializing_if = "Option::is_none")]
1905 pub start_column: Option<u32>,
1906 pub end_line: u32,
1907 #[serde(default, skip_serializing_if = "Option::is_none")]
1908 pub end_column: Option<u32>,
1909}
1910
1911impl ReviewSourceRange {
1912 fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1913 validate_review_path("finding.location.path", &self.path)?;
1914 if self.start_line == 0
1915 || self.end_line < self.start_line
1916 || self.start_column == Some(0)
1917 || self.end_column == Some(0)
1918 || (self.start_line == self.end_line
1919 && self
1920 .start_column
1921 .zip(self.end_column)
1922 .is_some_and(|(start, end)| end < start))
1923 {
1924 return Err(adversarial_review_error(
1925 "finding.location",
1926 "source range is malformed or reversed",
1927 ));
1928 }
1929 Ok(())
1930 }
1931}
1932
1933#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1935#[serde(deny_unknown_fields)]
1936pub struct ReviewFinding {
1937 pub version: u32,
1938 pub finding_id: String,
1939 pub snapshot_sha256: String,
1940 pub severity: ReviewFindingSeverity,
1941 pub disposition: ReviewFindingDisposition,
1942 #[serde(default, skip_serializing_if = "Option::is_none")]
1943 pub location: Option<ReviewSourceRange>,
1944 pub title: String,
1945 pub evidence: String,
1946 pub remediation: String,
1947}
1948
1949impl ReviewFinding {
1950 pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1956 if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
1957 return Err(adversarial_review_error(
1958 "finding.version",
1959 "unsupported version",
1960 ));
1961 }
1962 validate_review_id("finding.finding_id", &self.finding_id)?;
1963 validate_sha256("finding.snapshot_sha256", &self.snapshot_sha256)?;
1964 if let Some(location) = &self.location {
1965 location.validate()?;
1966 }
1967 validate_review_text("finding.title", &self.title)?;
1968 validate_review_text("finding.evidence", &self.evidence)?;
1969 validate_review_text("finding.remediation", &self.remediation)
1970 }
1971}
1972
1973#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1975#[serde(deny_unknown_fields)]
1976pub struct ReviewExecutionFacts {
1977 pub provider_id: String,
1978 pub model_id: String,
1979 pub agent_profile: String,
1980 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1982 pub tools: BTreeSet<String>,
1983}
1984
1985impl ReviewExecutionFacts {
1986 fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
1987 validate_review_id("report.execution.provider_id", &self.provider_id)?;
1988 validate_review_id("report.execution.model_id", &self.model_id)?;
1989 validate_review_id("report.execution.agent_profile", &self.agent_profile)?;
1990 if self.tools.len() > MAX_ADVERSARIAL_REVIEW_FACTS {
1991 return Err(adversarial_review_error(
1992 "report.execution.tools",
1993 "tool facts exceed the review bound",
1994 ));
1995 }
1996 for tool in &self.tools {
1997 validate_review_id("report.execution.tools", tool)?;
1998 }
1999 Ok(())
2000 }
2001}
2002
2003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2005#[serde(deny_unknown_fields)]
2006pub struct ReviewReport {
2007 pub version: u32,
2008 pub assignment_id: String,
2009 pub reviewer_id: String,
2010 pub snapshot_sha256: String,
2011 pub execution: ReviewExecutionFacts,
2012 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2013 pub findings: Vec<ReviewFinding>,
2014}
2015
2016impl ReviewReport {
2017 pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
2024 if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
2025 return Err(adversarial_review_error(
2026 "report.version",
2027 "unsupported version",
2028 ));
2029 }
2030 validate_review_id("report.assignment_id", &self.assignment_id)?;
2031 validate_review_id("report.reviewer_id", &self.reviewer_id)?;
2032 validate_sha256("report.snapshot_sha256", &self.snapshot_sha256)?;
2033 self.execution.validate()?;
2034 if self.findings.len() > MAX_ADVERSARIAL_REVIEW_FINDINGS {
2035 return Err(adversarial_review_error(
2036 "report.findings",
2037 "findings exceed the review bound",
2038 ));
2039 }
2040 let mut findings = BTreeMap::new();
2041 for finding in &self.findings {
2042 finding.validate()?;
2043 if finding.snapshot_sha256 != self.snapshot_sha256 {
2044 return Err(adversarial_review_error(
2045 "report.findings.snapshot_sha256",
2046 "every finding must bind to the report snapshot",
2047 ));
2048 }
2049 if findings.insert(&finding.finding_id, finding).is_some() {
2050 return Err(adversarial_review_error(
2051 "report.findings.finding_id",
2052 "finding identities must be unique",
2053 ));
2054 }
2055 }
2056 Ok(())
2057 }
2058
2059 pub fn validate_against_assignment(
2066 &self,
2067 assignment: &ReviewAssignment,
2068 ) -> Result<(), AdversarialReviewValidationError> {
2069 self.validate()?;
2070 assignment.validate()?;
2071 if self.assignment_id != assignment.assignment_id
2072 || self.reviewer_id != assignment.reviewer_id
2073 || self.snapshot_sha256 != assignment.snapshot.aggregate_sha256
2074 {
2075 return Err(adversarial_review_error(
2076 "report.assignment",
2077 "report must exactly match its assignment, reviewer, and repository snapshot",
2078 ));
2079 }
2080 Ok(())
2081 }
2082}
2083
2084#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2086#[serde(deny_unknown_fields)]
2087pub struct ReviewPanelResult {
2088 pub version: u32,
2089 pub snapshot_sha256: String,
2090 pub reports: Vec<ReviewReport>,
2092}
2093
2094impl ReviewPanelResult {
2095 pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
2102 if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
2103 return Err(adversarial_review_error(
2104 "panel.version",
2105 "unsupported version",
2106 ));
2107 }
2108 validate_sha256("panel.snapshot_sha256", &self.snapshot_sha256)?;
2109 if self.reports.len() < 2 || self.reports.len() > MAX_ADVERSARIAL_REVIEWERS {
2110 return Err(adversarial_review_error(
2111 "panel.reports",
2112 format!("panel requires 2..={MAX_ADVERSARIAL_REVIEWERS} reports"),
2113 ));
2114 }
2115 let mut previous = None;
2116 let mut findings = BTreeMap::<&str, &ReviewFinding>::new();
2117 for report in &self.reports {
2118 report.validate()?;
2119 if report.snapshot_sha256 != self.snapshot_sha256 {
2120 return Err(adversarial_review_error(
2121 "panel.reports.snapshot_sha256",
2122 "every report must bind to the panel snapshot",
2123 ));
2124 }
2125 if previous.is_some_and(|identity: &str| identity >= report.reviewer_id.as_str()) {
2126 return Err(adversarial_review_error(
2127 "panel.reports.reviewer_id",
2128 "reports must be strictly ordered by unique reviewer identity",
2129 ));
2130 }
2131 for finding in &report.findings {
2132 if findings
2133 .insert(&finding.finding_id, finding)
2134 .is_some_and(|existing| existing != finding)
2135 {
2136 return Err(adversarial_review_error(
2137 "panel.reports.findings.finding_id",
2138 "duplicate finding identities must have identical normalized payloads",
2139 ));
2140 }
2141 }
2142 previous = Some(report.reviewer_id.as_str());
2143 }
2144 Ok(())
2145 }
2146
2147 fn findings_by_id(&self) -> BTreeMap<&str, &ReviewFinding> {
2148 self.reports
2149 .iter()
2150 .flat_map(|report| &report.findings)
2151 .map(|finding| (finding.finding_id.as_str(), finding))
2152 .collect()
2153 }
2154}
2155
2156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2158#[serde(rename_all = "snake_case")]
2159pub enum ReviewConsensusDisposition {
2160 Required,
2161 Advisory,
2162 Invalid,
2163 Duplicate,
2164}
2165
2166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2168#[serde(deny_unknown_fields)]
2169pub struct ReviewConsensusDecision {
2170 pub finding_id: String,
2171 pub disposition: ReviewConsensusDisposition,
2172 #[serde(default, skip_serializing_if = "Option::is_none")]
2174 pub finding: Option<ReviewFinding>,
2175 #[serde(default, skip_serializing_if = "Option::is_none")]
2177 pub duplicate_of: Option<String>,
2178 pub rationale: String,
2179}
2180
2181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2183#[serde(deny_unknown_fields)]
2184pub struct ReviewConsensus {
2185 pub version: u32,
2186 pub snapshot_sha256: String,
2187 pub decisions: Vec<ReviewConsensusDecision>,
2189}
2190
2191impl ReviewConsensus {
2192 pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
2199 if self.version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
2200 return Err(adversarial_review_error(
2201 "consensus.version",
2202 "unsupported version",
2203 ));
2204 }
2205 validate_sha256("consensus.snapshot_sha256", &self.snapshot_sha256)?;
2206 if self.decisions.len() > MAX_ADVERSARIAL_REVIEW_FINDINGS {
2207 return Err(adversarial_review_error(
2208 "consensus.decisions",
2209 "decisions exceed the review bound",
2210 ));
2211 }
2212 let mut previous = None;
2213 for decision in &self.decisions {
2214 validate_review_id("consensus.decisions.finding_id", &decision.finding_id)?;
2215 validate_review_text("consensus.decisions.rationale", &decision.rationale)?;
2216 if previous.is_some_and(|identity: &str| identity >= decision.finding_id.as_str()) {
2217 return Err(adversarial_review_error(
2218 "consensus.decisions.finding_id",
2219 "decisions must be strictly ordered by unique finding identity",
2220 ));
2221 }
2222 match decision.disposition {
2223 ReviewConsensusDisposition::Required | ReviewConsensusDisposition::Advisory => {
2224 let finding = decision.finding.as_ref().ok_or_else(|| {
2225 adversarial_review_error(
2226 "consensus.decisions.finding",
2227 "retained decision requires the canonical finding",
2228 )
2229 })?;
2230 finding.validate()?;
2231 if finding.finding_id != decision.finding_id
2232 || finding.snapshot_sha256 != self.snapshot_sha256
2233 || decision.duplicate_of.is_some()
2234 {
2235 return Err(adversarial_review_error(
2236 "consensus.decisions",
2237 "retained decision payload is inconsistent",
2238 ));
2239 }
2240 }
2241 ReviewConsensusDisposition::Duplicate => {
2242 if decision.finding.is_some()
2243 || decision.duplicate_of.as_deref().is_none_or(|identity| {
2244 identity == decision.finding_id
2245 || validate_review_id("duplicate_of", identity).is_err()
2246 })
2247 {
2248 return Err(adversarial_review_error(
2249 "consensus.decisions.duplicate_of",
2250 "duplicate decision requires one different canonical finding identity",
2251 ));
2252 }
2253 }
2254 ReviewConsensusDisposition::Invalid => {
2255 if decision.finding.is_some() || decision.duplicate_of.is_some() {
2256 return Err(adversarial_review_error(
2257 "consensus.decisions",
2258 "invalid decision cannot retain a finding or duplicate target",
2259 ));
2260 }
2261 }
2262 }
2263 previous = Some(decision.finding_id.as_str());
2264 }
2265 Ok(())
2266 }
2267
2268 pub fn validate_against_panel(
2276 &self,
2277 panel: &ReviewPanelResult,
2278 ) -> Result<(), AdversarialReviewValidationError> {
2279 self.validate()?;
2280 panel.validate()?;
2281 if self.snapshot_sha256 != panel.snapshot_sha256 {
2282 return Err(adversarial_review_error(
2283 "consensus.snapshot_sha256",
2284 "consensus must bind to the panel snapshot",
2285 ));
2286 }
2287 let findings = panel.findings_by_id();
2288 if self.decisions.len() != findings.len() {
2289 return Err(adversarial_review_error(
2290 "consensus.decisions",
2291 "consensus must disposition every distinct panel finding exactly once",
2292 ));
2293 }
2294 let decisions = self
2295 .decisions
2296 .iter()
2297 .map(|decision| (decision.finding_id.as_str(), decision))
2298 .collect::<BTreeMap<_, _>>();
2299 for (finding_id, finding) in findings {
2300 let decision = decisions.get(finding_id).ok_or_else(|| {
2301 adversarial_review_error(
2302 "consensus.decisions",
2303 "consensus omitted a submitted finding",
2304 )
2305 })?;
2306 if finding.disposition == ReviewFindingDisposition::Required
2307 && !matches!(decision.disposition, ReviewConsensusDisposition::Required)
2308 {
2309 return Err(adversarial_review_error(
2310 "consensus.decisions.disposition",
2311 "required reviewer findings must remain required unless separately adjudicated with an explicit supported proof contract",
2312 ));
2313 }
2314 if matches!(
2315 decision.disposition,
2316 ReviewConsensusDisposition::Required | ReviewConsensusDisposition::Advisory
2317 ) && decision.finding.as_ref() != Some(finding)
2318 {
2319 return Err(adversarial_review_error(
2320 "consensus.decisions.finding",
2321 "retained consensus finding must equal the normalized panel finding",
2322 ));
2323 }
2324 if let Some(duplicate_of) = &decision.duplicate_of {
2325 let Some(target) = decisions.get(duplicate_of.as_str()) else {
2326 return Err(adversarial_review_error(
2327 "consensus.decisions.duplicate_of",
2328 "duplicate target must be another submitted finding",
2329 ));
2330 };
2331 if matches!(target.disposition, ReviewConsensusDisposition::Duplicate) {
2332 return Err(adversarial_review_error(
2333 "consensus.decisions.duplicate_of",
2334 "duplicate chains are prohibited",
2335 ));
2336 }
2337 }
2338 }
2339 Ok(())
2340 }
2341}
2342
2343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2345#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
2346pub enum ReviewRoundOutcome {
2347 Clean {
2348 version: u32,
2349 snapshot_sha256: String,
2350 },
2351 RemediationRequired {
2352 version: u32,
2353 snapshot_sha256: String,
2354 consensus: ReviewConsensus,
2355 },
2356 Exhausted {
2357 version: u32,
2358 snapshot_sha256: String,
2359 rounds: u32,
2360 consensus: ReviewConsensus,
2361 },
2362 Failed {
2363 version: u32,
2364 snapshot_sha256: String,
2365 message: String,
2366 },
2367 Cancelled {
2368 version: u32,
2369 snapshot_sha256: String,
2370 },
2371}
2372
2373impl ReviewRoundOutcome {
2374 pub fn validate(&self) -> Result<(), AdversarialReviewValidationError> {
2381 let (version, snapshot) = match self {
2382 Self::Clean {
2383 version,
2384 snapshot_sha256,
2385 }
2386 | Self::Cancelled {
2387 version,
2388 snapshot_sha256,
2389 } => (*version, snapshot_sha256),
2390 Self::RemediationRequired {
2391 version,
2392 snapshot_sha256,
2393 consensus,
2394 } => {
2395 consensus.validate()?;
2396 if consensus.snapshot_sha256 != *snapshot_sha256 {
2397 return Err(adversarial_review_error(
2398 "outcome.consensus.snapshot_sha256",
2399 "consensus must bind to the outcome snapshot",
2400 ));
2401 }
2402 (*version, snapshot_sha256)
2403 }
2404 Self::Exhausted {
2405 version,
2406 snapshot_sha256,
2407 rounds,
2408 consensus,
2409 } => {
2410 if *rounds == 0 {
2411 return Err(adversarial_review_error(
2412 "outcome.rounds",
2413 "exhausted outcome requires at least one round",
2414 ));
2415 }
2416 consensus.validate()?;
2417 if consensus.snapshot_sha256 != *snapshot_sha256 {
2418 return Err(adversarial_review_error(
2419 "outcome.consensus.snapshot_sha256",
2420 "consensus must bind to the outcome snapshot",
2421 ));
2422 }
2423 (*version, snapshot_sha256)
2424 }
2425 Self::Failed {
2426 version,
2427 snapshot_sha256,
2428 message,
2429 } => {
2430 validate_review_text("outcome.message", message)?;
2431 (*version, snapshot_sha256)
2432 }
2433 };
2434 if version != ADVERSARIAL_REVIEW_CONTRACT_VERSION {
2435 return Err(adversarial_review_error(
2436 "outcome.version",
2437 "unsupported version",
2438 ));
2439 }
2440 validate_sha256("outcome.snapshot_sha256", snapshot)
2441 }
2442}
2443
2444#[cfg(test)]
2445mod tests {
2446 use super::*;
2447
2448 fn review_snapshot() -> ReviewRepositorySnapshot {
2449 ReviewRepositorySnapshot {
2450 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2451 repository_identity_sha256: "a".repeat(64),
2452 head_object_id: "0123456789abcdef".to_string(),
2453 aggregate_sha256: "b".repeat(64),
2454 project_instruction_fingerprint_sha256: "c".repeat(64),
2455 }
2456 }
2457
2458 fn review_finding(id: &str) -> ReviewFinding {
2459 ReviewFinding {
2460 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2461 finding_id: id.to_string(),
2462 snapshot_sha256: "b".repeat(64),
2463 severity: ReviewFindingSeverity::Major,
2464 disposition: ReviewFindingDisposition::Required,
2465 location: Some(ReviewSourceRange {
2466 path: "src/lib.rs".to_string(),
2467 start_line: 10,
2468 start_column: Some(2),
2469 end_line: 12,
2470 end_column: Some(8),
2471 }),
2472 title: "Terminal state can reopen".to_string(),
2473 evidence: "A stale update overwrites the persisted terminal state.".to_string(),
2474 remediation: "Reject updates after the authoritative terminal transition.".to_string(),
2475 }
2476 }
2477
2478 fn review_report(reviewer: &str, finding_id: &str) -> ReviewReport {
2479 ReviewReport {
2480 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2481 assignment_id: format!("assignment-{reviewer}"),
2482 reviewer_id: reviewer.to_string(),
2483 snapshot_sha256: "b".repeat(64),
2484 execution: ReviewExecutionFacts {
2485 provider_id: "provider".to_string(),
2486 model_id: "model".to_string(),
2487 agent_profile: "reviewer".to_string(),
2488 tools: BTreeSet::from(["filesystem.read".to_string()]),
2489 },
2490 findings: vec![review_finding(finding_id)],
2491 }
2492 }
2493
2494 #[test]
2495 fn adversarial_review_contracts_validate_and_round_trip() {
2496 let assignment_request = ReviewPanelAssignmentRequest {
2497 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2498 round_id: "round-1".to_string(),
2499 snapshot: review_snapshot(),
2500 reviewer_ids: vec!["reviewer-a".to_string(), "reviewer-b".to_string()],
2501 include_paths: vec!["src/lib.rs".to_string()],
2502 policy_id: "strict".to_string(),
2503 };
2504 let assignments = assignment_request.materialize().expect("assignments");
2505 assert_eq!(assignments.assignments.len(), 2);
2506 assert_eq!(
2507 assignments.assignments[0].assignment_id,
2508 "round-1:reviewer-a"
2509 );
2510 assert_eq!(
2511 assignments.assignments[1].assignment_id,
2512 "round-1:reviewer-b"
2513 );
2514
2515 let assignment = ReviewAssignment {
2516 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2517 assignment_id: "assignment-a".to_string(),
2518 reviewer_id: "reviewer-a".to_string(),
2519 snapshot: review_snapshot(),
2520 include_paths: vec!["src/lib.rs".to_string()],
2521 policy_id: "strict".to_string(),
2522 };
2523 assignment.validate().expect("assignment");
2524
2525 let panel = ReviewPanelResult {
2526 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2527 snapshot_sha256: "b".repeat(64),
2528 reports: vec![
2529 review_report("reviewer-a", "finding-a"),
2530 review_report("reviewer-b", "finding-b"),
2531 ],
2532 };
2533 panel.validate().expect("panel");
2534 let encoded = serde_json::to_string(&panel).expect("serialize panel");
2535 assert_eq!(
2536 serde_json::from_str::<ReviewPanelResult>(&encoded).expect("deserialize panel"),
2537 panel
2538 );
2539
2540 let findings = [review_finding("finding-a"), review_finding("finding-b")];
2541 let consensus = ReviewConsensus {
2542 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2543 snapshot_sha256: "b".repeat(64),
2544 decisions: findings
2545 .into_iter()
2546 .map(|finding| ReviewConsensusDecision {
2547 finding_id: finding.finding_id.clone(),
2548 disposition: ReviewConsensusDisposition::Required,
2549 finding: Some(finding),
2550 duplicate_of: None,
2551 rationale: "The evidence demonstrates a correctness defect.".to_string(),
2552 })
2553 .collect(),
2554 };
2555 consensus.validate().expect("consensus");
2556 consensus
2557 .validate_against_panel(&panel)
2558 .expect("panel consensus");
2559 ReviewRoundOutcome::RemediationRequired {
2560 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2561 snapshot_sha256: "b".repeat(64),
2562 consensus,
2563 }
2564 .validate()
2565 .expect("outcome");
2566 }
2567
2568 #[test]
2569 fn adversarial_review_contracts_reject_mixed_snapshot_and_conflicting_identity() {
2570 let mut report = review_report("reviewer-a", "finding-a");
2571 report.findings[0].snapshot_sha256 = "d".repeat(64);
2572 assert!(report.validate().is_err());
2573
2574 let unordered = ReviewPanelAssignmentRequest {
2575 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2576 round_id: "round-1".to_string(),
2577 snapshot: review_snapshot(),
2578 reviewer_ids: vec!["reviewer-b".to_string(), "reviewer-a".to_string()],
2579 include_paths: Vec::new(),
2580 policy_id: "strict".to_string(),
2581 };
2582 assert!(unordered.materialize().is_err());
2583
2584 let duplicate_report = review_report("reviewer-a", "finding-b");
2585 let panel = ReviewPanelResult {
2586 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2587 snapshot_sha256: "b".repeat(64),
2588 reports: vec![review_report("reviewer-a", "finding-a"), duplicate_report],
2589 };
2590 assert!(panel.validate().is_err());
2591
2592 let decision = ReviewConsensusDecision {
2593 finding_id: "finding-a".to_string(),
2594 disposition: ReviewConsensusDisposition::Duplicate,
2595 finding: None,
2596 duplicate_of: Some("finding-a".to_string()),
2597 rationale: "duplicate".to_string(),
2598 };
2599 assert!(
2600 ReviewConsensus {
2601 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2602 snapshot_sha256: "b".repeat(64),
2603 decisions: vec![decision],
2604 }
2605 .validate()
2606 .is_err()
2607 );
2608
2609 let panel = ReviewPanelResult {
2610 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2611 snapshot_sha256: "b".repeat(64),
2612 reports: vec![
2613 review_report("reviewer-a", "finding-a"),
2614 review_report("reviewer-b", "finding-b"),
2615 ],
2616 };
2617 let finding = review_finding("finding-a");
2618 let incomplete = ReviewConsensus {
2619 version: ADVERSARIAL_REVIEW_CONTRACT_VERSION,
2620 snapshot_sha256: "b".repeat(64),
2621 decisions: vec![ReviewConsensusDecision {
2622 finding_id: finding.finding_id.clone(),
2623 disposition: ReviewConsensusDisposition::Advisory,
2624 finding: Some(finding),
2625 duplicate_of: None,
2626 rationale: "downgraded".to_string(),
2627 }],
2628 };
2629 assert!(incomplete.validate_against_panel(&panel).is_err());
2630 }
2631
2632 #[test]
2633 fn external_review_import_models_round_trip_json() {
2634 let response = ExternalReviewImportResponse {
2635 importer_id: "github_pr_review".to_string(),
2636 threads: vec![ExternalReviewThread {
2637 identity: ExternalReviewIdentity {
2638 provider_id: "github".to_string(),
2639 external_id: "thread-1".to_string(),
2640 url: Some("https://github.com/owner/repo/pull/1#discussion_r1".to_string()),
2641 },
2642 state: ExternalReviewThreadState::Outdated,
2643 mapping: ExternalReviewAnchorMapping {
2644 status: ExternalAnchorMappingStatus::Unmappable,
2645 anchor: None,
2646 message: Some("diff context is outdated".to_string()),
2647 },
2648 comments: vec![ExternalReviewComment {
2649 identity: ExternalReviewIdentity {
2650 provider_id: "github".to_string(),
2651 external_id: "comment-1".to_string(),
2652 url: None,
2653 },
2654 author: None,
2655 author_label: Some("reviewer".to_string()),
2656 body: "Please handle this case.".to_string(),
2657 created_at_ms: Some(10),
2658 updated_at_ms: Some(20),
2659 }],
2660 }],
2661 warnings: vec!["one thread was unmappable".to_string()],
2662 };
2663
2664 let json = serde_json::to_string(&response).expect("serialize import response");
2665 let decoded: ExternalReviewImportResponse =
2666 serde_json::from_str(&json).expect("deserialize import response");
2667
2668 assert_eq!(decoded, response);
2669 }
2670
2671 #[test]
2672 fn review_ai_exchange_models_round_trip_json() {
2673 let exchange = ReviewAiExchange {
2674 schema_version: REVIEW_AI_EXCHANGE_SCHEMA_VERSION,
2675 exchange_id: "exchange-1".to_string(),
2676 anchor: DraftAnchor {
2677 kind: ReviewAnchorKind::Range,
2678 file_path: "src/lib.rs".to_string(),
2679 diff_row: 3,
2680 start_diff_row: Some(3),
2681 end_diff_row: Some(4),
2682 old_start: Some(2),
2683 old_end: Some(2),
2684 new_start: Some(3),
2685 new_end: Some(4),
2686 old_line: None,
2687 new_line: None,
2688 line_kind: ReviewLineKind::Added,
2689 is_file_anchor: false,
2690 surface_id: Some("surface-1".to_string()),
2691 source_id: Some("source-1".to_string()),
2692 },
2693 question: "Why is this safe?".to_string(),
2694 session_id: Some("session-1".to_string()),
2695 status: ReviewAiExchangeStatus::Linked,
2696 error: None,
2697 created_at_ms: 10,
2698 updated_at_ms: 20,
2699 };
2700
2701 let json = serde_json::to_string(&exchange).expect("serialize exchange");
2702 let round_trip: ReviewAiExchange =
2703 serde_json::from_str(&json).expect("deserialize exchange");
2704
2705 assert_eq!(round_trip, exchange);
2706 assert_eq!(
2707 serde_json::to_value(ReviewAiExchangeStatus::Failed).expect("status json"),
2708 serde_json::json!("failed")
2709 );
2710 }
2711
2712 #[test]
2713 fn review_suggestion_models_round_trip_json() {
2714 let suggestion = ReviewSuggestion {
2715 suggestion_id: "suggestion-1".to_string(),
2716 anchor: DraftAnchor {
2717 kind: ReviewAnchorKind::Range,
2718 file_path: "src/lib.rs".to_string(),
2719 diff_row: 3,
2720 start_diff_row: Some(3),
2721 end_diff_row: Some(4),
2722 old_start: Some(2),
2723 old_end: Some(2),
2724 new_start: Some(3),
2725 new_end: Some(4),
2726 old_line: None,
2727 new_line: None,
2728 line_kind: ReviewLineKind::Added,
2729 is_file_anchor: false,
2730 surface_id: Some("surface-1".to_string()),
2731 source_id: Some("source-1".to_string()),
2732 },
2733 body: "Handle the error explicitly.".to_string(),
2734 session_id: Some("session-1".to_string()),
2735 status: ReviewSuggestionStatus::Rejected,
2736 rationale: Some("AI review".to_string()),
2737 created_at_ms: 10,
2738 updated_at_ms: 20,
2739 };
2740
2741 let json = serde_json::to_string(&suggestion).expect("serialize suggestion");
2742 let decoded: ReviewSuggestion =
2743 serde_json::from_str(&json).expect("deserialize suggestion");
2744
2745 assert_eq!(decoded, suggestion);
2746 }
2747
2748 #[test]
2749 fn review_suggestion_status_uses_provider_neutral_wire_values() {
2750 assert_eq!(
2751 serde_json::to_string(&ReviewSuggestionStatus::Refining)
2752 .expect("serialize suggestion status"),
2753 "\"refining\""
2754 );
2755 assert_eq!(
2756 serde_json::from_str::<ReviewSuggestionStatus>("\"accepted\"")
2757 .expect("deserialize suggestion status"),
2758 ReviewSuggestionStatus::Accepted
2759 );
2760 }
2761}