Skip to main content

timeseries_table_format/metadata/
index.rs

1//! Ordered-index metadata and validation.
2
3use std::{cmp::Ordering, collections::HashSet, fmt, num::NonZeroU64, str::FromStr};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use snafu::prelude::*;
8
9/// Errors produced when parsing a human-friendly time index granularity (e.g. `1h`).
10#[derive(Debug, Snafu, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum ParseTimeIndexGranularityError {
13    /// The spec string was empty or only whitespace.
14    #[snafu(display("time index granularity is empty"))]
15    Empty,
16
17    /// The spec did not include a numeric value.
18    #[snafu(display("time index granularity '{spec}' is missing a numeric value"))]
19    MissingNumber {
20        /// The original spec string.
21        spec: String,
22    },
23
24    /// The spec did not include a required unit suffix.
25    #[snafu(display(
26        "time index granularity '{spec}' is missing a unit suffix (expected s|m|h|d)"
27    ))]
28    MissingUnit {
29        /// The original spec string.
30        spec: String,
31    },
32
33    /// The numeric portion of the spec failed to parse.
34    #[snafu(display("invalid index granularity value in '{spec}': {source}"))]
35    InvalidNumber {
36        /// The original spec string.
37        spec: String,
38        /// The parse error returned by `u64::from_str`.
39        source: std::num::ParseIntError,
40    },
41
42    /// The parsed numeric value was zero.
43    #[snafu(display("index granularity value must be > 0 (got {value}) in '{spec}'"))]
44    NonPositive {
45        /// The original spec string.
46        spec: String,
47        /// The parsed numeric value.
48        value: u64,
49    },
50
51    /// The parsed numeric value did not fit in a `u32`.
52    #[snafu(display("index granularity value too large for u32 (got {value}) in '{spec}'"))]
53    TooLarge {
54        /// The original spec string.
55        spec: String,
56        /// The parsed numeric value.
57        value: u64,
58    },
59
60    /// The spec used an unsupported unit suffix.
61    #[snafu(display(
62        "unknown time index granularity unit '{unit}' in '{spec}' (expected s|m|h|d)"
63    ))]
64    UnknownUnit {
65        /// The original spec string.
66        spec: String,
67        /// The unrecognized unit suffix.
68        unit: String,
69    },
70}
71
72/// Time interval size used by coverage bitmap logic.
73///
74/// This does not affect physical storage directly, but describes how the time
75/// axis is discretized when building coverage bitmaps and computing gaps.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub enum TimeIndexGranularity {
78    /// A fixed number of seconds.
79    Seconds(u32),
80    /// A fixed number of minutes.
81    Minutes(u32),
82    /// A fixed number of hours.
83    Hours(u32),
84    /// A fixed number of days.
85    Days(u32),
86}
87
88impl FromStr for TimeIndexGranularity {
89    type Err = ParseTimeIndexGranularityError;
90
91    fn from_str(input: &str) -> Result<Self, Self::Err> {
92        let spec = input.trim();
93        if spec.is_empty() {
94            return Err(ParseTimeIndexGranularityError::Empty);
95        }
96
97        // Split into numeric prefix + unit suffix (unit starts at first alphabetic char).
98        let unit_start = spec
99            .char_indices()
100            .find(|(_, c)| c.is_ascii_alphabetic())
101            .map(|(i, _)| i);
102
103        let Some(unit_start) = unit_start else {
104            return Err(ParseTimeIndexGranularityError::MissingUnit {
105                spec: spec.to_string(),
106            });
107        };
108
109        if unit_start == 0 {
110            // No leading digits (e.g. "h")
111            return Err(ParseTimeIndexGranularityError::MissingNumber {
112                spec: spec.to_string(),
113            });
114        }
115
116        let (num_str, unit_str) = spec.split_at(unit_start);
117        let num_str = num_str.trim();
118        let unit_str = unit_str.trim();
119
120        if unit_str.is_empty() {
121            return Err(ParseTimeIndexGranularityError::MissingUnit {
122                spec: spec.to_string(),
123            });
124        }
125
126        let value: u64 =
127            num_str
128                .parse()
129                .map_err(|source| ParseTimeIndexGranularityError::InvalidNumber {
130                    spec: spec.to_string(),
131                    source,
132                })?;
133
134        if value == 0 {
135            return Err(ParseTimeIndexGranularityError::NonPositive {
136                spec: spec.to_string(),
137                value,
138            });
139        }
140
141        if value > u32::MAX as u64 {
142            return Err(ParseTimeIndexGranularityError::TooLarge {
143                spec: spec.to_string(),
144                value,
145            });
146        }
147
148        let v = value as u32;
149        let unit = unit_str.to_ascii_lowercase();
150
151        match unit.as_str() {
152            "s" | "sec" | "secs" | "second" | "seconds" => Ok(TimeIndexGranularity::Seconds(v)),
153            "m" | "min" | "mins" | "minute" | "minutes" => Ok(TimeIndexGranularity::Minutes(v)),
154            "h" | "hr" | "hrs" | "hour" | "hours" => Ok(TimeIndexGranularity::Hours(v)),
155            "d" | "day" | "days" => Ok(TimeIndexGranularity::Days(v)),
156            _ => Err(ParseTimeIndexGranularityError::UnknownUnit {
157                spec: spec.to_string(),
158                unit: unit_str.to_string(),
159            }),
160        }
161    }
162}
163
164impl TimeIndexGranularity {
165    /// Parse a human-friendly time index granularity (e.g. `1h`, `15m`, `30s`, `2d`).
166    ///
167    /// This is a convenience wrapper around `str::parse` for [`TimeIndexGranularity`], and
168    /// accepts common unit aliases (e.g. `sec`, `min`, `hr`, `day`).
169    ///
170    /// # Errors
171    /// Returns [`ParseTimeIndexGranularityError`] if the spec is empty, missing a unit,
172    /// has an invalid or non-positive number, overflows `u32`, or uses an
173    /// unsupported unit.
174    pub fn parse(spec: &str) -> Result<Self, ParseTimeIndexGranularityError> {
175        spec.parse()
176    }
177}
178
179/// Canonical ordered-index configuration for a time-series table.
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
181#[serde(deny_unknown_fields)]
182pub struct IndexSpec {
183    /// Name of the single ordered index column.
184    pub column: String,
185
186    /// Optional ordered entity columns used for entity-scoped coverage.
187    #[serde(default)]
188    pub entity_columns: Vec<String>,
189
190    /// Ordered value domain and index granularity configuration.
191    pub kind: IndexKind,
192}
193
194impl IndexSpec {
195    /// Validate structural invariants that do not require a logical schema.
196    ///
197    /// # Errors
198    /// Returns [`IndexSpecError`] for an empty index column, an empty entity
199    /// column, a duplicate entity column, or an entity column that is also the
200    /// ordered index.
201    pub fn validate(&self) -> Result<(), IndexSpecError> {
202        if self.column.is_empty() {
203            return Err(IndexSpecError::EmptyColumn);
204        }
205
206        let mut seen = HashSet::with_capacity(self.entity_columns.len());
207        for (position, column) in self.entity_columns.iter().enumerate() {
208            if column.is_empty() {
209                return Err(IndexSpecError::EmptyEntityColumn { position });
210            }
211            if column == &self.column {
212                return Err(IndexSpecError::EntityColumnMatchesIndex {
213                    column: column.clone(),
214                });
215            }
216            if !seen.insert(column) {
217                return Err(IndexSpecError::DuplicateEntityColumn {
218                    column: column.clone(),
219                });
220            }
221        }
222
223        self.kind.validate()
224    }
225}
226
227/// Ordered value domain and its index granularity configuration.
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
230pub enum IndexKind {
231    /// Timestamp index with a fixed granularity and optional timezone metadata.
232    Timestamp {
233        /// Logical index interval size.
234        index_granularity: TimeIndexGranularity,
235        /// Optional IANA timezone identifier.
236        #[serde(default, skip_serializing_if = "Option::is_none")]
237        timezone: Option<String>,
238    },
239    /// Signed 64-bit integer index.
240    Int64 {
241        /// Positive index granularity in index-value units.
242        index_granularity: NonZeroU64,
243    },
244    /// Unsigned 64-bit integer index.
245    #[serde(rename = "uint64")]
246    UInt64 {
247        /// Positive index granularity in index-value units.
248        index_granularity: NonZeroU64,
249    },
250}
251
252impl IndexKind {
253    /// Stable user-facing domain name.
254    pub fn name(&self) -> &'static str {
255        match self {
256            Self::Timestamp { .. } => "timestamp",
257            Self::Int64 { .. } => "int64",
258            Self::UInt64 { .. } => "uint64",
259        }
260    }
261
262    /// Validate index granularity not enforced by the Rust type system.
263    pub fn validate(&self) -> Result<(), IndexSpecError> {
264        if let Self::Timestamp {
265            index_granularity, ..
266        } = self
267        {
268            let width = match index_granularity {
269                TimeIndexGranularity::Seconds(width)
270                | TimeIndexGranularity::Minutes(width)
271                | TimeIndexGranularity::Hours(width)
272                | TimeIndexGranularity::Days(width) => *width,
273            };
274            if width == 0 {
275                return Err(IndexSpecError::ZeroTimeIndexGranularity);
276            }
277        }
278        Ok(())
279    }
280}
281
282/// A value in one of the supported ordered-index domains.
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284#[serde(
285    tag = "type",
286    content = "value",
287    rename_all = "snake_case",
288    deny_unknown_fields
289)]
290pub enum IndexValue {
291    /// UTC timestamp value.
292    Timestamp(DateTime<Utc>),
293    /// Signed 64-bit integer value.
294    Int64(i64),
295    /// Unsigned 64-bit integer value.
296    UInt64(u64),
297}
298
299impl IndexValue {
300    /// Stable user-facing domain name.
301    pub fn kind_name(&self) -> &'static str {
302        match self {
303            Self::Timestamp(_) => "timestamp",
304            Self::Int64(_) => "int64",
305            Self::UInt64(_) => "uint64",
306        }
307    }
308
309    /// Compare two values in the same ordered domain.
310    ///
311    /// # Errors
312    /// Returns [`IndexValueError::DomainMismatch`] for cross-domain values.
313    pub fn compare(&self, other: &Self) -> Result<Ordering, IndexValueError> {
314        match (self, other) {
315            (Self::Timestamp(left), Self::Timestamp(right)) => Ok(left.cmp(right)),
316            (Self::Int64(left), Self::Int64(right)) => Ok(left.cmp(right)),
317            (Self::UInt64(left), Self::UInt64(right)) => Ok(left.cmp(right)),
318            _ => Err(IndexValueError::DomainMismatch {
319                left: self.kind_name(),
320                right: other.kind_name(),
321            }),
322        }
323    }
324
325    /// Validate that this value belongs to `kind`.
326    ///
327    /// # Errors
328    /// Returns [`IndexValueError::KindMismatch`] when the domains differ.
329    pub fn validate_kind(&self, kind: &IndexKind) -> Result<(), IndexValueError> {
330        if self.kind_name() == kind.name() {
331            Ok(())
332        } else {
333            Err(IndexValueError::KindMismatch {
334                expected: kind.name(),
335                actual: self.kind_name(),
336            })
337        }
338    }
339}
340
341impl fmt::Display for IndexValue {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        match self {
344            Self::Timestamp(value) => write!(f, "timestamp({value})"),
345            Self::Int64(value) => write!(f, "int64({value})"),
346            Self::UInt64(value) => write!(f, "uint64({value})"),
347        }
348    }
349}
350
351impl From<DateTime<Utc>> for IndexValue {
352    fn from(value: DateTime<Utc>) -> Self {
353        Self::Timestamp(value)
354    }
355}
356
357impl From<i64> for IndexValue {
358    fn from(value: i64) -> Self {
359        Self::Int64(value)
360    }
361}
362
363impl From<u64> for IndexValue {
364    fn from(value: u64) -> Self {
365        Self::UInt64(value)
366    }
367}
368
369/// Validate a public half-open ordered-index range.
370///
371/// # Errors
372/// Returns [`IndexValueError`] when a bound has the wrong domain, the bounds
373/// use different domains, or `start >= end`.
374pub fn validate_index_range(
375    kind: &IndexKind,
376    start: &IndexValue,
377    end: &IndexValue,
378) -> Result<(), IndexValueError> {
379    start.validate_kind(kind)?;
380    end.validate_kind(kind)?;
381    if start.compare(end)? != Ordering::Less {
382        return Err(IndexValueError::InvalidRange {
383            start: start.clone(),
384            end: end.clone(),
385        });
386    }
387    Ok(())
388}
389
390/// Structural errors in an [`IndexSpec`].
391#[derive(Debug, Snafu, PartialEq, Eq)]
392#[non_exhaustive]
393pub enum IndexSpecError {
394    /// The registered index column is empty.
395    #[snafu(display("ordered index column is empty"))]
396    EmptyColumn,
397    /// An entity column is empty.
398    #[snafu(display("entity column at position {position} is empty"))]
399    EmptyEntityColumn {
400        /// Zero-based position of the empty entity column.
401        position: usize,
402    },
403    /// An entity column is repeated.
404    #[snafu(display("duplicate entity column: {column}"))]
405    DuplicateEntityColumn {
406        /// Repeated entity column name.
407        column: String,
408    },
409    /// An entity column is also the ordered index column.
410    #[snafu(display("entity column cannot also be the ordered index column: {column}"))]
411    EntityColumnMatchesIndex {
412        /// Conflicting column name.
413        column: String,
414    },
415    /// A timestamp index granularity was constructed directly with a zero width.
416    #[snafu(display("timestamp index granularity must be nonzero"))]
417    ZeroTimeIndexGranularity,
418}
419
420/// Domain and range errors for [`IndexValue`].
421#[derive(Debug, Snafu, PartialEq, Eq)]
422#[non_exhaustive]
423pub enum IndexValueError {
424    /// Two values use different ordered domains.
425    #[snafu(display("ordered index domain mismatch: left={left}, right={right}"))]
426    DomainMismatch {
427        /// Left value domain.
428        left: &'static str,
429        /// Right value domain.
430        right: &'static str,
431    },
432    /// A value does not match the table's registered domain.
433    #[snafu(display("ordered index kind mismatch: expected {expected}, found {actual}"))]
434    KindMismatch {
435        /// Registered domain.
436        expected: &'static str,
437        /// Supplied value domain.
438        actual: &'static str,
439    },
440    /// A half-open range is empty or reversed.
441    #[snafu(display(
442        "invalid ordered index range: start={start}, end={end} (expected start < end)"
443    ))]
444    InvalidRange {
445        /// Inclusive lower bound.
446        start: IndexValue,
447        /// Exclusive upper bound.
448        end: IndexValue,
449    },
450    /// Inclusive segment bounds are reversed.
451    #[snafu(display("invalid ordered index bounds: min={min}, max={max} (expected min <= max)"))]
452    InvalidBounds {
453        /// Inclusive observed minimum.
454        min: IndexValue,
455        /// Inclusive observed maximum.
456        max: IndexValue,
457    },
458}
459
460#[cfg(test)]
461mod tests {
462    use chrono::TimeZone;
463
464    use super::*;
465
466    fn sample_time_index_spec() -> IndexSpec {
467        IndexSpec {
468            column: "ts".to_string(),
469            entity_columns: vec!["symbol".to_string()],
470            kind: IndexKind::Timestamp {
471                index_granularity: TimeIndexGranularity::Minutes(1),
472                timezone: None,
473            },
474        }
475    }
476
477    #[test]
478    fn index_spec_json_roundtrips_all_domains() {
479        let cases = [
480            (
481                sample_time_index_spec(),
482                serde_json::json!({
483                    "column": "ts",
484                    "entity_columns": ["symbol"],
485                    "kind": {
486                        "type": "timestamp",
487                        "index_granularity": {"Minutes": 1}
488                    }
489                }),
490            ),
491            (
492                IndexSpec {
493                    column: "sequence".to_string(),
494                    entity_columns: Vec::new(),
495                    kind: IndexKind::Int64 {
496                        index_granularity: NonZeroU64::new(u64::MAX).unwrap(),
497                    },
498                },
499                serde_json::json!({
500                    "column": "sequence",
501                    "entity_columns": [],
502                    "kind": {
503                        "type": "int64",
504                        "index_granularity": u64::MAX
505                    }
506                }),
507            ),
508            (
509                IndexSpec {
510                    column: "offset".to_string(),
511                    entity_columns: vec!["source".to_string()],
512                    kind: IndexKind::UInt64 {
513                        index_granularity: NonZeroU64::new(7).unwrap(),
514                    },
515                },
516                serde_json::json!({
517                    "column": "offset",
518                    "entity_columns": ["source"],
519                    "kind": {
520                        "type": "uint64",
521                        "index_granularity": 7
522                    }
523                }),
524            ),
525        ];
526
527        for (spec, expected_json) in cases {
528            let json = serde_json::to_value(&spec).unwrap();
529            assert_eq!(json, expected_json);
530            let restored: IndexSpec = serde_json::from_value(json).unwrap();
531            assert_eq!(restored, spec);
532        }
533    }
534
535    #[test]
536    fn index_spec_json_rejects_impossible_field_combinations() {
537        let timestamp_with_integer_granularity = r#"{
538            "column":"ts","kind":{"type":"timestamp","index_granularity":1}
539        }"#;
540        let integer_with_time_granularity_object = r#"{
541            "column":"id","kind":{"type":"int64","index_granularity":{"Seconds":1}}
542        }"#;
543        let zero_integer_granularity =
544            r#"{"column":"id","kind":{"type":"uint64","index_granularity":0}}"#;
545
546        assert!(serde_json::from_str::<IndexSpec>(timestamp_with_integer_granularity).is_err());
547        assert!(serde_json::from_str::<IndexSpec>(integer_with_time_granularity_object).is_err());
548        assert!(serde_json::from_str::<IndexSpec>(zero_integer_granularity).is_err());
549    }
550
551    #[test]
552    fn index_spec_validation_rejects_invalid_structure_and_time_granularity() {
553        let mut spec = sample_time_index_spec();
554        spec.column.clear();
555        assert_eq!(spec.validate(), Err(IndexSpecError::EmptyColumn));
556
557        let mut spec = sample_time_index_spec();
558        spec.entity_columns.push("symbol".to_string());
559        assert!(matches!(
560            spec.validate(),
561            Err(IndexSpecError::DuplicateEntityColumn { .. })
562        ));
563
564        let mut spec = sample_time_index_spec();
565        spec.entity_columns = vec![spec.column.clone()];
566        assert_eq!(
567            spec.validate(),
568            Err(IndexSpecError::EntityColumnMatchesIndex {
569                column: "ts".to_string(),
570            })
571        );
572
573        let mut spec = sample_time_index_spec();
574        spec.kind = IndexKind::Timestamp {
575            index_granularity: TimeIndexGranularity::Seconds(0),
576            timezone: None,
577        };
578        assert_eq!(
579            spec.validate(),
580            Err(IndexSpecError::ZeroTimeIndexGranularity)
581        );
582    }
583
584    #[test]
585    fn index_value_roundtrips_and_compares_integer_extremes() {
586        let timestamp = Utc.timestamp_opt(1, 987_654_321).single().unwrap();
587        let values = [
588            IndexValue::Timestamp(timestamp),
589            IndexValue::Int64(i64::MIN),
590            IndexValue::Int64(i64::MAX),
591            IndexValue::UInt64(0),
592            IndexValue::UInt64(u64::MAX),
593        ];
594
595        for value in values {
596            let json = serde_json::to_string(&value).unwrap();
597            assert_eq!(serde_json::from_str::<IndexValue>(&json).unwrap(), value);
598            assert_eq!(value.compare(&value).unwrap(), Ordering::Equal);
599        }
600        assert_eq!(
601            IndexValue::Int64(i64::MIN)
602                .compare(&IndexValue::Int64(i64::MAX))
603                .unwrap(),
604            Ordering::Less
605        );
606        assert_eq!(
607            IndexValue::UInt64(u64::MAX)
608                .compare(&IndexValue::UInt64(0))
609                .unwrap(),
610            Ordering::Greater
611        );
612    }
613
614    #[test]
615    fn index_value_cross_domain_comparison_and_ranges_are_typed_errors() {
616        assert_eq!(
617            IndexValue::Int64(0).compare(&IndexValue::UInt64(0)),
618            Err(IndexValueError::DomainMismatch {
619                left: "int64",
620                right: "uint64"
621            })
622        );
623
624        let kind = IndexKind::UInt64 {
625            index_granularity: NonZeroU64::new(1).unwrap(),
626        };
627        assert!(matches!(
628            validate_index_range(&kind, &IndexValue::Int64(0), &IndexValue::Int64(1)),
629            Err(IndexValueError::KindMismatch { .. })
630        ));
631        assert!(matches!(
632            validate_index_range(&kind, &IndexValue::UInt64(1), &IndexValue::UInt64(1)),
633            Err(IndexValueError::InvalidRange { .. })
634        ));
635    }
636
637    #[test]
638    fn time_index_granularity_parse_accepts_basic_units() {
639        let cases = [
640            ("1s", TimeIndexGranularity::Seconds(1)),
641            ("2m", TimeIndexGranularity::Minutes(2)),
642            ("3h", TimeIndexGranularity::Hours(3)),
643            ("4d", TimeIndexGranularity::Days(4)),
644        ];
645
646        for (input, expected) in cases {
647            assert_eq!(input.parse::<TimeIndexGranularity>().unwrap(), expected);
648        }
649    }
650
651    #[test]
652    fn time_index_granularity_parse_accepts_aliases_case_and_whitespace() {
653        let cases = [
654            ("1sec", TimeIndexGranularity::Seconds(1)),
655            ("1secs", TimeIndexGranularity::Seconds(1)),
656            ("1second", TimeIndexGranularity::Seconds(1)),
657            ("1seconds", TimeIndexGranularity::Seconds(1)),
658            ("1min", TimeIndexGranularity::Minutes(1)),
659            ("1mins", TimeIndexGranularity::Minutes(1)),
660            ("1minute", TimeIndexGranularity::Minutes(1)),
661            ("1minutes", TimeIndexGranularity::Minutes(1)),
662            ("1hr", TimeIndexGranularity::Hours(1)),
663            ("1hrs", TimeIndexGranularity::Hours(1)),
664            ("1hour", TimeIndexGranularity::Hours(1)),
665            ("1hours", TimeIndexGranularity::Hours(1)),
666            ("1day", TimeIndexGranularity::Days(1)),
667            ("1days", TimeIndexGranularity::Days(1)),
668            ("1H", TimeIndexGranularity::Hours(1)),
669            ("1MiN", TimeIndexGranularity::Minutes(1)),
670            ("  2h", TimeIndexGranularity::Hours(2)),
671            ("3d  ", TimeIndexGranularity::Days(3)),
672            ("  4m  ", TimeIndexGranularity::Minutes(4)),
673            ("1 h", TimeIndexGranularity::Hours(1)),
674        ];
675
676        for (input, expected) in cases {
677            assert_eq!(input.parse::<TimeIndexGranularity>().unwrap(), expected);
678        }
679    }
680
681    #[test]
682    fn time_index_granularity_parse_rejects_empty_or_whitespace() {
683        let cases = ["", "   ", "\n\t"];
684        for input in cases {
685            let err = input.parse::<TimeIndexGranularity>().unwrap_err();
686            assert!(matches!(err, ParseTimeIndexGranularityError::Empty));
687        }
688    }
689
690    #[test]
691    fn time_index_granularity_parse_rejects_missing_number() {
692        let cases = ["h", " hr", "day", "abcmin"];
693        for input in cases {
694            let err = input.parse::<TimeIndexGranularity>().unwrap_err();
695            assert!(
696                matches!(err, ParseTimeIndexGranularityError::MissingNumber { .. }),
697                "expected MissingNumber for {input:?}, got {err:?}"
698            );
699        }
700    }
701
702    #[test]
703    fn time_index_granularity_parse_rejects_missing_unit() {
704        let cases = ["1", "  42  "];
705        for input in cases {
706            let err = input.parse::<TimeIndexGranularity>().unwrap_err();
707            assert!(
708                matches!(err, ParseTimeIndexGranularityError::MissingUnit { .. }),
709                "expected MissingUnit for {input:?}, got {err:?}"
710            );
711        }
712    }
713
714    #[test]
715    fn time_index_granularity_parse_rejects_invalid_number() {
716        let cases = ["1.5h", "1_000s"];
717        for input in cases {
718            let err = input.parse::<TimeIndexGranularity>().unwrap_err();
719            assert!(
720                matches!(err, ParseTimeIndexGranularityError::InvalidNumber { .. }),
721                "expected InvalidNumber for {input:?}, got {err:?}"
722            );
723        }
724    }
725
726    #[test]
727    fn time_index_granularity_parse_rejects_non_positive() {
728        let cases = ["0s", "0m"];
729        for input in cases {
730            let err = input.parse::<TimeIndexGranularity>().unwrap_err();
731            assert!(
732                matches!(
733                    err,
734                    ParseTimeIndexGranularityError::NonPositive { value: 0, .. }
735                ),
736                "expected NonPositive for {input:?}, got {err:?}"
737            );
738        }
739    }
740
741    #[test]
742    fn time_index_granularity_parse_rejects_too_large() {
743        let too_large = (u32::MAX as u64 + 1).to_string();
744        let input = format!("{too_large}h");
745        let err = input.parse::<TimeIndexGranularity>().unwrap_err();
746        assert!(
747            matches!(err, ParseTimeIndexGranularityError::TooLarge { value, .. } if value == u32::MAX as u64 + 1),
748            "expected TooLarge for {input:?}, got {err:?}"
749        );
750    }
751
752    #[test]
753    fn time_index_granularity_parse_rejects_unknown_units() {
754        let cases = ["1w", "1ms", "1mo", "10msec"];
755        for input in cases {
756            let err = input.parse::<TimeIndexGranularity>().unwrap_err();
757            assert!(
758                matches!(err, ParseTimeIndexGranularityError::UnknownUnit { .. }),
759                "expected UnknownUnit for {input:?}, got {err:?}"
760            );
761        }
762    }
763
764    #[test]
765    fn time_index_granularity_parse_matches_from_str() {
766        let via_method = TimeIndexGranularity::parse("5m").unwrap();
767        let via_trait: TimeIndexGranularity = "5m".parse().unwrap();
768        assert_eq!(via_method, via_trait);
769    }
770}