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}