Skip to main content

solti_model/domain/
label.rs

1//! # Labels
2//!
3//! [`Labels`] is a key-sorted map with Kubernetes label validation.
4//! Insertion and direct deserialization do not validate entries.
5//! Call [`Labels::validate`] at an input boundary.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{ModelResult, validation};
12
13/// Structured key-value metadata based on [`BTreeMap`].
14///
15/// Iteration order is stable because labels are stored in key order.
16///
17/// ## Example
18///
19/// ```
20/// use solti_model::Labels;
21///
22/// let mut labels = Labels::new();
23/// labels.insert("zone", "eu");
24/// labels.insert("gpu", "h100");
25///
26/// assert_eq!(labels.get("zone"), Some("eu"));
27/// assert!(labels.contains_key("gpu"));
28/// ```
29#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[cfg_attr(feature = "schema", schemars(schema_with = "crate::schema::labels"))]
32#[serde(transparent)]
33pub struct Labels(BTreeMap<String, String>);
34
35impl Labels {
36    /// Creates an empty label map.
37    ///
38    /// ## Example
39    ///
40    /// ```
41    /// use solti_model::Labels;
42    ///
43    /// let labels = Labels::new();
44    /// assert!(labels.is_empty());
45    /// ```
46    #[inline]
47    pub fn new() -> Self {
48        Self(BTreeMap::new())
49    }
50
51    /// Returns the number of labels.
52    #[inline]
53    pub fn len(&self) -> usize {
54        self.0.len()
55    }
56
57    /// Returns whether the map is empty.
58    #[inline]
59    pub fn is_empty(&self) -> bool {
60        self.0.is_empty()
61    }
62
63    /// Inserts or replaces a label.
64    ///
65    /// ## Example
66    ///
67    /// ```
68    /// use solti_model::Labels;
69    ///
70    /// let mut labels = Labels::new();
71    /// labels.insert("tier", "dev").insert("tier", "prod");
72    ///
73    /// assert_eq!(labels.get("tier"), Some("prod"));
74    /// ```
75    #[inline]
76    pub fn insert<K, V>(&mut self, key: K, val: V) -> &mut Self
77    where
78        K: Into<String>,
79        V: Into<String>,
80    {
81        self.0.insert(key.into(), val.into());
82        self
83    }
84
85    /// Returns the value for a key.
86    #[inline]
87    pub fn get(&self, key: &str) -> Option<&str> {
88        self.0.get(key).map(|s| s.as_str())
89    }
90
91    /// Returns whether a key exists.
92    #[inline]
93    pub fn contains_key(&self, key: &str) -> bool {
94        self.0.contains_key(key)
95    }
96
97    /// Iterates over labels in key order.
98    ///
99    /// ## Example
100    ///
101    /// ```
102    /// use solti_model::Labels;
103    ///
104    /// let mut labels = Labels::new();
105    /// labels.insert("b", "2");
106    /// labels.insert("a", "1");
107    ///
108    /// let pairs: Vec<_> = labels.iter().collect();
109    /// assert_eq!(pairs, vec![("a", "1"), ("b", "2")]);
110    /// ```
111    #[inline]
112    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
113        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
114    }
115
116    /// Validates every label.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`crate::ModelError::Invalid`].
121    pub fn validate(&self) -> ModelResult<()> {
122        for (key, value) in &self.0 {
123            validation::validate_qualified_name("label key", key)?;
124            validation::validate_label_value("label value", value)?;
125        }
126        Ok(())
127    }
128}
129
130impl<'a> IntoIterator for &'a Labels {
131    type Item = (&'a str, &'a str);
132    type IntoIter = LabelsIter<'a>;
133
134    #[inline]
135    fn into_iter(self) -> Self::IntoIter {
136        LabelsIter(self.0.iter())
137    }
138}
139
140/// Iterator over `Labels` yielding `(&str, &str)` pairs.
141pub struct LabelsIter<'a>(std::collections::btree_map::Iter<'a, String, String>);
142
143impl<'a> Iterator for LabelsIter<'a> {
144    type Item = (&'a str, &'a str);
145
146    #[inline]
147    fn next(&mut self) -> Option<Self::Item> {
148        self.0.next().map(|(k, v)| (k.as_str(), v.as_str()))
149    }
150
151    #[inline]
152    fn size_hint(&self) -> (usize, Option<usize>) {
153        self.0.size_hint()
154    }
155}
156
157impl ExactSizeIterator for LabelsIter<'_> {}
158
159#[cfg(test)]
160mod tests {
161    use super::Labels;
162
163    #[test]
164    fn insertion_lookup_overwrite_and_iteration_are_deterministic() {
165        let mut labels = Labels::new();
166        assert!(labels.is_empty());
167        assert_eq!(labels.len(), 0);
168
169        labels.insert("z", "last").insert("a", "first");
170        labels.insert("env", "dev");
171        labels.insert("env", "prod");
172
173        assert_eq!(labels.len(), 3);
174        assert_eq!(labels.get("env"), Some("prod"));
175        assert_eq!(
176            labels.iter().collect::<Vec<_>>(),
177            vec![("a", "first"), ("env", "prod"), ("z", "last")]
178        );
179    }
180
181    #[test]
182    fn serde_is_transparent() {
183        let mut labels = Labels::new();
184        labels.insert("runner-tag", "prod");
185
186        let json = serde_json::to_string(&labels).unwrap();
187        assert_eq!(json, r#"{"runner-tag":"prod"}"#);
188        let back: Labels = serde_json::from_str(&json).unwrap();
189        assert_eq!(back, labels);
190    }
191
192    #[test]
193    fn validation_uses_kubernetes_label_rules() {
194        let mut labels = Labels::new();
195        labels
196            .insert("app.kubernetes.io/name", "solti_agent-1")
197            .insert("empty", "");
198        labels.validate().unwrap();
199
200        let mut invalid_key = Labels::new();
201        invalid_key.insert("example.io/bad key", "value");
202        assert!(invalid_key.validate().is_err());
203
204        let mut invalid_value = Labels::new();
205        invalid_value.insert("example.io/name", "-value");
206        assert!(invalid_value.validate().is_err());
207    }
208}