Skip to main content

solti_model/domain/selector/
requirement.rs

1//! # Selector requirement.
2//!
3//! [`SelectorRequirement`] is a single label constraint used in [`RunnerSelector`](crate::RunnerSelector).
4
5use serde::{Deserialize, Serialize};
6
7use super::SelectorOperator;
8
9/// Single set-based requirement for label matching.
10///
11/// ```text
12///  { key: "gpu", operator: In, values: ["a100", "h100"] } ⇒ runner must have label gpu ∈ {"a100", "h100"}
13///
14///  { key: "zone", operator: Exists, values: [] } ⇒ runner must have label  zone  (any value)
15/// ```
16///
17/// Used inside [`super::RunnerSelector::match_expressions`].
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct SelectorRequirement {
21    /// Label key to evaluate.
22    pub key: String,
23    /// Comparison operator.
24    pub operator: SelectorOperator,
25    /// Values for `In` / `NotIn`.
26    /// Must be empty for `Exists` / `DoesNotExist`.
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub values: Vec<String>,
29}
30
31impl SelectorRequirement {
32    /// Validate structural invariants.
33    ///
34    /// - `key` must not be empty
35    /// - `In`/`NotIn` must have non-empty `values`
36    /// - `Exists`/`DoesNotExist` must have empty `values`
37    pub fn validate(&self) -> crate::error::ModelResult<()> {
38        use std::borrow::Cow;
39
40        if self.key.is_empty() {
41            return Err(crate::ModelError::Invalid(Cow::Borrowed(
42                "selector requirement key must not be empty",
43            )));
44        }
45        match self.operator {
46            SelectorOperator::In | SelectorOperator::NotIn => {
47                if self.values.is_empty() {
48                    return Err(crate::ModelError::Invalid(Cow::Owned(format!(
49                        "selector requirement '{}' with operator {} must have non-empty values",
50                        self.key, self.operator,
51                    ))));
52                }
53            }
54            SelectorOperator::Exists | SelectorOperator::DoesNotExist => {
55                if !self.values.is_empty() {
56                    return Err(crate::ModelError::Invalid(Cow::Owned(format!(
57                        "selector requirement '{}' with operator {} must have empty values",
58                        self.key, self.operator,
59                    ))));
60                }
61            }
62        }
63        Ok(())
64    }
65
66    /// Shorthand: require `key ∈ values`.
67    #[inline]
68    pub fn r#in(key: impl Into<String>, values: Vec<String>) -> Self {
69        Self {
70            key: key.into(),
71            operator: SelectorOperator::In,
72            values,
73        }
74    }
75
76    /// Shorthand: require `key ∉ values`.
77    #[inline]
78    pub fn not_in(key: impl Into<String>, values: Vec<String>) -> Self {
79        Self {
80            key: key.into(),
81            operator: SelectorOperator::NotIn,
82            values,
83        }
84    }
85
86    /// Shorthand: require label key to exist.
87    #[inline]
88    pub fn exists(key: impl Into<String>) -> Self {
89        Self {
90            key: key.into(),
91            operator: SelectorOperator::Exists,
92            values: vec![],
93        }
94    }
95
96    /// Shorthand: require label key to NOT exist.
97    #[inline]
98    pub fn does_not_exist(key: impl Into<String>) -> Self {
99        Self {
100            key: key.into(),
101            operator: SelectorOperator::DoesNotExist,
102            values: vec![],
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn in_constructor() {
113        let req = SelectorRequirement::r#in("gpu", vec!["a100".into(), "h100".into()]);
114        assert_eq!(req.key, "gpu");
115        assert_eq!(req.operator, SelectorOperator::In);
116        assert_eq!(req.values, vec!["a100", "h100"]);
117    }
118
119    #[test]
120    fn not_in_constructor() {
121        let req = SelectorRequirement::not_in("zone", vec!["us-west".into()]);
122        assert_eq!(req.operator, SelectorOperator::NotIn);
123    }
124
125    #[test]
126    fn exists_constructor() {
127        let req = SelectorRequirement::exists("gpu");
128        assert_eq!(req.operator, SelectorOperator::Exists);
129        assert!(req.values.is_empty());
130    }
131
132    #[test]
133    fn does_not_exist_constructor() {
134        let req = SelectorRequirement::does_not_exist("tainted");
135        assert_eq!(req.operator, SelectorOperator::DoesNotExist);
136        assert!(req.values.is_empty());
137    }
138
139    #[test]
140    fn serde_roundtrip() {
141        let req = SelectorRequirement::r#in("tier", vec!["prod".into(), "staging".into()]);
142        let json = serde_json::to_string(&req).unwrap();
143        let back: SelectorRequirement = serde_json::from_str(&json).unwrap();
144        assert_eq!(back, req);
145    }
146
147    #[test]
148    fn serde_skips_empty_values() {
149        let req = SelectorRequirement::exists("gpu");
150        let json = serde_json::to_string(&req).unwrap();
151        assert!(
152            !json.contains("values"),
153            "empty values should be skipped: {json}"
154        );
155    }
156}