Skip to main content

t2t_core/serde/
schema.rs

1//! The JSON schema of each spelling, inlined, since each is one string.
2
3use alloc::borrow::Cow;
4
5use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
6
7use crate::{
8    BootUptime, RawUptime, TaiTimestamp, TickRate, Tickdelta, Tickstamp, Timedelta, Timestamp,
9    Uptime,
10};
11
12/// The pattern of a span's spelling: `0`, or counts with units, each at most once and coarsest
13/// first, behind a lookahead for a count.
14const SPAN_PATTERN: &str =
15    "^(0|-?(?=[0-9])([0-9]+d)?([0-9]+h)?([0-9]+m)?([0-9]+s)?([0-9]+ms)?([0-9]+us)?([0-9]+ns)?)$";
16
17/// The pattern of RFC 3339's date and time, a fraction of one to nine digits or none, then `$zone`.
18macro_rules! date_time_pattern {
19    ($zone:literal) => {
20        concat!(
21            "^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,9})?",
22            $zone,
23            "$"
24        )
25    };
26}
27
28/// The pattern of a count of ticks: an integer, then ` ticks`.
29const TICKS_PATTERN: &str = "^-?[0-9]+ ticks$";
30
31/// The schema of a `$type` written as its spelling: a string, described and patterned.
32macro_rules! spelled_schema {
33    ($type:ident, $description:literal, $pattern:expr $(, format = $format:literal)? $(,)?) => {
34        impl JsonSchema for $type {
35            fn schema_name() -> Cow<'static, str> {
36                Cow::Borrowed(stringify!($type))
37            }
38
39            fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
40                json_schema!({
41                    "type": "string",
42                    $("format": $format,)?
43                    "description": $description,
44                    "pattern": $pattern,
45                })
46            }
47
48            fn inline_schema() -> bool {
49                true
50            }
51        }
52    };
53}
54
55spelled_schema!(
56    Timestamp,
57    "An RFC 3339 instant in UTC, with a fraction of one to nine digits or none, as \"2026-09-16T07:45:35Z\".",
58    date_time_pattern!("[Zz]"),
59    format = "date-time",
60);
61spelled_schema!(
62    TaiTimestamp,
63    "An RFC 3339 date and time in TAI, with a fraction of one to nine digits or none, as \"2026-09-16T07:46:12 TAI\".",
64    date_time_pattern!(" TAI"),
65);
66spelled_schema!(Uptime, "The span since the monotonic clock's origin, as \"1m30s\".", SPAN_PATTERN);
67spelled_schema!(
68    RawUptime,
69    "The span since the raw monotonic clock's origin, as \"1m30s\".",
70    SPAN_PATTERN,
71);
72spelled_schema!(BootUptime, "The span since boot, as \"1m30s\".", SPAN_PATTERN);
73spelled_schema!(
74    Tickstamp,
75    "A counter's reading, a count of ticks, as \"24 ticks\".",
76    TICKS_PATTERN
77);
78spelled_schema!(
79    Timedelta,
80    "A signed span: \"0\", or counts with units, each at most once and coarsest first, of d, h, m, s, ms, us, ns, as \"1m30s\".",
81    SPAN_PATTERN,
82);
83spelled_schema!(Tickdelta, "A signed count of ticks, as \"24 ticks\".", TICKS_PATTERN);
84spelled_schema!(
85    TickRate,
86    "A counter's rate, a count of hertz above zero, as \"24000000 Hz\".",
87    "^[0-9]*[1-9][0-9]* Hz$",
88);
89
90#[cfg(test)]
91mod tests {
92    use core::str::FromStr;
93
94    use fancy_regex::Regex;
95    use rstest::rstest;
96    use schemars::{JsonSchema, schema_for};
97
98    use crate::{
99        BootUptime, RawUptime, TaiTimestamp, TickRate, Tickdelta, Tickstamp, Timedelta, Timestamp,
100        Uptime,
101    };
102
103    /// The pattern of `T`'s schema.
104    fn pattern<T: JsonSchema>() -> Regex {
105        let schema = schema_for!(T);
106        let pattern = schema.get("pattern").and_then(|pattern| pattern.as_str());
107        // fancy-regex reads the lookahead a span's pattern holds, which the `regex` crate refuses.
108        Regex::new(pattern.expect("a pattern")).expect("a pattern fancy-regex reads")
109    }
110
111    /// Asserts that `T`'s schema takes `text` as `T`'s parser does.
112    fn assert_agree<T: JsonSchema + FromStr>(text: &str) {
113        let takes = pattern::<T>().is_match(text).expect("a match that ends");
114        assert_eq!(
115            takes,
116            text.parse::<T>().is_ok(),
117            "the pattern takes {text:?} as the parser does"
118        );
119    }
120
121    // A count past the range is the parser's alone to refuse.
122    #[rstest]
123    #[case::zero("0")]
124    #[case::zero_seconds("0s")]
125    #[case::every_unit("1d2h3m4s5ms6us7ns")]
126    #[case::a_gap_inside("1m0s123ms")]
127    #[case::backwards("-3us")]
128    #[case::a_count_past_the_next_unit("1000ms")]
129    #[case::empty("")]
130    #[case::a_sign_alone("-")]
131    #[case::a_backwards_zero("-0")]
132    #[case::no_unit("1")]
133    #[case::no_count("s")]
134    #[case::a_unit_twice("1m1m")]
135    #[case::finest_first("1s1m")]
136    #[case::spaced("5 minutes")]
137    #[case::an_unknown_unit("1msec")]
138    fn the_span_pattern_takes_what_the_parser_reads(#[case] text: &str) {
139        assert_agree::<Timedelta>(text);
140        assert_agree::<Uptime>(text);
141        assert_agree::<RawUptime>(text);
142        assert_agree::<BootUptime>(text);
143    }
144
145    // A date that does not exist, or one past the range, is the parser's alone to refuse.
146    #[rstest]
147    #[case::nanoseconds("2026-09-16T07:45:35.123456789Z")]
148    #[case::either_case("1970-01-01t00:00:00z")]
149    #[case::one_fraction_digit("1970-01-01T00:00:00.5Z")]
150    #[case::in_tai("2026-09-16T07:46:12.123456789 TAI")]
151    #[case::a_date_alone("2026-09-16")]
152    #[case::no_zone("2026-09-16T07:45:35")]
153    #[case::an_offset("2026-09-16T07:45:35+01:00")]
154    #[case::trailing_text("2026-09-16T07:45:35Z ")]
155    #[case::a_point_without_digits("2026-09-16T07:45:35.Z")]
156    #[case::ten_fraction_digits("2026-09-16T07:45:35.1234567890Z")]
157    #[case::a_letter_for_a_digit("2026-09-1xT07:45:35Z")]
158    #[case::tai_in_lower_case("2026-09-16T07:46:12 tai")]
159    fn the_instant_patterns_take_what_their_parsers_read(#[case] text: &str) {
160        assert_agree::<Timestamp>(text);
161        assert_agree::<TaiTimestamp>(text);
162    }
163
164    // A count past the range is the parser's alone to refuse.
165    #[rstest]
166    #[case::ticks("24 ticks")]
167    #[case::backwards("-24 ticks")]
168    #[case::hertz("24000000 Hz")]
169    #[case::leading_zeros("0024 Hz")]
170    #[case::zero_hertz("0 Hz")]
171    #[case::no_unit("24")]
172    #[case::a_sign_display_never_writes("+24 ticks")]
173    #[case::one_tick("1 tick")]
174    #[case::kilohertz("24 kHz")]
175    fn the_count_patterns_take_what_their_parsers_read(#[case] text: &str) {
176        assert_agree::<Tickstamp>(text);
177        assert_agree::<Tickdelta>(text);
178        assert_agree::<TickRate>(text);
179    }
180}