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