1use clankerdiff_core::{DiffScope, ReviewSubmission};
8use clankerdiff_markdown::MarkdownReviewSubmission;
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11use thiserror::Error;
12
13pub const PROTOCOL_VERSION: u32 = 1;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum ReviewOutcome {
19 Approved,
20 ChangesRequested,
21 Cancelled,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(tag = "document_kind", rename_all = "snake_case")]
26pub enum ReviewResponse {
27 Diff {
28 protocol_version: u32,
29 outcome: ReviewOutcome,
30 repository_root: PathBuf,
31 #[serde(with = "diff_scope")]
32 scope: DiffScope,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 submission: Option<ReviewSubmission>,
35 },
36 Markdown {
37 protocol_version: u32,
38 outcome: ReviewOutcome,
39 source_path: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 submission: Option<MarkdownReviewSubmission>,
42 },
43}
44
45impl ReviewResponse {
46 #[must_use]
47 pub const fn protocol_version(&self) -> u32 {
48 match self {
49 Self::Diff {
50 protocol_version, ..
51 }
52 | Self::Markdown {
53 protocol_version, ..
54 } => *protocol_version,
55 }
56 }
57
58 #[must_use]
59 pub const fn outcome(&self) -> ReviewOutcome {
60 match self {
61 Self::Diff { outcome, .. } | Self::Markdown { outcome, .. } => *outcome,
62 }
63 }
64
65 pub fn validate(&self) -> Result<(), ProtocolValidationError> {
70 if self.protocol_version() != PROTOCOL_VERSION {
71 return Err(ProtocolValidationError::UnsupportedVersion {
72 received: self.protocol_version(),
73 supported: PROTOCOL_VERSION,
74 });
75 }
76 let has_submission = match self {
77 Self::Diff { submission, .. } => submission.is_some(),
78 Self::Markdown { submission, .. } => submission.is_some(),
79 };
80 match (self.outcome(), has_submission) {
81 (ReviewOutcome::Cancelled, false)
82 | (ReviewOutcome::Approved | ReviewOutcome::ChangesRequested, true) => Ok(()),
83 (ReviewOutcome::Cancelled, true) => {
84 Err(ProtocolValidationError::CancellationHasSubmission)
85 }
86 (ReviewOutcome::Approved | ReviewOutcome::ChangesRequested, false) => {
87 Err(ProtocolValidationError::SubmittedWithoutSubmission)
88 }
89 }
90 }
91}
92
93mod diff_scope {
94 use clankerdiff_core::DiffScope;
95 use serde::{Deserialize, Deserializer, Serializer, de::Error as _};
96 use std::str::FromStr;
97
98 #[expect(
99 clippy::trivially_copy_pass_by_ref,
100 reason = "serde with modules require this serializer signature"
101 )]
102 pub fn serialize<S>(scope: &DiffScope, serializer: S) -> Result<S::Ok, S::Error>
103 where
104 S: Serializer,
105 {
106 serializer.serialize_str(scope.as_str())
107 }
108
109 pub fn deserialize<'de, D>(deserializer: D) -> Result<DiffScope, D::Error>
110 where
111 D: Deserializer<'de>,
112 {
113 let scope = String::deserialize(deserializer)?;
114 DiffScope::from_str(&scope).map_err(D::Error::custom)
115 }
116}
117
118pub fn parse_response(bytes: &[u8]) -> Result<ReviewResponse, ParseResponseError> {
124 let response = serde_json::from_slice::<ReviewResponse>(bytes)?;
125 response.validate()?;
126 Ok(response)
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct CapabilityResponse {
131 pub protocol_version: u32,
132 pub supported_protocol_versions: Vec<u32>,
133 pub review_kinds: Vec<ReviewKind>,
134 pub uis: Vec<UiKind>,
135 pub current_terminal_tui: bool,
136}
137
138impl Default for CapabilityResponse {
139 fn default() -> Self {
140 Self {
141 protocol_version: PROTOCOL_VERSION,
142 supported_protocol_versions: vec![PROTOCOL_VERSION],
143 review_kinds: vec![ReviewKind::Diff, ReviewKind::Markdown],
144 uis: vec![UiKind::Tui, UiKind::Desktop],
145 current_terminal_tui: true,
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum ReviewKind {
153 Diff,
154 Markdown,
155}
156
157impl ReviewKind {
158 #[must_use]
159 pub const fn as_str(self) -> &'static str {
160 match self {
161 Self::Diff => "diff",
162 Self::Markdown => "markdown",
163 }
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum UiKind {
170 Tui,
171 Desktop,
172}
173
174impl UiKind {
175 #[must_use]
176 pub const fn as_str(self) -> &'static str {
177 match self {
178 Self::Tui => "tui",
179 Self::Desktop => "desktop",
180 }
181 }
182}
183
184#[derive(Debug, Error, PartialEq, Eq)]
185pub enum ProtocolValidationError {
186 #[error(
187 "unsupported Clankerdiff protocol version {received}; this client supports version {supported}"
188 )]
189 UnsupportedVersion { received: u32, supported: u32 },
190 #[error("a cancelled review must not contain a submission")]
191 CancellationHasSubmission,
192 #[error("an approved or changes-requested review must contain a submission")]
193 SubmittedWithoutSubmission,
194}
195
196#[derive(Debug, Error)]
197pub enum ParseResponseError {
198 #[error("invalid Clankerdiff response JSON: {0}")]
199 Json(#[from] serde_json::Error),
200 #[error(transparent)]
201 Validation(#[from] ProtocolValidationError),
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn round_trips_submitted_and_cancelled_responses() {
210 let responses = [
211 ReviewResponse::Diff {
212 protocol_version: PROTOCOL_VERSION,
213 outcome: ReviewOutcome::Approved,
214 repository_root: PathBuf::from("/repo"),
215 scope: DiffScope::Both,
216 submission: Some(ReviewSubmission {
217 comments: Vec::new(),
218 formatted: "approved".into(),
219 }),
220 },
221 ReviewResponse::Markdown {
222 protocol_version: PROTOCOL_VERSION,
223 outcome: ReviewOutcome::Cancelled,
224 source_path: Some("plans/a.md".into()),
225 submission: None,
226 },
227 ];
228 for response in &responses {
229 let json = serde_json::to_vec(response).unwrap();
230 let parsed = parse_response(&json).unwrap();
231 assert_eq!(&parsed, response);
232 }
233
234 let json = serde_json::to_string(&responses[0]).unwrap();
235 assert!(json.contains(r#""scope":"both""#));
236 }
237
238 #[test]
239 fn rejects_invalid_outcome_submission_combinations() {
240 let missing = ReviewResponse::Diff {
241 protocol_version: PROTOCOL_VERSION,
242 outcome: ReviewOutcome::Approved,
243 repository_root: PathBuf::from("/repo"),
244 scope: DiffScope::Both,
245 submission: None,
246 };
247 assert_eq!(
248 missing.validate(),
249 Err(ProtocolValidationError::SubmittedWithoutSubmission)
250 );
251 }
252
253 #[test]
254 fn accepts_unknown_fields_and_rejects_new_versions() {
255 let cancelled = br#"{
256 "document_kind":"markdown",
257 "protocol_version":1,
258 "outcome":"cancelled",
259 "source_path":"plans/a.md",
260 "future_field":true
261 }"#;
262 let response = parse_response(cancelled).unwrap();
263 assert_eq!(response.outcome(), ReviewOutcome::Cancelled);
264
265 let newer = cancelled.to_vec();
266 let newer = String::from_utf8(newer).unwrap().replacen(":1", ":2", 1);
267 assert!(matches!(
268 parse_response(newer.as_bytes()),
269 Err(ParseResponseError::Validation(
270 ProtocolValidationError::UnsupportedVersion { received: 2, .. }
271 ))
272 ));
273 }
274}