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
7use 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    /// Checks protocol version and outcome/submission invariants.
66    ///
67    /// # Errors
68    /// Returns an error for an unsupported version or inconsistent submission.
69    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
118/// Parses and validates a subprocess review response.
119///
120/// # Errors
121/// Returns an error for malformed JSON, unsupported protocol versions, or an
122/// invalid outcome/submission combination.
123pub 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}