Skip to main content

clankerdiff_protocol/
lib.rs

1//! Stable, versioned JSON envelopes emitted by the `clankerdiff` executable.
2//!
3//! Protocol compatibility is independent from this crate's package version.
4//! Deserialization accepts unknown fields for forward compatibility, while
5//! [`parse_response`] validates versions and outcome/submission invariants.
6
7pub 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    /// Checks protocol version and outcome/submission invariants.
70    ///
71    /// # Errors
72    /// Returns an error for an unsupported version or inconsistent submission.
73    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
122/// Parses and validates a subprocess review response.
123///
124/// # Errors
125/// Returns an error for malformed JSON, unsupported protocol versions, or an
126/// invalid outcome/submission combination.
127pub 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}