Skip to main content

solti_model/domain/selector/
requirement.rs

1//! # Selector requirement
2//!
3//! [`SelectorRequirement`] is one constraint inside a [`LabelSelector`](crate::LabelSelector).
4//! Constructors set fields but do not validate them.
5
6use serde::{Deserialize, Serialize};
7
8use super::SelectorOperator;
9
10/// Single set-based requirement for label matching.
11///
12/// Used inside [`super::LabelSelector::match_expressions`].
13///
14/// ## Example
15///
16/// ```
17/// use solti_model::{SelectorOperator, SelectorRequirement};
18///
19/// let req = SelectorRequirement::r#in("gpu", vec!["a100".into(), "h100".into()]);
20///
21/// assert_eq!(req.key, "gpu");
22/// assert_eq!(req.operator, SelectorOperator::In);
23/// req.validate().unwrap();
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct SelectorRequirement {
28    /// Label key to evaluate.
29    pub key: String,
30    /// Comparison operator.
31    pub operator: SelectorOperator,
32    /// Values used by `In` and `NotIn`.
33    ///
34    /// This must be empty for `Exists` and `DoesNotExist`.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub values: Vec<String>,
37}
38
39#[cfg(feature = "schema")]
40impl schemars::JsonSchema for SelectorRequirement {
41    fn schema_name() -> std::borrow::Cow<'static, str> {
42        "SelectorRequirement".into()
43    }
44
45    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
46        crate::schema::selector_requirement(generator)
47    }
48}
49
50impl SelectorRequirement {
51    /// Validates the requirement.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`crate::ModelError::Invalid`].
56    ///
57    /// ## Example
58    ///
59    /// ```
60    /// use solti_model::SelectorRequirement;
61    ///
62    /// assert!(SelectorRequirement::exists("gpu").validate().is_ok());
63    /// assert!(SelectorRequirement::r#in("gpu", vec![]).validate().is_err());
64    /// ```
65    pub fn validate(&self) -> crate::error::ModelResult<()> {
66        use std::borrow::Cow;
67
68        crate::validation::validate_qualified_name("selector requirement key", &self.key)?;
69        match self.operator {
70            SelectorOperator::In | SelectorOperator::NotIn => {
71                if self.values.is_empty() {
72                    return Err(crate::ModelError::Invalid(Cow::Owned(format!(
73                        "selector requirement '{}' with operator {} must have non-empty values",
74                        self.key, self.operator,
75                    ))));
76                }
77                for value in &self.values {
78                    crate::validation::validate_label_value("selector requirement value", value)?;
79                }
80            }
81            SelectorOperator::Exists | SelectorOperator::DoesNotExist => {
82                if !self.values.is_empty() {
83                    return Err(crate::ModelError::Invalid(Cow::Owned(format!(
84                        "selector requirement '{}' with operator {} must have empty values",
85                        self.key, self.operator,
86                    ))));
87                }
88            }
89        }
90        Ok(())
91    }
92
93    /// Creates an `In` requirement.
94    ///
95    /// ## Example
96    ///
97    /// ```
98    /// use solti_model::{SelectorOperator, SelectorRequirement};
99    ///
100    /// let req = SelectorRequirement::r#in("gpu", vec!["h100".into()]);
101    /// assert_eq!(req.operator, SelectorOperator::In);
102    /// ```
103    #[inline]
104    pub fn r#in(key: impl Into<String>, values: Vec<String>) -> Self {
105        Self {
106            key: key.into(),
107            operator: SelectorOperator::In,
108            values,
109        }
110    }
111
112    /// Creates a `NotIn` requirement.
113    #[inline]
114    pub fn not_in(key: impl Into<String>, values: Vec<String>) -> Self {
115        Self {
116            key: key.into(),
117            operator: SelectorOperator::NotIn,
118            values,
119        }
120    }
121
122    /// Creates an `Exists` requirement.
123    #[inline]
124    pub fn exists(key: impl Into<String>) -> Self {
125        Self {
126            key: key.into(),
127            operator: SelectorOperator::Exists,
128            values: vec![],
129        }
130    }
131
132    /// Creates a `DoesNotExist` requirement.
133    #[inline]
134    pub fn does_not_exist(key: impl Into<String>) -> Self {
135        Self {
136            key: key.into(),
137            operator: SelectorOperator::DoesNotExist,
138            values: vec![],
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn constructors_set_operator_key_and_values() {
149        let included = SelectorRequirement::r#in("gpu", vec!["a100".into(), "h100".into()]);
150        assert_eq!(included.key, "gpu");
151        assert_eq!(included.operator, SelectorOperator::In);
152        assert_eq!(included.values, vec!["a100", "h100"]);
153
154        let excluded = SelectorRequirement::not_in("zone", vec!["us-west".into()]);
155        assert_eq!(excluded.key, "zone");
156        assert_eq!(excluded.operator, SelectorOperator::NotIn);
157        assert_eq!(excluded.values, vec!["us-west"]);
158
159        for (requirement, operator) in [
160            (SelectorRequirement::exists("gpu"), SelectorOperator::Exists),
161            (
162                SelectorRequirement::does_not_exist("tainted"),
163                SelectorOperator::DoesNotExist,
164            ),
165        ] {
166            assert_eq!(requirement.operator, operator);
167            assert!(requirement.values.is_empty());
168        }
169    }
170
171    #[test]
172    fn serde_roundtrip_and_empty_values_shape_are_stable() {
173        let req = SelectorRequirement::r#in("tier", vec!["prod".into(), "staging".into()]);
174        let json = serde_json::to_string(&req).unwrap();
175        let back: SelectorRequirement = serde_json::from_str(&json).unwrap();
176        assert_eq!(back, req);
177
178        let req = SelectorRequirement::exists("gpu");
179        let json = serde_json::to_string(&req).unwrap();
180        assert!(
181            !json.contains("values"),
182            "empty values should be skipped: {json}"
183        );
184    }
185
186    #[test]
187    fn validation_uses_kubernetes_label_rules() {
188        SelectorRequirement::r#in(
189            "workloads.example.io/class",
190            vec!["gpu_fast".into(), "".into()],
191        )
192        .validate()
193        .unwrap();
194
195        assert!(SelectorRequirement::exists("bad key").validate().is_err());
196        assert!(
197            SelectorRequirement::r#in("valid", vec!["-invalid".into()])
198                .validate()
199                .is_err()
200        );
201    }
202}