Skip to main content

protocol_droid/domain/acp/
protocol_version.rs

1//! `AcpProtocolVersion` — the wire-level protocol version.
2//!
3//! # Why a separate type from [`AcpVersion`](crate::domain::acp::version::AcpVersion)
4//!
5//! The ACP `meta.json` file and the schema's `MANIFEST.toml`
6//! entry pin a *release* (`schema-v1.25.0`). The wire's
7//! `protocolVersion` field on `InitializeRequest` and
8//! `InitializeResponse` is a `u16` that increments on
9//! breaking changes (per the schema's own docstring on
10//! `ProtocolVersion`:
11//! <https://github.com/agentclientprotocol/agent-client-protocol/blob/schema-v1.25.0/schema/v1/schema.json#LProtocolVersion>).
12//!
13//! The two coexist: a build pinned to `AcpVersion::V1_25_0`
14//! *always* sends `AcpProtocolVersion(1)` on the wire. A
15//! future build pinned to a v2 release sends `2`. A peer
16//! that does not understand the offered version answers
17//! with the same `u16` value the build knows, and the
18//! negotiation result is "the lower of the two".
19//!
20//! # Why `Copy` and a `u16` newtype
21//!
22//! The wire form is a `u16`; the dispatcher's API boundary
23//! hands out the typed value by value, not by reference.
24//! A bare `u16` would type-collide with every other
25//! `u16` in the codebase; the newtype is the boundary
26//! marker the type checker reads.
27
28use core::fmt;
29use core::str::FromStr;
30
31use serde::{Deserialize, Deserializer, Serialize, Serializer};
32
33/// The wire-level protocol version. The current pin is
34/// `V1`, which is what every `schema-v1.x.y` release
35/// advertises. A future breaking change moves the pin to
36/// `V2` and the build knows.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
38pub struct AcpProtocolVersion(
39    /// The wire-level integer. `0` is reserved (the
40    /// schema's `minimum` is 0, but no published release
41    /// has ever used 0; the `AcpProtocolVersion::CURRENT`
42    /// constant is the only well-known value).
43    pub u16,
44);
45
46impl AcpProtocolVersion {
47    /// The current protocol version. `1` is the wire value
48    /// every `schema-v1.x.y` release advertises. The
49    /// release tag in [`AcpVersion`](crate::domain::acp::version::AcpVersion) is a different
50    /// number (the schema's release tag is a `Major.Minor.Patch`
51    /// tuple, the wire value is the *protocol* major).
52    pub const CURRENT: Self = Self(1);
53}
54
55impl Default for AcpProtocolVersion {
56    fn default() -> Self {
57        Self::CURRENT
58    }
59}
60
61impl fmt::Display for AcpProtocolVersion {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        self.0.fmt(formatter)
64    }
65}
66
67/// The error returned by [`AcpProtocolVersion::from_str`]
68/// when the input is not a well-formed decimal integer
69/// in `0..=u16::MAX`.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct AcpProtocolVersionParseError {
72    input: String,
73    reason: &'static str,
74}
75
76impl fmt::Display for AcpProtocolVersionParseError {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(
79            formatter,
80            "ACP protocol version {:?} is invalid: {}",
81            self.input, self.reason
82        )
83    }
84}
85
86impl std::error::Error for AcpProtocolVersionParseError {}
87
88impl FromStr for AcpProtocolVersion {
89    type Err = AcpProtocolVersionParseError;
90
91    fn from_str(input: &str) -> Result<Self, Self::Err> {
92        let value = input.parse::<u16>().map_err(|error| {
93            let reason = match error.kind() {
94                core::num::IntErrorKind::Empty => "the input is empty",
95                core::num::IntErrorKind::InvalidDigit => "the input is not a decimal integer",
96                core::num::IntErrorKind::PosOverflow => "the value overflows u16",
97                _ => "the input is not a valid u16",
98            };
99            AcpProtocolVersionParseError {
100                input: input.to_owned(),
101                reason,
102            }
103        })?;
104        Ok(Self(value))
105    }
106}
107
108impl Serialize for AcpProtocolVersion {
109    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
110    where
111        S: Serializer,
112    {
113        self.0.serialize(serializer)
114    }
115}
116
117impl<'de> Deserialize<'de> for AcpProtocolVersion {
118    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
119    where
120        D: Deserializer<'de>,
121    {
122        u16::deserialize(deserializer).map(Self)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    /// The test error type. `serde_json` is the
131    /// only fallible call; everything else is
132    /// infallible for a well-formed value.
133    #[derive(Debug)]
134    #[expect(
135        dead_code,
136        reason = "the test only constructs the error variant on failure; the field is read by the Debug derive"
137    )]
138    enum Failure {
139        Parse(AcpProtocolVersionParseError),
140        Json(serde_json::Error),
141    }
142
143    impl From<AcpProtocolVersionParseError> for Failure {
144        fn from(error: AcpProtocolVersionParseError) -> Self {
145            Self::Parse(error)
146        }
147    }
148
149    impl From<serde_json::Error> for Failure {
150        fn from(error: serde_json::Error) -> Self {
151            Self::Json(error)
152        }
153    }
154
155    /// The current pin round-trips through
156    /// `Display` and `FromStr`, and serialises
157    /// as a bare `u16` (no struct wrapper, no
158    /// string) on the wire.
159    #[test]
160    fn the_current_pin_round_trips_and_serialises_as_u16() -> Result<(), Failure> {
161        let value: AcpProtocolVersion = "1".parse()?;
162        assert_eq!(value, AcpProtocolVersion::CURRENT);
163        let round_tripped: AcpProtocolVersion = format!("{value}").parse()?;
164        assert_eq!(round_tripped, value);
165        let json = serde_json::to_string(&value)?;
166        assert_eq!(json, "1", "the wire form is a bare u16");
167        Ok(())
168    }
169
170    /// An empty input and an out-of-range input
171    /// are both errors with a reason. The error
172    /// type carries the input and the reason so
173    /// a log line can be actionable.
174    #[test]
175    fn parse_failures_carry_the_input_and_the_reason() -> Result<(), Failure> {
176        match "".parse::<AcpProtocolVersion>() {
177            Err(error) => assert_eq!(error.reason, "the input is empty"),
178            Ok(_) => {
179                return Err(Failure::Parse(AcpProtocolVersionParseError {
180                    input: "<empty>".to_owned(),
181                    reason: "an empty input should fail to parse",
182                }));
183            }
184        }
185        match "999999".parse::<AcpProtocolVersion>() {
186            Err(error) => assert_eq!(error.reason, "the value overflows u16"),
187            Ok(_) => {
188                return Err(Failure::Parse(AcpProtocolVersionParseError {
189                    input: "999999".to_owned(),
190                    reason: "an out-of-range input should fail to parse",
191                }));
192            }
193        }
194        match "1.5".parse::<AcpProtocolVersion>() {
195            Err(error) => assert_eq!(error.reason, "the input is not a decimal integer"),
196            Ok(_) => {
197                return Err(Failure::Parse(AcpProtocolVersionParseError {
198                    input: "1.5".to_owned(),
199                    reason: "a fractional input should fail to parse",
200                }));
201            }
202        }
203        Ok(())
204    }
205}