Skip to main content

nmbrs_metrics/
selector.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Component lookup by label predicate — see SRD 24.
5//!
6//! A [`Selector`] is a conjunction of label clauses:
7//!
8//! | Operator | Meaning                          | Example        |
9//! |----------|----------------------------------|----------------|
10//! | `=`      | exact match                      | `phase=rampup` |
11//! | `!=`     | exact non-match                  | `phase!=teardown` |
12//! | `~=`     | glob / wildcard match            | `profile~=label_*` |
13//! | `?`      | label must be present (any value)| `profile?`     |
14//! | `!?`     | label must be absent             | `profile!?`    |
15//!
16//! Multiple clauses combine with AND. A selector with no clauses
17//! matches every label set (vacuously true). Callers needing a
18//! union of two selectors issue two queries and merge results —
19//! the grammar deliberately excludes OR and nesting.
20//!
21//! The same type drives four consumers: dynamic controls
22//! (SRD 23), metrics selection (SRD 42), component-tree
23//! structural queries, and scripted orchestration. Placement
24//! is in `nmbrs-metrics` alongside [`Labels`] so every consumer's
25//! existing dependency picks it up without a new crate.
26
27use std::fmt;
28
29use crate::labels::Labels;
30
31// =========================================================================
32// Public API
33// =========================================================================
34
35/// Label predicate. Construct via [`Selector::new`] and the
36/// chainable builder methods, or parse from text with
37/// [`Selector::parse`]. Evaluate against any [`Labels`] with
38/// [`Selector::matches`].
39#[derive(Clone, Debug, Default, PartialEq, Eq)]
40pub struct Selector {
41    clauses: Vec<Clause>,
42}
43
44/// One label-valued constraint in a [`Selector`]. Private so the
45/// grammar stays closed — callers add clauses via the `Selector`
46/// builder methods, which is the full public API.
47#[derive(Clone, Debug, PartialEq, Eq)]
48enum Clause {
49    /// `key = value` — label must be present with exact value.
50    Eq(String, String),
51    /// `key != value` — label absent OR present with a different
52    /// value. Absence-as-non-match is intentional; see SRD 24
53    /// §"Matching rules".
54    Ne(String, String),
55    /// `key ~= glob` — label present, value matches the glob.
56    Glob(String, GlobPattern),
57    /// `key?` — label present (any value).
58    Present(String),
59    /// `key!?` — label absent.
60    Absent(String),
61}
62
63/// Error returned by [`Selector::parse`]. Wraps a human-facing
64/// message; callers render with `Display` / `to_string`.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct SelectorParseError {
67    message: String,
68}
69
70impl fmt::Display for SelectorParseError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(&self.message)
73    }
74}
75
76impl std::error::Error for SelectorParseError {}
77
78impl Selector {
79    /// Empty selector — matches every label set.
80    pub fn new() -> Self {
81        Self {
82            clauses: Vec::new(),
83        }
84    }
85
86    /// Parse the text form:
87    /// `type=phase,name=rampup,profile~=label_*,optimize_for=RECALL`.
88    /// Whitespace around commas and operators is tolerated.
89    pub fn parse(s: &str) -> Result<Self, SelectorParseError> {
90        let mut sel = Self::new();
91        for raw in s.split(',') {
92            let clause = raw.trim();
93            if clause.is_empty() {
94                continue;
95            }
96            sel.clauses.push(parse_clause(clause)?);
97        }
98        Ok(sel)
99    }
100
101    /// Push `key = value` — exact match clause.
102    pub fn eq(mut self, key: &str, value: &str) -> Self {
103        self.clauses
104            .push(Clause::Eq(key.to_string(), value.to_string()));
105        self
106    }
107
108    /// Push `key != value` — non-match (or absent) clause.
109    pub fn ne(mut self, key: &str, value: &str) -> Self {
110        self.clauses
111            .push(Clause::Ne(key.to_string(), value.to_string()));
112        self
113    }
114
115    /// Push `key ~= pattern` — glob-match clause.
116    pub fn glob(mut self, key: &str, pattern: &str) -> Self {
117        self.clauses
118            .push(Clause::Glob(key.to_string(), GlobPattern::new(pattern)));
119        self
120    }
121
122    /// Push `key?` — presence clause.
123    pub fn present(mut self, key: &str) -> Self {
124        self.clauses.push(Clause::Present(key.to_string()));
125        self
126    }
127
128    /// Push `key!?` — absence clause.
129    pub fn absent(mut self, key: &str) -> Self {
130        self.clauses.push(Clause::Absent(key.to_string()));
131        self
132    }
133
134    /// True if every clause matches. An empty selector matches
135    /// every label set.
136    pub fn matches(&self, labels: &Labels) -> bool {
137        self.clauses.iter().all(|c| c.matches(labels))
138    }
139
140    /// Number of clauses in the selector.
141    pub fn len(&self) -> usize {
142        self.clauses.len()
143    }
144
145    /// True if the selector carries no clauses.
146    pub fn is_empty(&self) -> bool {
147        self.clauses.is_empty()
148    }
149}
150
151impl fmt::Display for Selector {
152    /// Round-trippable text form. Whitespace omitted so the
153    /// output is canonical: `parse(selector.to_string())` yields
154    /// a selector equal to the original.
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        let mut first = true;
157        for clause in &self.clauses {
158            if !first {
159                f.write_str(",")?;
160            }
161            first = false;
162            match clause {
163                Clause::Eq(k, v) => write!(f, "{k}={v}")?,
164                Clause::Ne(k, v) => write!(f, "{k}!={v}")?,
165                Clause::Glob(k, g) => write!(f, "{k}~={}", g.pattern)?,
166                Clause::Present(k) => write!(f, "{k}?")?,
167                Clause::Absent(k) => write!(f, "{k}!?")?,
168            }
169        }
170        Ok(())
171    }
172}
173
174impl Clause {
175    fn matches(&self, labels: &Labels) -> bool {
176        match self {
177            Self::Eq(k, v) => labels.get(k) == Some(v.as_str()),
178            Self::Ne(k, v) => labels.get(k) != Some(v.as_str()),
179            Self::Glob(k, g) => labels.get(k).is_some_and(|s| g.matches(s)),
180            Self::Present(k) => labels.get(k).is_some(),
181            Self::Absent(k) => labels.get(k).is_none(),
182        }
183    }
184}
185
186// =========================================================================
187// Glob matching
188// =========================================================================
189
190/// `fnmatch`-style glob — `*` matches any sequence (including
191/// empty), `?` matches exactly one character. Everything else
192/// matches literally. No character classes, no escapes, no regex.
193#[derive(Clone, Debug, PartialEq, Eq)]
194struct GlobPattern {
195    pattern: String,
196}
197
198impl GlobPattern {
199    fn new(pattern: &str) -> Self {
200        Self {
201            pattern: pattern.to_string(),
202        }
203    }
204
205    fn matches(&self, s: &str) -> bool {
206        glob_matches(&self.pattern, s)
207    }
208}
209
210/// Two-pointer glob match with `*` backtracking. O(n·m) worst
211/// case (pathological alternating patterns), O(n+m) typical.
212fn glob_matches(pattern: &str, s: &str) -> bool {
213    let p: Vec<char> = pattern.chars().collect();
214    let t: Vec<char> = s.chars().collect();
215    let (mut pi, mut ti) = (0usize, 0usize);
216    let mut star_pi: Option<usize> = None;
217    let mut star_ti: usize = 0;
218    while ti < t.len() {
219        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
220            pi += 1;
221            ti += 1;
222        } else if pi < p.len() && p[pi] == '*' {
223            star_pi = Some(pi);
224            star_ti = ti;
225            pi += 1;
226        } else if let Some(sp) = star_pi {
227            pi = sp + 1;
228            star_ti += 1;
229            ti = star_ti;
230        } else {
231            return false;
232        }
233    }
234    while pi < p.len() && p[pi] == '*' {
235        pi += 1;
236    }
237    pi == p.len()
238}
239
240// =========================================================================
241// Text-form parser
242// =========================================================================
243
244fn parse_clause(clause: &str) -> Result<Clause, SelectorParseError> {
245    // Operators checked in order of *longest* match first so
246    // e.g. `!?` doesn't get misread as `?`, `!=` doesn't get
247    // misread as `=`. Order:
248    //   suffix `!?`   →  absent
249    //   suffix `?`    →  present
250    //   infix  `~=`   →  glob
251    //   infix  `!=`   →  not-equal
252    //   infix  `=`    →  equal
253    if let Some(key) = clause.strip_suffix("!?") {
254        let key = key.trim();
255        validate_key(key)?;
256        return Ok(Clause::Absent(key.to_string()));
257    }
258    if let Some(key) = clause.strip_suffix('?') {
259        let key = key.trim();
260        validate_key(key)?;
261        return Ok(Clause::Present(key.to_string()));
262    }
263    if let Some(idx) = clause.find("~=") {
264        let (k, rest) = clause.split_at(idx);
265        let v = &rest[2..];
266        let k = k.trim();
267        let v = v.trim();
268        validate_key(k)?;
269        return Ok(Clause::Glob(k.to_string(), GlobPattern::new(v)));
270    }
271    if let Some(idx) = clause.find("!=") {
272        let (k, rest) = clause.split_at(idx);
273        let v = &rest[2..];
274        let k = k.trim();
275        let v = v.trim();
276        validate_key(k)?;
277        return Ok(Clause::Ne(k.to_string(), v.to_string()));
278    }
279    if let Some(idx) = clause.find('=') {
280        let (k, rest) = clause.split_at(idx);
281        let v = &rest[1..];
282        let k = k.trim();
283        let v = v.trim();
284        validate_key(k)?;
285        return Ok(Clause::Eq(k.to_string(), v.to_string()));
286    }
287    Err(SelectorParseError {
288        message: format!("clause '{clause}': no operator (expected one of =, !=, ~=, ?, !?)"),
289    })
290}
291
292fn validate_key(key: &str) -> Result<(), SelectorParseError> {
293    if key.is_empty() {
294        return Err(SelectorParseError {
295            message: "empty label key".to_string(),
296        });
297    }
298    // Permissive: any non-whitespace character is valid. Stricter
299    // validation (e.g. matching Prometheus label names) would
300    // exclude perfectly usable keys and isn't needed — the labels
301    // system itself doesn't enforce a stricter rule either.
302    if key.chars().any(char::is_whitespace) {
303        return Err(SelectorParseError {
304            message: format!("label key '{key}' contains whitespace"),
305        });
306    }
307    Ok(())
308}
309
310// =========================================================================
311// Lookup errors
312// =========================================================================
313
314/// Error returned by [`crate::component::Component::find_one`].
315#[derive(Clone, Debug, PartialEq, Eq)]
316pub enum LookupError {
317    /// Zero components matched the selector.
318    NotFound,
319    /// More than one component matched. The count is the total
320    /// number of matches, including the first.
321    Ambiguous { count: usize },
322}
323
324impl fmt::Display for LookupError {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        match self {
327            Self::NotFound => f.write_str("no components matched selector"),
328            Self::Ambiguous { count } => {
329                write!(f, "selector matched {count} components, expected exactly 1")
330            }
331        }
332    }
333}
334
335impl std::error::Error for LookupError {}
336
337// =========================================================================
338// `selector!` macro
339// =========================================================================
340
341/// Compile-time-expanded `Selector` builder. Equivalent to a
342/// sequence of `.eq(key, value)` calls:
343///
344/// ```ignore
345/// let s = selector!(phase = "ann_query", k = "100");
346/// // expands to:
347/// let s = Selector::new().eq("phase", "ann_query").eq("k", "100");
348/// ```
349///
350/// Only `=` is supported — callers needing `!=`, `~=`, `?`, or
351/// `!?` use the builder API directly or [`Selector::parse`].
352#[macro_export]
353macro_rules! selector {
354    ($($key:tt = $value:expr),* $(,)?) => {
355        {
356            let mut __sel = $crate::selector::Selector::new();
357            $( __sel = __sel.eq(stringify!($key), $value); )*
358            __sel
359        }
360    };
361}
362
363// =========================================================================
364// Tests
365// =========================================================================
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn lbls(pairs: &[(&str, &str)]) -> Labels {
372        let mut l = Labels::empty();
373        for (k, v) in pairs {
374            l = l.with(*k, *v);
375        }
376        l
377    }
378
379    // ---- glob matching ------------------------------------------
380
381    #[test]
382    fn glob_empty_pattern_matches_only_empty() {
383        assert!(glob_matches("", ""));
384        assert!(!glob_matches("", "a"));
385    }
386
387    #[test]
388    fn glob_literal_exact() {
389        assert!(glob_matches("rampup", "rampup"));
390        assert!(!glob_matches("rampup", "ramp"));
391        assert!(!glob_matches("rampup", "rampups"));
392    }
393
394    #[test]
395    fn glob_star_only_matches_anything() {
396        assert!(glob_matches("*", ""));
397        assert!(glob_matches("*", "anything"));
398        assert!(glob_matches("*", "with spaces and $pecial_chars"));
399    }
400
401    #[test]
402    fn glob_star_prefix() {
403        assert!(glob_matches("*_00", "label_00"));
404        assert!(glob_matches("*_00", "_00"));
405        assert!(!glob_matches("*_00", "label_01"));
406    }
407
408    #[test]
409    fn glob_star_suffix() {
410        assert!(glob_matches("label_*", "label_00"));
411        assert!(glob_matches("label_*", "label_"));
412        assert!(!glob_matches("label_*", "other"));
413    }
414
415    #[test]
416    fn glob_star_middle() {
417        assert!(glob_matches("label_*_tail", "label_x_tail"));
418        assert!(glob_matches("label_*_tail", "label__tail"));
419        assert!(!glob_matches("label_*_tail", "label_tail"));
420    }
421
422    #[test]
423    fn glob_question_single_char() {
424        assert!(glob_matches("label_?", "label_0"));
425        assert!(glob_matches("label_?", "label_z"));
426        assert!(!glob_matches("label_?", "label_00"));
427        assert!(!glob_matches("label_?", "label_"));
428    }
429
430    #[test]
431    fn glob_multiple_stars_backtrack() {
432        assert!(glob_matches("*a*b*", "xyzaXYZb"));
433        assert!(glob_matches("*a*b*", "ab"));
434        assert!(!glob_matches("*a*b*", "ba"));
435    }
436
437    #[test]
438    fn glob_unicode_chars() {
439        assert!(glob_matches("*_μ", "prefix_μ"));
440        assert!(glob_matches("?_x", "λ_x"));
441    }
442
443    // ---- clause matching against labels ------------------------
444
445    #[test]
446    fn eq_matches_exact_value() {
447        let l = lbls(&[("phase", "rampup")]);
448        assert!(Selector::new().eq("phase", "rampup").matches(&l));
449        assert!(!Selector::new().eq("phase", "teardown").matches(&l));
450    }
451
452    #[test]
453    fn eq_misses_on_absent_key() {
454        let l = lbls(&[("name", "x")]);
455        assert!(!Selector::new().eq("phase", "rampup").matches(&l));
456    }
457
458    #[test]
459    fn ne_matches_absent_key_by_design() {
460        // SRD 24: absence counts as non-match for `!=`, not as an
461        // error. Captures scenarios like "every component except
462        // those explicitly tagged `skip=true`".
463        let l = lbls(&[("name", "x")]);
464        assert!(Selector::new().ne("skip", "true").matches(&l));
465    }
466
467    #[test]
468    fn ne_matches_differing_value() {
469        let l = lbls(&[("phase", "rampup")]);
470        assert!(Selector::new().ne("phase", "teardown").matches(&l));
471        assert!(!Selector::new().ne("phase", "rampup").matches(&l));
472    }
473
474    #[test]
475    fn glob_clause_matches_value() {
476        let l = lbls(&[("profile", "label_03")]);
477        assert!(Selector::new().glob("profile", "label_*").matches(&l));
478        assert!(Selector::new().glob("profile", "label_0?").matches(&l));
479        assert!(!Selector::new().glob("profile", "other_*").matches(&l));
480    }
481
482    #[test]
483    fn glob_clause_misses_on_absent_key() {
484        let l = lbls(&[("name", "x")]);
485        assert!(!Selector::new().glob("profile", "*").matches(&l));
486    }
487
488    #[test]
489    fn present_and_absent_clauses() {
490        let l = lbls(&[("profile", "label_00")]);
491        assert!(Selector::new().present("profile").matches(&l));
492        assert!(!Selector::new().present("missing").matches(&l));
493        assert!(Selector::new().absent("missing").matches(&l));
494        assert!(!Selector::new().absent("profile").matches(&l));
495    }
496
497    #[test]
498    fn empty_value_is_present_not_absent() {
499        // An explicitly set empty-string value counts as present.
500        // This distinction matters for users who use an empty
501        // value as a sentinel (rare but legal).
502        let l = lbls(&[("tag", "")]);
503        assert!(Selector::new().present("tag").matches(&l));
504        assert!(!Selector::new().absent("tag").matches(&l));
505        assert!(Selector::new().eq("tag", "").matches(&l));
506    }
507
508    // ---- selector conjunction ----------------------------------
509
510    #[test]
511    fn empty_selector_matches_everything() {
512        assert!(Selector::new().matches(&Labels::empty()));
513        assert!(Selector::new().matches(&lbls(&[("a", "b"), ("c", "d")])));
514    }
515
516    #[test]
517    fn multi_clause_conjunction() {
518        let l = lbls(&[("phase", "rampup"), ("k", "10"), ("profile", "label_00")]);
519        let sel = Selector::new()
520            .eq("phase", "rampup")
521            .eq("k", "10")
522            .glob("profile", "label_*");
523        assert!(sel.matches(&l));
524
525        // Any single clause failing breaks the AND.
526        let miss = Selector::new().eq("phase", "rampup").eq("k", "999");
527        assert!(!miss.matches(&l));
528    }
529
530    #[test]
531    fn contradictory_clauses_never_match() {
532        // `phase=X AND phase!=X` is satisfiable only by… nothing.
533        let sel = Selector::new().eq("phase", "x").ne("phase", "x");
534        assert!(!sel.matches(&lbls(&[("phase", "x")])));
535        assert!(!sel.matches(&lbls(&[("phase", "y")])));
536        assert!(!sel.matches(&Labels::empty()));
537    }
538
539    // ---- text-form parser --------------------------------------
540
541    #[test]
542    fn parse_single_clause_eq() {
543        let s = Selector::parse("phase=rampup").unwrap();
544        assert_eq!(s.len(), 1);
545        assert!(s.matches(&lbls(&[("phase", "rampup")])));
546    }
547
548    #[test]
549    fn parse_multi_clause() {
550        let s = Selector::parse("phase=rampup,k=10,profile~=label_*").unwrap();
551        assert_eq!(s.len(), 3);
552        assert!(s.matches(&lbls(&[
553            ("phase", "rampup"),
554            ("k", "10"),
555            ("profile", "label_07"),
556        ])));
557    }
558
559    #[test]
560    fn parse_tolerates_whitespace() {
561        let s = Selector::parse(" phase = rampup , profile ~= label_* ").unwrap();
562        assert_eq!(s.len(), 2);
563        assert!(s.matches(&lbls(&[("phase", "rampup"), ("profile", "label_99")])));
564    }
565
566    #[test]
567    fn parse_present_and_absent() {
568        let s = Selector::parse("profile?,skip!?").unwrap();
569        assert_eq!(s.len(), 2);
570        assert!(s.matches(&lbls(&[("profile", "label_00")])));
571        assert!(!s.matches(&lbls(&[("skip", "true")])));
572    }
573
574    #[test]
575    fn parse_ne_disambiguates_from_eq() {
576        // `!=` and `=` share the `=` character; operator-order
577        // matters. Parser must read `!=` as one operator.
578        let s = Selector::parse("phase!=teardown").unwrap();
579        assert!(s.matches(&lbls(&[("phase", "rampup")])));
580        assert!(!s.matches(&lbls(&[("phase", "teardown")])));
581    }
582
583    #[test]
584    fn parse_glob_disambiguates_from_eq() {
585        let s = Selector::parse("profile~=label_*").unwrap();
586        assert!(s.matches(&lbls(&[("profile", "label_04")])));
587        assert!(!s.matches(&lbls(&[("profile", "other")])));
588    }
589
590    #[test]
591    fn parse_absent_disambiguates_from_present() {
592        // `!?` must beat `?` — otherwise `key!?` would parse as
593        // "present `key!`" (with a trailing `!` in the key).
594        let s = Selector::parse("skip!?").unwrap();
595        assert_eq!(s.len(), 1);
596        assert!(s.matches(&lbls(&[("other", "x")])));
597        assert!(!s.matches(&lbls(&[("skip", "y")])));
598    }
599
600    #[test]
601    fn parse_empty_input_is_empty_selector() {
602        assert!(Selector::parse("").unwrap().is_empty());
603        assert!(Selector::parse("   ").unwrap().is_empty());
604        // Stray commas (including trailing commas) collapse away.
605        assert!(Selector::parse(",,, ,").unwrap().is_empty());
606    }
607
608    #[test]
609    fn parse_missing_operator_errors() {
610        let e = Selector::parse("phase rampup").unwrap_err();
611        assert!(e.to_string().contains("no operator"), "got: {e}");
612    }
613
614    #[test]
615    fn parse_empty_key_errors() {
616        assert!(Selector::parse("=value").is_err());
617        assert!(Selector::parse("?").is_err());
618        assert!(Selector::parse("!?").is_err());
619    }
620
621    #[test]
622    fn parse_empty_value_is_fine() {
623        // Empty-value eq is legal — matches labels with an
624        // explicitly-empty value.
625        let s = Selector::parse("tag=").unwrap();
626        assert!(s.matches(&lbls(&[("tag", "")])));
627        assert!(!s.matches(&lbls(&[("tag", "x")])));
628    }
629
630    // ---- Display / round-trip ---------------------------------
631
632    #[test]
633    fn display_round_trips_through_parse() {
634        for text in &[
635            "",
636            "phase=rampup",
637            "type=phase,name=rampup",
638            "profile~=label_*",
639            "skip!?",
640            "profile?",
641            "phase!=teardown",
642            "type=phase,profile~=label_*,skip!?,optimize_for=RECALL",
643        ] {
644            let parsed = Selector::parse(text).unwrap();
645            let rendered = parsed.to_string();
646            let reparsed = Selector::parse(&rendered).unwrap();
647            assert_eq!(parsed, reparsed, "text={text} rendered={rendered}");
648        }
649    }
650
651    // ---- `selector!` macro ------------------------------------
652
653    #[test]
654    fn macro_basic_eq_chain() {
655        let sel: Selector = crate::selector!(phase = "ann_query", k = "10");
656        assert_eq!(sel.len(), 2);
657        assert!(sel.matches(&lbls(&[("phase", "ann_query"), ("k", "10"),])));
658    }
659
660    #[test]
661    fn macro_trailing_comma_accepted() {
662        let sel: Selector = crate::selector!(a = "1", b = "2",);
663        assert_eq!(sel.len(), 2);
664    }
665
666    #[test]
667    fn macro_zero_args_empty_selector() {
668        let sel: Selector = crate::selector!();
669        assert!(sel.is_empty());
670    }
671
672    // ---- LookupError Display ----------------------------------
673
674    #[test]
675    fn lookup_error_display() {
676        assert_eq!(
677            LookupError::NotFound.to_string(),
678            "no components matched selector",
679        );
680        assert_eq!(
681            LookupError::Ambiguous { count: 3 }.to_string(),
682            "selector matched 3 components, expected exactly 1",
683        );
684    }
685}