1use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16pub type ReviewId = String;
17pub type ReviewPublisherId = String;
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "camelCase", tag = "kind")]
23pub enum ReviewTarget {
24 #[default]
26 UncommittedChanges,
27 BaseBranch { branch: String },
29 Commit {
31 sha: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 title: Option<String>,
34 },
35 Custom { instructions: String },
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ReviewRequest {
44 pub target: ReviewTarget,
45 pub label: String,
47 pub prompt: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub base_sha: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub head_sha: Option<String>,
55 pub workspace_root: PathBuf,
56}
57
58#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "camelCase")]
61pub enum ReviewDelivery {
62 #[default]
64 Inline,
65 Detached,
68}
69
70#[derive(
73 Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
74)]
75#[serde(rename_all = "snake_case")]
76pub enum ReviewPriority {
77 P0,
78 P1,
79 #[default]
80 P2,
81 P3,
82}
83
84impl ReviewPriority {
85 pub const ALL: [ReviewPriority; 4] = [
86 ReviewPriority::P0,
87 ReviewPriority::P1,
88 ReviewPriority::P2,
89 ReviewPriority::P3,
90 ];
91
92 pub fn as_str(self) -> &'static str {
93 match self {
94 ReviewPriority::P0 => "p0",
95 ReviewPriority::P1 => "p1",
96 ReviewPriority::P2 => "p2",
97 ReviewPriority::P3 => "p3",
98 }
99 }
100
101 pub fn parse(value: &str) -> Option<Self> {
104 match value.trim().to_ascii_lowercase().as_str() {
105 "p0" => Some(ReviewPriority::P0),
106 "p1" => Some(ReviewPriority::P1),
107 "p2" => Some(ReviewPriority::P2),
108 "p3" => Some(ReviewPriority::P3),
109 _ => None,
110 }
111 }
112}
113
114impl std::fmt::Display for ReviewPriority {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.write_str(self.as_str())
117 }
118}
119
120impl std::str::FromStr for ReviewPriority {
121 type Err = String;
122
123 fn from_str(value: &str) -> Result<Self, Self::Err> {
124 Self::parse(value)
125 .ok_or_else(|| format!("unknown review priority {value}; expected p0..p3"))
126 }
127}
128
129#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub struct ReviewLineRange {
133 pub start: u32,
134 pub end: u32,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "camelCase")]
139pub struct ReviewCodeLocation {
140 pub absolute_file_path: PathBuf,
141 pub line_range: ReviewLineRange,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct ReviewFinding {
147 pub title: String,
148 pub body: String,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub confidence_score: Option<f32>,
151 #[serde(default)]
152 pub priority: ReviewPriority,
153 pub code_location: ReviewCodeLocation,
154}
155
156impl ReviewFinding {
157 pub fn display_title(&self) -> &str {
164 let title = self.title.trim();
165 let Some(rest) = title.strip_prefix('[') else {
166 return title;
167 };
168 let Some((tag, rest)) = rest.split_once(']') else {
169 return title;
170 };
171 let tag = tag.trim();
172 let is_priority_tag = tag.len() == 2
173 && tag.starts_with(['p', 'P'])
174 && tag[1..].chars().all(|digit| ('0'..='3').contains(&digit));
175 if is_priority_tag {
176 rest.trim_start()
177 } else {
178 title
179 }
180 }
181}
182
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct ReviewOutput {
188 #[serde(default)]
189 pub findings: Vec<ReviewFinding>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub overall_correctness: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub overall_explanation: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub overall_confidence_score: Option<f32>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199#[serde(rename_all = "camelCase")]
200pub struct ReviewPublisherDescriptor {
201 pub id: ReviewPublisherId,
202 pub display_name: String,
203 pub capabilities: ReviewPublisherCapabilities,
204}
205
206#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
207#[serde(rename_all = "camelCase")]
208pub struct ReviewPublisherCapabilities {
209 pub inline_comments: bool,
211 pub summary_body: bool,
213 pub autodetect_destination: bool,
215 pub dry_run: bool,
217}
218
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct ReviewDestination {
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub publisher_id: Option<ReviewPublisherId>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub target: Option<String>,
229 #[serde(default)]
230 pub options: serde_json::Map<String, serde_json::Value>,
231}
232
233#[derive(Debug, Clone)]
234pub struct ReviewPublishRequest {
235 pub review_id: ReviewId,
236 pub workspace_root: PathBuf,
237 pub request: ReviewRequest,
239 pub output: ReviewOutput,
240 pub selected: Option<Vec<usize>>,
242 pub destination: ReviewDestination,
243 pub dry_run: bool,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248#[serde(rename_all = "camelCase")]
249pub struct ReviewPublishSkip {
250 pub finding_index: usize,
251 pub reason: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct ReviewPublishResult {
257 pub publisher_id: ReviewPublisherId,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub url: Option<String>,
260 pub published_findings: usize,
261 #[serde(default)]
262 pub skipped: Vec<ReviewPublishSkip>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub payload_preview: Option<serde_json::Value>,
266}
267
268#[derive(Debug)]
269pub enum ReviewPublishError {
270 DestinationUnresolved(String),
272 NotConfigured(String),
274 Remote(String),
276 Other(anyhow::Error),
277}
278
279impl std::fmt::Display for ReviewPublishError {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 match self {
282 Self::DestinationUnresolved(message) => {
283 write!(f, "destination could not be resolved: {message}")
284 }
285 Self::NotConfigured(message) => write!(f, "publisher not configured: {message}"),
286 Self::Remote(message) => write!(f, "remote rejected the review: {message}"),
287 Self::Other(error) => write!(f, "{error}"),
288 }
289 }
290}
291
292impl std::error::Error for ReviewPublishError {
293 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
294 match self {
295 Self::Other(error) => Some(error.as_ref()),
296 _ => None,
297 }
298 }
299}
300
301impl From<anyhow::Error> for ReviewPublishError {
302 fn from(error: anyhow::Error) -> Self {
303 Self::Other(error)
304 }
305}
306
307#[async_trait::async_trait]
311pub trait ReviewPublisher: Send + Sync + 'static {
312 fn descriptor(&self) -> ReviewPublisherDescriptor;
313
314 async fn is_available(&self, workspace_root: &Path) -> bool {
317 let _ = workspace_root;
318 true
319 }
320
321 async fn publish(
322 &self,
323 request: ReviewPublishRequest,
324 ) -> Result<ReviewPublishResult, ReviewPublishError>;
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn display_title_strips_only_a_leading_priority_tag() {
333 let finding = |title: &str| ReviewFinding {
334 title: title.to_string(),
335 body: String::new(),
336 confidence_score: None,
337 priority: ReviewPriority::P1,
338 code_location: ReviewCodeLocation {
339 absolute_file_path: PathBuf::from("/tmp/a.rs"),
340 line_range: ReviewLineRange { start: 1, end: 1 },
341 },
342 };
343
344 assert_eq!(
345 finding("[P1] Guard the divisor").display_title(),
346 "Guard the divisor"
347 );
348 assert_eq!(finding("[p0] Guard").display_title(), "Guard");
349 assert_eq!(finding(" [P3] Guard ").display_title(), "Guard");
350 assert_eq!(
352 finding("Guard the divisor").display_title(),
353 "Guard the divisor"
354 );
355 assert_eq!(finding("[BUG] Guard").display_title(), "[BUG] Guard");
356 assert_eq!(finding("[P9] Guard").display_title(), "[P9] Guard");
357 assert_eq!(finding("[P1 Guard").display_title(), "[P1 Guard");
358 }
359
360 #[test]
361 fn review_target_is_tagged_and_camel_case() {
362 let target = ReviewTarget::BaseBranch {
363 branch: "main".to_string(),
364 };
365 let value = serde_json::to_value(&target).expect("serialize target");
366 assert_eq!(value["kind"], "baseBranch");
367 assert_eq!(value["branch"], "main");
368
369 let round_trip: ReviewTarget = serde_json::from_value(value).expect("deserialize target");
370 assert_eq!(round_trip, target);
371 assert_eq!(ReviewTarget::default(), ReviewTarget::UncommittedChanges);
372 }
373
374 #[test]
375 fn review_priority_defaults_to_p2_and_parses() {
376 assert_eq!(ReviewPriority::default(), ReviewPriority::P2);
377 assert_eq!(ReviewPriority::parse("P1"), Some(ReviewPriority::P1));
378 assert_eq!(ReviewPriority::parse(" p3 "), Some(ReviewPriority::P3));
379 assert_eq!(ReviewPriority::parse("urgent"), None);
380 assert_eq!(ReviewPriority::P0.to_string(), "p0");
381 assert!(ReviewPriority::P0 < ReviewPriority::P3);
382 assert_eq!(
383 serde_json::to_value(ReviewPriority::P1).expect("serialize priority"),
384 serde_json::json!("p1")
385 );
386 }
387
388 #[test]
389 fn review_finding_defaults_missing_priority() {
390 let finding: ReviewFinding = serde_json::from_value(serde_json::json!({
391 "title": "Off-by-one",
392 "body": "The loop overruns.",
393 "codeLocation": {
394 "absoluteFilePath": "/repo/src/lib.rs",
395 "lineRange": { "start": 10, "end": 12 }
396 }
397 }))
398 .expect("deserialize finding");
399
400 assert_eq!(finding.priority, ReviewPriority::P2);
401 assert!(finding.confidence_score.is_none());
402 assert_eq!(finding.code_location.line_range.end, 12);
403 }
404
405 #[test]
406 fn review_output_tolerates_an_empty_object() {
407 let output: ReviewOutput =
408 serde_json::from_str("{}").expect("deserialize empty review output");
409 assert!(output.findings.is_empty());
410 assert!(output.overall_correctness.is_none());
411 }
412
413 #[test]
414 fn publish_error_renders_context() {
415 let error = ReviewPublishError::NotConfigured("gh not on PATH".to_string());
416 assert_eq!(
417 error.to_string(),
418 "publisher not configured: gh not on PATH"
419 );
420 }
421}