Skip to main content

obs_types/
cardinality.rs

1//! [`Cardinality`] — bound on the number of distinct values a field admits.
2
3use std::str::FromStr;
4
5use buffa::Enumeration;
6use serde::{Deserialize, Serialize};
7
8use crate::UnknownVariant;
9
10/// Bound on the number of distinct values a field admits at runtime.
11///
12/// Drives lint L001 (LABEL fields must be Low or Medium) and L005 (enum
13/// variant count must fit the cap). See [10-data-model.md § 4](
14/// ../../specs/10-data-model.md#4-field-roles).
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[repr(i32)]
18#[non_exhaustive]
19pub enum Cardinality {
20    /// Never appears in a well-formed schema.
21    #[default]
22    Unspecified = 0,
23    /// `< 10` distinct values (status, boolean).
24    Low = 1,
25    /// `< 10_000` distinct values (route, tenant).
26    Medium = 2,
27    /// `< 1_000_000` distinct values (user_id) — illegal for LABEL.
28    High = 3,
29    /// Open / unbounded — illegal for LABEL and MEASUREMENT.
30    Unbounded = 4,
31}
32
33impl Cardinality {
34    /// Stable string label.
35    #[must_use]
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Self::Unspecified => "unspecified",
39            Self::Low => "low",
40            Self::Medium => "medium",
41            Self::High => "high",
42            Self::Unbounded => "unbounded",
43        }
44    }
45
46    /// Numeric cap: the maximum distinct value count permitted at this level.
47    /// Returns `u64::MAX` for `Unbounded`. Used by lint L005 (variant count).
48    ///
49    /// `Unspecified` returns 0 — a schema that did not declare cardinality
50    /// should fail the cap check.
51    #[must_use]
52    pub const fn cap(self) -> u64 {
53        match self {
54            Self::Unspecified => 0,
55            Self::Low => 10,
56            Self::Medium => 10_000,
57            Self::High => 1_000_000,
58            Self::Unbounded => u64::MAX,
59        }
60    }
61
62    /// True if this cardinality is permitted on a `FieldKind::Label` field.
63    /// Labels become metric/span dimensions; only Low and Medium are safe.
64    #[must_use]
65    pub const fn is_label_compatible(self) -> bool {
66        matches!(self, Self::Low | Self::Medium)
67    }
68
69    /// True if this cardinality is permitted on a `FieldKind::Measurement`
70    /// field. `Unbounded` is illegal — measurement values come from the
71    /// numeric type, not from the cardinality.
72    #[must_use]
73    pub const fn is_measurement_compatible(self) -> bool {
74        !matches!(self, Self::Unbounded)
75    }
76}
77
78impl Enumeration for Cardinality {
79    fn from_i32(value: i32) -> Option<Self> {
80        match value {
81            0 => Some(Self::Unspecified),
82            1 => Some(Self::Low),
83            2 => Some(Self::Medium),
84            3 => Some(Self::High),
85            4 => Some(Self::Unbounded),
86            _ => None,
87        }
88    }
89
90    fn to_i32(&self) -> i32 {
91        *self as i32
92    }
93
94    fn proto_name(&self) -> &'static str {
95        match self {
96            Self::Unspecified => "CARDINALITY_UNSPECIFIED",
97            Self::Low => "LOW",
98            Self::Medium => "MEDIUM",
99            Self::High => "HIGH",
100            Self::Unbounded => "UNBOUNDED",
101        }
102    }
103
104    fn from_proto_name(name: &str) -> Option<Self> {
105        match name {
106            "CARDINALITY_UNSPECIFIED" => Some(Self::Unspecified),
107            "LOW" => Some(Self::Low),
108            "MEDIUM" => Some(Self::Medium),
109            "HIGH" => Some(Self::High),
110            "UNBOUNDED" => Some(Self::Unbounded),
111            _ => None,
112        }
113    }
114
115    fn values() -> &'static [Self] {
116        &[
117            Self::Unspecified,
118            Self::Low,
119            Self::Medium,
120            Self::High,
121            Self::Unbounded,
122        ]
123    }
124}
125
126impl FromStr for Cardinality {
127    type Err = UnknownVariant;
128
129    fn from_str(s: &str) -> Result<Self, Self::Err> {
130        match s.to_ascii_lowercase().as_str() {
131            "low" => Ok(Self::Low),
132            "medium" => Ok(Self::Medium),
133            "high" => Ok(Self::High),
134            "unbounded" => Ok(Self::Unbounded),
135            _ => Err(UnknownVariant {
136                kind: "Cardinality",
137                value: s.to_string(),
138            }),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_should_round_trip_via_i32() {
149        for v in Cardinality::values() {
150            assert_eq!(Cardinality::from_i32(v.to_i32()), Some(*v));
151        }
152    }
153
154    #[test]
155    fn test_should_enforce_label_compatibility() {
156        assert!(Cardinality::Low.is_label_compatible());
157        assert!(Cardinality::Medium.is_label_compatible());
158        assert!(!Cardinality::High.is_label_compatible());
159        assert!(!Cardinality::Unbounded.is_label_compatible());
160    }
161
162    #[test]
163    fn test_should_compute_caps() {
164        assert_eq!(Cardinality::Low.cap(), 10);
165        assert_eq!(Cardinality::Medium.cap(), 10_000);
166        assert_eq!(Cardinality::High.cap(), 1_000_000);
167        assert_eq!(Cardinality::Unbounded.cap(), u64::MAX);
168    }
169}