Skip to main content

api_bones/
version.rs

1//! API versioning types.
2//!
3//! [`ApiVersion`] supports three versioning schemes used in practice:
4//! - Simple integer (`v1`, `v2`, …) — internal/private APIs
5//! - Semver (`1.2.3`) — SDK-coupled APIs
6//! - Date-based (`2024-06-01`) — Stripe/Cloudflare style public APIs
7//!
8//! # Example
9//!
10//! ```rust
11//! use api_bones::version::ApiVersion;
12//! use core::str::FromStr;
13//!
14//! let v: ApiVersion = "v3".parse().unwrap();
15//! assert_eq!(v.to_string(), "v3");
16//!
17//! let v: ApiVersion = "1.2.3".parse().unwrap();
18//! assert_eq!(v.to_string(), "1.2.3");
19//!
20//! let v: ApiVersion = "2024-06-01".parse().unwrap();
21//! assert_eq!(v.to_string(), "2024-06-01");
22//! ```
23
24#[cfg(all(not(feature = "std"), feature = "alloc"))]
25use alloc::{
26    string::{String, ToString},
27    vec::Vec,
28};
29use core::fmt;
30#[cfg(any(feature = "std", feature = "alloc"))]
31use core::str::FromStr;
32#[cfg(feature = "serde")]
33use serde::{Deserialize, Serialize};
34
35// ---------------------------------------------------------------------------
36// ApiVersion
37// ---------------------------------------------------------------------------
38
39/// An API version that supports three common schemes.
40///
41/// Variants are ordered: `Simple` < `Semver` < `Date` within each variant, and
42/// by discriminant across variants (i.e. a `Simple` version is always less than
43/// a `Semver` version). Use the `PartialOrd` / `Ord` implementations for
44/// "minimum version" guards.
45#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
46#[cfg_attr(feature = "serde", derive(Serialize))]
47#[cfg_attr(feature = "serde", serde(into = "String"))]
48#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
49#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
50pub enum ApiVersion {
51    /// Integer version: `v1`, `v2`, … (stored as the bare number).
52    Simple(u32),
53    /// Semantic version: `1.2.3`.
54    Semver(SemverTriple),
55    /// Date version: `YYYY-MM-DD` (stored as `(year, month, day)`).
56    Date(u16, u8, u8),
57}
58
59/// Semantic version triple `(major, minor, patch)`.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
61#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
62#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
63#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
64pub struct SemverTriple(pub u32, pub u32, pub u32);
65
66// ---------------------------------------------------------------------------
67// Display
68// ---------------------------------------------------------------------------
69
70impl fmt::Display for SemverTriple {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "{}.{}.{}", self.0, self.1, self.2)
73    }
74}
75
76impl fmt::Display for ApiVersion {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::Simple(n) => write!(f, "v{n}"),
80            Self::Semver(t) => write!(f, "{t}"),
81            Self::Date(y, m, d) => write!(f, "{y:04}-{m:02}-{d:02}"),
82        }
83    }
84}
85
86// ---------------------------------------------------------------------------
87// Parsing
88// ---------------------------------------------------------------------------
89
90/// Error returned when an [`ApiVersion`] string cannot be parsed.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
93pub struct ApiVersionParseError(
94    #[cfg(any(feature = "std", feature = "alloc"))] pub String,
95    #[cfg(not(any(feature = "std", feature = "alloc")))] pub &'static str,
96);
97
98impl fmt::Display for ApiVersionParseError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "invalid API version: {}", self.0)
101    }
102}
103
104#[cfg(feature = "std")]
105impl std::error::Error for ApiVersionParseError {}
106
107#[cfg(any(feature = "std", feature = "alloc"))]
108impl FromStr for ApiVersion {
109    type Err = ApiVersionParseError;
110
111    /// Parse a version string.
112    ///
113    /// Accepted formats:
114    /// - `vN` or `VN` — simple integer (e.g. `v1`)
115    /// - `N.N.N` — semver (e.g. `1.2.3`)
116    /// - `YYYY-MM-DD` — date (e.g. `2024-06-01`)
117    ///
118    /// # Errors
119    ///
120    /// Returns [`ApiVersionParseError`] when `s` does not match any recognised format.
121    fn from_str(s: &str) -> Result<Self, Self::Err> {
122        // vN / VN
123        if let Some(rest) = s.strip_prefix(['v', 'V']) {
124            let n: u32 = rest.parse().map_err(|_| ApiVersionParseError(s.into()))?;
125            return Ok(Self::Simple(n));
126        }
127
128        // YYYY-MM-DD — exactly 10 chars, dashes at positions 4 and 7
129        if s.len() == 10 && s.as_bytes().get(4) == Some(&b'-') && s.as_bytes().get(7) == Some(&b'-')
130        {
131            let year: u16 = s[..4].parse().map_err(|_| ApiVersionParseError(s.into()))?;
132            let month: u8 = s[5..7]
133                .parse()
134                .map_err(|_| ApiVersionParseError(s.into()))?;
135            let day: u8 = s[8..10]
136                .parse()
137                .map_err(|_| ApiVersionParseError(s.into()))?;
138            if (1..=12).contains(&month) && (1..=31).contains(&day) {
139                return Ok(Self::Date(year, month, day));
140            }
141            return Err(ApiVersionParseError(s.into()));
142        }
143
144        // N.N.N
145        let parts: Vec<&str> = s.splitn(4, '.').collect();
146        if parts.len() == 3 {
147            let maj: u32 = parts[0]
148                .parse()
149                .map_err(|_| ApiVersionParseError(s.into()))?;
150            let min: u32 = parts[1]
151                .parse()
152                .map_err(|_| ApiVersionParseError(s.into()))?;
153            let pat: u32 = parts[2]
154                .parse()
155                .map_err(|_| ApiVersionParseError(s.into()))?;
156            return Ok(Self::Semver(SemverTriple(maj, min, pat)));
157        }
158
159        Err(ApiVersionParseError(s.into()))
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Serde: string-based serialization/deserialization
165// ---------------------------------------------------------------------------
166
167// `serde(into = "String")` requires `Into<String>`.
168#[cfg(any(feature = "std", feature = "alloc"))]
169impl From<ApiVersion> for String {
170    fn from(v: ApiVersion) -> Self {
171        v.to_string()
172    }
173}
174
175#[cfg(feature = "serde")]
176#[cfg(any(feature = "std", feature = "alloc"))]
177impl<'de> Deserialize<'de> for ApiVersion {
178    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
179        let s = String::deserialize(deserializer)?;
180        s.parse::<Self>().map_err(serde::de::Error::custom)
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Header helpers
186// ---------------------------------------------------------------------------
187
188/// Header name for the requested API version (`Accept-Version`).
189pub const ACCEPT_VERSION: &str = "Accept-Version";
190
191/// Header name for the version the response was produced with (`Content-Version`).
192pub const CONTENT_VERSION: &str = "Content-Version";
193
194impl ApiVersion {
195    /// Return the value suitable for use in an `Accept-Version` or
196    /// `Content-Version` HTTP header (the display string).
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// use api_bones::version::ApiVersion;
202    ///
203    /// let v = ApiVersion::Simple(2);
204    /// assert_eq!(v.header_value(), "v2");
205    /// ```
206    #[must_use]
207    #[cfg(any(feature = "std", feature = "alloc"))]
208    pub fn header_value(&self) -> String {
209        self.to_string()
210    }
211
212    /// Inject this version into an [`http::HeaderMap`] as `Content-Version`.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if the version string contains characters that are
217    /// invalid in HTTP header values.
218    #[cfg(feature = "http")]
219    pub fn inject_content_version(
220        &self,
221        headers: &mut http::HeaderMap,
222    ) -> Result<(), http::header::InvalidHeaderValue> {
223        #[cfg(not(feature = "std"))]
224        use alloc::string::ToString;
225        use http::header::HeaderValue;
226        let val = HeaderValue::from_str(&self.to_string())?;
227        headers.insert(
228            http::header::HeaderName::from_static("content-version"),
229            val,
230        );
231        Ok(())
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Axum extractor
237// ---------------------------------------------------------------------------
238
239/// Extracts the API version from the `X-Api-Version` header or the `v` query
240/// parameter (header takes precedence). Parses the raw string through
241/// [`ApiVersion::from_str`]; rejects with `400 Bad Request` when neither
242/// source is present or the value is not a recognised version format.
243#[cfg(all(feature = "axum", any(feature = "std", feature = "alloc")))]
244impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ApiVersion {
245    type Rejection = crate::error::ApiError;
246
247    async fn from_request_parts(
248        parts: &mut axum::http::request::Parts,
249        _state: &S,
250    ) -> Result<Self, Self::Rejection> {
251        // 1. Try X-Api-Version header
252        if let Some(val) = parts.headers.get("x-api-version") {
253            let s = val.to_str().map_err(|_| {
254                crate::error::ApiError::bad_request("header x-api-version contains non-UTF-8 bytes")
255            })?;
256            return s.parse::<Self>().map_err(|e| {
257                crate::error::ApiError::bad_request(format!("invalid X-Api-Version: {e}"))
258            });
259        }
260        // 2. Try query parameter `v`
261        if let Some(query) = parts.uri.query() {
262            for pair in query.split('&') {
263                if let Some(v) = pair.strip_prefix("v=") {
264                    return v.parse::<Self>().map_err(|e| {
265                        crate::error::ApiError::bad_request(format!("invalid v= query param: {e}"))
266                    });
267                }
268            }
269        }
270        Err(crate::error::ApiError::bad_request(
271            "missing api version: provide X-Api-Version header or v= query parameter",
272        ))
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Tests
278// ---------------------------------------------------------------------------
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn parse_simple() {
286        let v: ApiVersion = "v1".parse().unwrap();
287        assert_eq!(v, ApiVersion::Simple(1));
288        assert_eq!(v.to_string(), "v1");
289    }
290
291    #[test]
292    fn parse_simple_uppercase() {
293        let v: ApiVersion = "V42".parse().unwrap();
294        assert_eq!(v, ApiVersion::Simple(42));
295    }
296
297    #[test]
298    fn parse_semver() {
299        let v: ApiVersion = "1.2.3".parse().unwrap();
300        assert_eq!(v, ApiVersion::Semver(SemverTriple(1, 2, 3)));
301        assert_eq!(v.to_string(), "1.2.3");
302    }
303
304    #[test]
305    fn parse_date() {
306        let v: ApiVersion = "2024-06-01".parse().unwrap();
307        assert_eq!(v, ApiVersion::Date(2024, 6, 1));
308        assert_eq!(v.to_string(), "2024-06-01");
309    }
310
311    #[test]
312    fn parse_invalid() {
313        assert!("nope".parse::<ApiVersion>().is_err());
314        assert!("1.2".parse::<ApiVersion>().is_err());
315        assert!("2024-13-01".parse::<ApiVersion>().is_err());
316    }
317
318    #[test]
319    fn ordering_simple() {
320        let v1: ApiVersion = "v1".parse().unwrap();
321        let v2: ApiVersion = "v2".parse().unwrap();
322        assert!(v1 < v2);
323    }
324
325    #[test]
326    fn ordering_semver() {
327        let a: ApiVersion = "1.0.0".parse().unwrap();
328        let b: ApiVersion = "1.0.1".parse().unwrap();
329        let c: ApiVersion = "2.0.0".parse().unwrap();
330        assert!(a < b);
331        assert!(b < c);
332    }
333
334    #[test]
335    fn ordering_date() {
336        let a: ApiVersion = "2024-01-01".parse().unwrap();
337        let b: ApiVersion = "2024-06-01".parse().unwrap();
338        assert!(a < b);
339    }
340
341    #[cfg(any(feature = "std", feature = "alloc"))]
342    #[test]
343    fn header_value() {
344        let v = ApiVersion::Date(2024, 6, 1);
345        assert_eq!(v.header_value(), "2024-06-01");
346    }
347
348    #[cfg(feature = "serde")]
349    #[test]
350    fn serde_round_trip_simple() {
351        let v = ApiVersion::Simple(3);
352        // string-based serde: serialises as "v3"
353        let s = serde_json::to_value(&v).unwrap();
354        assert_eq!(s, serde_json::json!("v3"));
355        let back: ApiVersion = serde_json::from_value(s).unwrap();
356        assert_eq!(back, v);
357    }
358
359    #[cfg(feature = "serde")]
360    #[test]
361    fn serde_round_trip_semver() {
362        let v = ApiVersion::Semver(SemverTriple(1, 2, 3));
363        // string-based serde: serialises as "1.2.3"
364        let s = serde_json::to_value(&v).unwrap();
365        assert_eq!(s, serde_json::json!("1.2.3"));
366        let back: ApiVersion = serde_json::from_value(s).unwrap();
367        assert_eq!(back, v);
368    }
369
370    #[test]
371    fn semver_triple_display() {
372        let t = SemverTriple(2, 10, 0);
373        assert_eq!(t.to_string(), "2.10.0");
374    }
375
376    #[test]
377    fn api_version_parse_error_display() {
378        let err = ApiVersionParseError("bad".into());
379        let s = err.to_string();
380        assert!(s.contains("invalid API version"));
381        assert!(s.contains("bad"));
382    }
383
384    #[test]
385    fn ordering_cross_variant() {
386        // Simple < Semver < Date by discriminant ordering
387        let simple: ApiVersion = "v1".parse().unwrap();
388        let semver: ApiVersion = "1.0.0".parse().unwrap();
389        let date: ApiVersion = "2024-01-01".parse().unwrap();
390        assert!(simple < semver);
391        assert!(semver < date);
392    }
393
394    #[cfg(any(feature = "std", feature = "alloc"))]
395    #[test]
396    fn header_value_simple() {
397        let v = ApiVersion::Simple(5);
398        assert_eq!(v.header_value(), "v5");
399    }
400
401    #[cfg(any(feature = "std", feature = "alloc"))]
402    #[test]
403    fn header_value_semver() {
404        let v = ApiVersion::Semver(SemverTriple(1, 2, 3));
405        assert_eq!(v.header_value(), "1.2.3");
406    }
407
408    #[test]
409    fn parse_date_invalid_day_zero() {
410        assert!("2024-01-00".parse::<ApiVersion>().is_err());
411    }
412
413    #[test]
414    fn parse_date_invalid_month_zero() {
415        assert!("2024-00-01".parse::<ApiVersion>().is_err());
416    }
417
418    #[test]
419    fn parse_semver_bad_component() {
420        assert!("1.x.3".parse::<ApiVersion>().is_err());
421    }
422
423    #[test]
424    fn parse_simple_bad_number() {
425        assert!("vabc".parse::<ApiVersion>().is_err());
426    }
427
428    #[test]
429    fn display_date_pads_correctly() {
430        let v = ApiVersion::Date(2024, 1, 5);
431        assert_eq!(v.to_string(), "2024-01-05");
432    }
433
434    #[cfg(feature = "serde")]
435    #[test]
436    fn serde_round_trip_date() {
437        let v = ApiVersion::Date(2024, 6, 1);
438        // string-based serde: serialises as "2024-06-01"
439        let json = serde_json::to_value(&v).unwrap();
440        assert_eq!(json, serde_json::json!("2024-06-01"));
441        let back: ApiVersion = serde_json::from_value(json).unwrap();
442        assert_eq!(back, v);
443    }
444
445    #[cfg(feature = "http")]
446    #[test]
447    fn inject_content_version_header() {
448        let v = ApiVersion::Simple(3);
449        let mut headers = http::HeaderMap::new();
450        v.inject_content_version(&mut headers).unwrap();
451        assert_eq!(headers["content-version"], "v3");
452    }
453
454    #[cfg(feature = "std")]
455    #[test]
456    fn api_version_parse_error_is_std_error() {
457        // Exercises the `std::error::Error` impl (source returns None by default).
458        let err = ApiVersionParseError("oops".into());
459        let boxed: Box<dyn std::error::Error> = Box::new(err);
460        assert!(boxed.source().is_none());
461    }
462
463    #[test]
464    fn semver_triple_ordering() {
465        let a = SemverTriple(1, 0, 0);
466        let b = SemverTriple(1, 1, 0);
467        let c = SemverTriple(2, 0, 0);
468        assert!(a < b);
469        assert!(b < c);
470        assert!(a < c);
471        assert_eq!(a, SemverTriple(1, 0, 0));
472    }
473
474    #[test]
475    fn api_version_parse_error_clone_and_eq() {
476        let err = ApiVersionParseError("bad-version".into());
477        let cloned = err.clone();
478        assert_eq!(err, cloned);
479    }
480
481    #[test]
482    fn parse_date_invalid_year_non_numeric() {
483        // "abcd-01-01" matches the date pattern (len=10, dashes at 4/7) but
484        // year parse fails → exercises that error branch in from_str.
485        assert!("abcd-01-01".parse::<ApiVersion>().is_err());
486    }
487
488    #[test]
489    fn parse_date_invalid_day_non_numeric() {
490        // "2024-01-xx" — day parse fails.
491        assert!("2024-01-xx".parse::<ApiVersion>().is_err());
492    }
493
494    #[test]
495    fn parse_date_invalid_month_non_numeric() {
496        // month parse fails → exercises that map_err closure in from_str
497        assert!("2024-xx-01".parse::<ApiVersion>().is_err());
498    }
499
500    #[test]
501    fn parse_semver_bad_major() {
502        // major component non-numeric → exercises that map_err closure
503        assert!("x.1.3".parse::<ApiVersion>().is_err());
504    }
505
506    #[test]
507    fn parse_semver_bad_patch() {
508        // patch component non-numeric → exercises that map_err closure
509        assert!("1.2.x".parse::<ApiVersion>().is_err());
510    }
511
512    #[test]
513    fn parse_semver_too_many_parts() {
514        // "1.2.3.4" splits into 4 parts with splitn(4,'.')  → parts.len()==4, not 3
515        // → falls through to the final Err.
516        assert!("1.2.3.4".parse::<ApiVersion>().is_err());
517    }
518
519    #[test]
520    fn hash_semver_triple() {
521        use core::hash::{Hash, Hasher};
522        use std::collections::hash_map::DefaultHasher;
523        let mut h1 = DefaultHasher::new();
524        let mut h2 = DefaultHasher::new();
525        SemverTriple(1, 2, 3).hash(&mut h1);
526        SemverTriple(1, 2, 3).hash(&mut h2);
527        assert_eq!(h1.finish(), h2.finish());
528    }
529
530    #[test]
531    fn hash_api_version() {
532        use core::hash::{Hash, Hasher};
533        use std::collections::hash_map::DefaultHasher;
534        let mut h = DefaultHasher::new();
535        ApiVersion::Simple(1).hash(&mut h);
536        let _ = h.finish();
537    }
538
539    #[test]
540    fn api_version_in_hashset() {
541        use std::collections::HashSet;
542        let mut set = HashSet::new();
543        set.insert(ApiVersion::Simple(1));
544        set.insert(ApiVersion::Semver(SemverTriple(1, 0, 0)));
545        set.insert(ApiVersion::Date(2024, 1, 1));
546        assert_eq!(set.len(), 3);
547        assert!(set.contains(&ApiVersion::Simple(1)));
548    }
549
550    #[test]
551    fn semver_triple_in_hashset() {
552        use std::collections::HashSet;
553        let mut set = HashSet::new();
554        set.insert(SemverTriple(1, 2, 3));
555        assert!(set.contains(&SemverTriple(1, 2, 3)));
556    }
557
558    #[test]
559    fn api_version_parse_error_in_hashset() {
560        // ApiVersionParseError derives PartialEq+Eq but not Hash — just clone+eq coverage
561        let e1 = ApiVersionParseError("x".into());
562        let e2 = e1.clone();
563        assert_eq!(e1, e2);
564        assert_ne!(e1, ApiVersionParseError("y".into()));
565    }
566
567    #[test]
568    fn semver_triple_ord_cmp() {
569        use core::cmp::Ordering;
570        let a = SemverTriple(1, 0, 0);
571        let b = SemverTriple(2, 0, 0);
572        assert_eq!(a.cmp(&b), Ordering::Less);
573        assert_eq!(b.cmp(&a), Ordering::Greater);
574        assert_eq!(a.cmp(&a), Ordering::Equal);
575    }
576
577    #[test]
578    fn api_version_ord_cmp() {
579        use core::cmp::Ordering;
580        let a = ApiVersion::Simple(1);
581        let b = ApiVersion::Simple(2);
582        assert_eq!(a.cmp(&b), Ordering::Less);
583        assert_eq!(b.cmp(&a), Ordering::Greater);
584        assert_eq!(a.cmp(&a), Ordering::Equal);
585    }
586
587    #[test]
588    fn api_version_parse_error_eq() {
589        use core::cmp::PartialEq;
590        let e1 = ApiVersionParseError("a".into());
591        let e2 = ApiVersionParseError("a".into());
592        let e3 = ApiVersionParseError("b".into());
593        assert!(e1.eq(&e2));
594        assert!(!e1.eq(&e3));
595    }
596
597    #[test]
598    fn api_version_clone_all_variants() {
599        let simple = ApiVersion::Simple(1);
600        let semver = ApiVersion::Semver(SemverTriple(1, 2, 3));
601        let date = ApiVersion::Date(2024, 6, 1);
602        assert_eq!(simple.clone(), simple);
603        assert_eq!(semver.clone(), semver);
604        assert_eq!(date.clone(), date);
605    }
606
607    #[test]
608    fn semver_triple_clone_and_copy() {
609        let t = SemverTriple(1, 2, 3);
610        let cloned = t; // Copy
611        assert_eq!(t, cloned);
612        assert_eq!(t.clone(), cloned);
613    }
614
615    #[cfg(feature = "serde")]
616    #[test]
617    fn serde_round_trip_semver_triple() {
618        let t = SemverTriple(3, 14, 159);
619        let s = serde_json::to_value(t).unwrap();
620        let back: SemverTriple = serde_json::from_value(s).unwrap();
621        assert_eq!(back, t);
622    }
623
624    #[cfg(feature = "serde")]
625    #[test]
626    fn serde_parse_error_round_trip() {
627        let e = ApiVersionParseError("bad".into());
628        let s = serde_json::to_value(&e).unwrap();
629        let back: ApiVersionParseError = serde_json::from_value(s).unwrap();
630        assert_eq!(back, e);
631    }
632
633    #[cfg(feature = "serde")]
634    #[test]
635    fn serde_api_version_invalid_string_produces_error() {
636        // String that cannot be parsed as any ApiVersion variant.
637        let result: Result<ApiVersion, _> =
638            serde_json::from_value(serde_json::json!("not-a-version"));
639        assert!(result.is_err());
640        let msg = result.unwrap_err().to_string();
641        assert!(!msg.is_empty());
642    }
643
644    #[cfg(feature = "serde")]
645    #[test]
646    fn serde_api_version_non_string_is_invalid() {
647        // Deserializer now expects a string, so integer/boolean/null fail.
648        let result: Result<ApiVersion, _> = serde_json::from_value(serde_json::json!(true));
649        assert!(result.is_err());
650        let result2: Result<ApiVersion, _> = serde_json::from_value(serde_json::json!(42));
651        assert!(result2.is_err());
652    }
653
654    #[test]
655    fn display_fmt_via_format_macro() {
656        // Exercise Display::fmt for all three types through format! to ensure
657        // the fmt function is reachable via the format path (not just to_string).
658        let s = format!("{}", SemverTriple(1, 0, 0));
659        assert_eq!(s, "1.0.0");
660        let v = format!("{}", ApiVersion::Simple(7));
661        assert_eq!(v, "v7");
662        let e = format!("{}", ApiVersionParseError("x".into()));
663        assert!(e.contains("invalid API version"));
664    }
665
666    #[test]
667    fn display_fmt_direct_write() {
668        use core::fmt::Write;
669        let mut buf = String::new();
670        write!(buf, "{}", SemverTriple(2, 3, 4)).unwrap();
671        assert_eq!(buf, "2.3.4");
672        buf.clear();
673        write!(buf, "{}", ApiVersion::Semver(SemverTriple(0, 1, 0))).unwrap();
674        assert_eq!(buf, "0.1.0");
675        buf.clear();
676        write!(buf, "{}", ApiVersionParseError("z".into())).unwrap();
677        assert!(buf.contains('z'));
678    }
679}