Skip to main content

protocol_droid/domain/a2a/
version.rs

1//! `A2aVersion` — the negotiated A2A revision, as a value object.
2//!
3//! # Why `Major.Minor` and not a date
4//!
5//! The A2A protocol identifies a revision by a `Major.Minor` string
6//! ("0.3", "1.0") carried in the `A2A-Version` request header (or the
7//! `protocolVersion` field on the agent card). It is semver-shaped, not
8//! date-shaped, so the model is `{ major: u16, minor: u16 }` rather than a
9//! `ReleaseDate`. The string form is what crosses the wire; the parsed
10//! form is what crosses the dispatcher's API boundary.
11//!
12//! # The ordering invariant
13//!
14//! Both `Ord` and `PartialOrd` are derived, from the same field order
15//! (major before minor), which is what the 1.98 derive fast path
16//! requires. For that to also be *correct* under the A2A spec's
17//! "latest minor per major" rule, a future minor bumps the major
18//! without a code change; an `Unknown { major: 1, minor: 0 }` must
19//! sort the same as `V1_0`. See `tests::unknown_versions_sort_with_their_known_equivalent`.
20//!
21//! # Why `Unknown` is a real struct, not a string
22//!
23//! Holding a bare `String` would let `Unknown` be anything, including a
24//! typo, and would make the comparison lexicographic — `0.10 < 0.2`
25//! is wrong. Holding an `A2aVersion` parsed from a well-formed
26//! `Major.Minor` string means every `Unknown` is a real, ordered point
27//! in the same `Major.Minor` space; the version comparators and
28//! `best_match` table stay type-safe.
29
30use core::fmt;
31use core::str::FromStr;
32
33use serde::{Deserialize, Deserializer, Serialize, Serializer};
34
35/// A published A2A revision, or one published after this build was compiled.
36///
37/// Ordered by `major` then `minor`, oldest to newest. See the module docs for
38/// why that is an invariant rather than a coincidence.
39///
40/// `Ord` and `PartialOrd` are *hand-written* (the derive is removed) for the
41/// reason in the module docs: a derived `Ord` orders enum variants by
42/// declaration order first, which would put `V1_0` (the first variant) before
43/// `Unknown { major: 1, minor: 0 }` even though they carry the same wire
44/// value. The hand impl compares by `(major, minor)` only, so the two are
45/// `Ordering::Equal`. The 1.98 derive fast path that assumes a derived
46/// `PartialOrd` agrees with a hand-written `Ord` does not apply here — this
47/// is the case the rule was written for.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum A2aVersion {
50    /// The first A2A version with the `A2A-Version` header. Establishes the
51    /// JSON-RPC binding the protocol-neutral core already speaks, plus the
52    /// agent card well-known path.
53    V1_0,
54    /// A version published after this build, identified by its `Major.Minor`.
55    /// Carries the same `major`/`minor` as a known revision would; what
56    /// changes between a known revision and its `Unknown` peer is whether
57    /// this build's per-version codec has types for it.
58    Unknown {
59        /// The peer-named major version. `u16` because the A2A
60        /// `Major.Minor` wire form is decimal; a major number
61        /// that overflows `u16` is not a real A2A version.
62        major: u16,
63        /// The peer-named minor version.
64        minor: u16,
65    },
66}
67
68/// The `(major, minor)` pair a version carries, in a form the
69/// comparison can use. `V1_0` is `(1, 0)`; an `Unknown` is its
70/// own fields. The helper exists so the `Ord` impl is one
71/// expression, and so a future variant that adds a new field to
72/// `Unknown` (a build-flag, a build date) cannot silently change
73/// the comparison.
74const fn parts(version: A2aVersion) -> (u16, u16) {
75    match version {
76        A2aVersion::V1_0 => (1, 0),
77        A2aVersion::Unknown { major, minor } => (major, minor),
78    }
79}
80
81impl PartialOrd for A2aVersion {
82    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
83        Some(self.cmp(other))
84    }
85}
86
87impl Ord for A2aVersion {
88    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
89        parts(*self).cmp(&parts(*other))
90    }
91}
92
93impl A2aVersion {
94    /// Every revision this build has wire types for, oldest first.
95    pub const KNOWN: [Self; 1] = [Self::V1_0];
96
97    /// The canonical wire string, e.g. `"1.0"`. An `Unknown` formats
98    /// the same way the spec defines, so the on-the-wire form does
99    /// not distinguish a known revision from a future one — the
100    /// dispatcher's `VersionNotSupportedError` does.
101    pub const fn as_str(&self) -> &'static str {
102        match self {
103            Self::V1_0 => "1.0",
104            // An `Unknown` cannot return a `&'static str` because the
105            // formatted value is not a literal. Callers that need a
106            // `String` use `Display`, which formats the same way.
107            Self::Unknown { .. } => "unknown",
108        }
109    }
110
111    /// True if this version is a build-time known revision. A
112    /// `VersionNotSupportedError` is the right response to a peer
113    /// that names a `Known` revision we don't speak *yet* and an
114    /// `Unknown` we have no codec for; the dispatcher's table
115    /// decides which side of the line a given string falls on.
116    pub const fn is_known(&self) -> bool {
117        matches!(self, Self::V1_0)
118    }
119}
120
121impl fmt::Display for A2aVersion {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::V1_0 => f.write_str("1.0"),
125            Self::Unknown { major, minor } => write!(f, "{major}.{minor}"),
126        }
127    }
128}
129
130/// The wire form of an `A2aVersion` is the `Display` form: `"1.0"`,
131/// `"0.3"`, etc. A `Serialize` impl that delegates to `Display` keeps
132/// the wire form and the in-memory form in lockstep — a change to
133/// one without the other would break the protocol and the type
134/// would catch it.
135impl Serialize for A2aVersion {
136    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
137        serializer.collect_str(self)
138    }
139}
140
141/// The wire form round-trips through `FromStr`; a version that does
142/// not parse is a `ParseA2aVersionError`, which surfaces as a serde
143/// error so a malformed `protocolVersion` field is the typed answer
144/// the spec expects, not a 500.
145impl<'de> Deserialize<'de> for A2aVersion {
146    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
147        let s = String::deserialize(deserializer)?;
148        Self::from_str(&s).map_err(serde::de::Error::custom)
149    }
150}
151
152impl FromStr for A2aVersion {
153    type Err = ParseA2aVersionError;
154
155    fn from_str(s: &str) -> Result<Self, Self::Err> {
156        // The wire form is "Major.Minor"; a `+build` suffix is a
157        // recognised A2A practice but a pre-1.0 convention that this
158        // build's codec does not model. Reject anything that is not
159        // a clean two-part number.
160        let (major, minor) =
161            s.split_once('.')
162                .ok_or_else(|| ParseA2aVersionError::MissingMinor {
163                    input: s.to_owned(),
164                })?;
165        let major: u16 = major
166            .parse()
167            .map_err(|error| ParseA2aVersionError::InvalidMajor {
168                input: s.to_owned(),
169                error,
170            })?;
171        let minor: u16 = minor
172            .parse()
173            .map_err(|error| ParseA2aVersionError::InvalidMinor {
174                input: s.to_owned(),
175                error,
176            })?;
177
178        // Map a parsed (major, minor) to a known variant where one
179        // exists; otherwise hold the parsed form. The match is
180        // exhaustive on the `KNOWN` array, so adding a new
181        // revision to the array is what makes it a known variant.
182        let version = match (major, minor) {
183            (1, 0) => Self::V1_0,
184            _ => Self::Unknown { major, minor },
185        };
186        Ok(version)
187    }
188}
189
190/// Why an A2A version string failed to parse.
191///
192/// The variants are named for the *part* that was wrong, not the
193/// *string*, because a peer that says `"foo"` is making a different
194/// mistake than one that says `"1"` or `"1.x"`.
195#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
196pub enum ParseA2aVersionError {
197    /// The string had no `.` separator. The A2A wire form is always
198    /// `Major.Minor`; `1` alone is not a valid A2A-Version header.
199    #[error("A2A version `{input}` is missing the `.Minor` part")]
200    MissingMinor {
201        /// The verbatim string the peer sent, included so the
202        /// error carries the full input rather than the part
203        /// that failed.
204        input: String,
205    },
206    /// The `Major` part did not parse as a `u16`. Carries the parse
207    /// error so the caller can decide whether to fall back to a
208    /// default or surface it to the peer.
209    #[error("A2A version `{input}` has a non-numeric Major: {error}")]
210    InvalidMajor {
211        /// The verbatim string the peer sent.
212        input: String,
213        /// The parse error the `u16` parser returned. Surfaced
214        /// rather than absorbed because the consumer (the
215        /// dispatcher, an HTTP error handler) decides whether
216        /// `1.x` is a peer mistake to report or an
217        /// accept-and-default case.
218        #[source]
219        error: core::num::ParseIntError,
220    },
221    /// The `Minor` part did not parse as a `u16`. Same shape as
222    /// `InvalidMajor`; a separate variant so the error message names
223    /// the offending part.
224    #[error("A2A version `{input}` has a non-numeric Minor: {error}")]
225    InvalidMinor {
226        /// The verbatim string the peer sent.
227        input: String,
228        /// The parse error the `u16` parser returned.
229        #[source]
230        error: core::num::ParseIntError,
231    },
232}
233
234#[cfg(test)]
235mod tests {
236    use core::str::FromStr;
237
238    use super::{A2aVersion, ParseA2aVersionError};
239
240    /// `V1_0.as_str()` is the wire form
241    /// `"1.0"`. The wire form is what
242    /// crosses the `A2A-Version` header
243    /// and the `protocolVersion` field
244    /// on the agent card; a regression
245    /// that returned `"v1.0"` or
246    /// `"1.0.0"` would break the
247    /// protocol.
248    #[test]
249    fn v1_0_as_str_is_the_wire_form() {
250        assert_eq!(A2aVersion::V1_0.as_str(), "1.0");
251    }
252
253    /// `Unknown { .. }.as_str()` returns
254    /// `"unknown"`, not a formatted
255    /// `Major.Minor`. The doc comment
256    /// on `as_str` notes that
257    /// formatting an `Unknown` into a
258    /// `&'static str` is impossible —
259    /// the value is not a literal —
260    /// so the function is a debug
261    /// marker rather than a wire form.
262    /// A peer-supplied `Unknown`
263    /// version is formatted through
264    /// `Display`, not `as_str`.
265    #[test]
266    fn unknown_as_str_is_the_literal_unknown() {
267        let version = A2aVersion::Unknown { major: 2, minor: 5 };
268        assert_eq!(version.as_str(), "unknown");
269    }
270
271    /// `is_known()` is `true` for
272    /// `V1_0` and `false` for any
273    /// `Unknown`. A peer that names a
274    /// version this build does not
275    /// speak gets `Unknown`, and the
276    /// dispatcher's `KNOWN` table
277    /// decides what to do with it.
278    #[test]
279    fn is_known_matches_the_variant() {
280        assert!(A2aVersion::V1_0.is_known());
281        assert!(!A2aVersion::Unknown { major: 1, minor: 0 }.is_known());
282        assert!(!A2aVersion::Unknown { major: 2, minor: 0 }.is_known());
283    }
284
285    /// `Display` formats `V1_0` as
286    /// `"1.0"`. The wire form is the
287    /// same string the `Serialize`
288    /// impl writes.
289    #[test]
290    fn display_for_v1_0_is_dot_zero() {
291        assert_eq!(A2aVersion::V1_0.to_string(), "1.0");
292    }
293
294    /// `Display` formats an `Unknown`
295    /// as `"<major>.<minor>"`. This is
296    /// the wire form for forward-
297    /// compatible version negotiation:
298    /// a peer that names `"2.5"`
299    /// carries an `Unknown { major: 2,
300    /// minor: 5 }` whose wire form is
301    /// `"2.5"`.
302    #[test]
303    fn display_for_unknown_is_major_dot_minor() {
304        let version = A2aVersion::Unknown { major: 2, minor: 5 };
305        assert_eq!(version.to_string(), "2.5");
306    }
307
308    /// `FromStr` parses `"1.0"` as
309    /// `V1_0`. The wire form is the
310    /// exact match.
311    #[test]
312    fn from_str_parses_v1_0() -> Result<(), ParseA2aVersionError> {
313        let version = A2aVersion::from_str("1.0")?;
314        assert_eq!(version, A2aVersion::V1_0);
315        Ok(())
316    }
317
318    /// `FromStr` parses a future
319    /// version as `Unknown` with the
320    /// same `Major.Minor`. A peer that
321    /// speaks `"2.5"` (a build newer
322    /// than this one) is an `Unknown`,
323    /// not an error.
324    #[test]
325    fn from_str_parses_unknown_with_known_fields() -> Result<(), ParseA2aVersionError> {
326        let version = A2aVersion::from_str("2.5")?;
327        assert_eq!(version, A2aVersion::Unknown { major: 2, minor: 5 });
328        Ok(())
329    }
330
331    /// A version string with no `.`
332    /// separator is `MissingMinor`.
333    /// The variant names the part
334    /// that was wrong (`the `.Minor`
335    /// part`), not the string, so a
336    /// peer that sent `"1"` gets a
337    /// message about the missing
338    /// `Minor`.
339    #[test]
340    fn from_str_rejects_missing_minor_with_named_variant() -> Result<(), String> {
341        let result = A2aVersion::from_str("1");
342        match result {
343            Err(ParseA2aVersionError::MissingMinor { input }) => {
344                if input != "1" {
345                    return Err(format!("expected input '1', got {input:?}"));
346                }
347                Ok(())
348            }
349            other => Err(format!("expected MissingMinor, got {other:?}")),
350        }
351    }
352
353    /// A non-numeric `Major` is
354    /// `InvalidMajor`. The variant
355    /// carries the verbatim input and
356    /// the parse error.
357    #[test]
358    fn from_str_rejects_non_numeric_major_with_named_variant() -> Result<(), String> {
359        let result = A2aVersion::from_str("foo.bar");
360        match result {
361            Err(ParseA2aVersionError::InvalidMajor { input, .. }) => {
362                if input != "foo.bar" {
363                    return Err(format!("expected input 'foo.bar', got {input:?}"));
364                }
365                Ok(())
366            }
367            other => Err(format!("expected InvalidMajor, got {other:?}")),
368        }
369    }
370
371    /// A non-numeric `Minor` is
372    /// `InvalidMinor`. The variant
373    /// names the offending part, not
374    /// the string.
375    #[test]
376    fn from_str_rejects_non_numeric_minor_with_named_variant() -> Result<(), String> {
377        let result = A2aVersion::from_str("1.foo");
378        match result {
379            Err(ParseA2aVersionError::InvalidMinor { input, .. }) => {
380                if input != "1.foo" {
381                    return Err(format!("expected input '1.foo', got {input:?}"));
382                }
383                Ok(())
384            }
385            other => Err(format!("expected InvalidMinor, got {other:?}")),
386        }
387    }
388
389    /// Ordering is numeric, not
390    /// lexicographic. `"0.9" < "0.10"`
391    /// in the wire form is false
392    /// lexicographically, true
393    /// numerically; the `Ord` derive
394    /// must agree with the spec's
395    /// "latest minor per major" rule
396    /// (an `Unknown { major: 0, minor:
397    /// 9 }` is older than `Unknown {
398    /// major: 0, minor: 10 }`).
399    #[test]
400    fn unknown_ordering_is_numeric_not_lexicographic() {
401        let nine = A2aVersion::Unknown { major: 0, minor: 9 };
402        let ten = A2aVersion::Unknown {
403            major: 0,
404            minor: 10,
405        };
406        assert!(nine < ten, "0.9 must sort before 0.10 numerically");
407    }
408
409    /// An `Unknown { major: 1, minor: 0
410    /// }` sorts equal to `V1_0`. The
411    /// module doc promises this; a
412    /// future minor that bumps the
413    /// major without a code change
414    /// must end up at the same
415    /// ordering point. A regression
416    /// that gave `V1_0` and its
417    /// `Unknown` peer different
418    /// ordering would silently change
419    /// the dispatcher's
420    /// "best-match" table.
421    #[test]
422    fn unknown_versions_sort_with_their_known_equivalent() {
423        let known = A2aVersion::V1_0;
424        let unknown_equivalent = A2aVersion::Unknown { major: 1, minor: 0 };
425        assert_eq!(known.cmp(&unknown_equivalent), core::cmp::Ordering::Equal);
426        assert_eq!(unknown_equivalent.cmp(&known), core::cmp::Ordering::Equal);
427    }
428
429    /// `KNOWN` lists every revision
430    /// this build has wire types for,
431    /// oldest first. A future commit
432    /// that adds a new revision must
433    /// update this array; the
434    /// dispatcher's table is keyed on
435    /// it. The test pins the current
436    /// shape: one revision, `V1_0`.
437    /// When a new revision lands, this
438    /// test must change to match the
439    /// new array.
440    #[test]
441    fn known_array_lists_v1_0() {
442        assert_eq!(A2aVersion::KNOWN, [A2aVersion::V1_0]);
443    }
444}