Skip to main content

krabka_replicator/
selector.rs

1//! Include/exclude name selectors. `*` is a glob wildcard; patterns are anchored.
2
3use regex::Regex;
4
5use crate::error::ReplicatorError;
6
7/// Matches topic or consumer-group names against include/exclude pattern lists.
8///
9/// Rules:
10/// - `*` is a glob wildcard (maps to `.*` in regex).
11/// - Patterns are fully anchored (`^...$`).
12/// - Exclude wins over include.
13/// - An empty include list matches nothing.
14#[derive(Debug, Clone)]
15pub struct Selector {
16    include: Vec<Regex>,
17    exclude: Vec<Regex>,
18}
19
20fn glob_to_regex(pat: &str) -> Result<Regex, ReplicatorError> {
21    let mut re = String::from("^");
22    for ch in pat.chars() {
23        match ch {
24            '*' => re.push_str(".*"),
25            c if "\\.[]{}()+?^$|".contains(c) => {
26                re.push('\\');
27                re.push(c);
28            }
29            c => re.push(c),
30        }
31    }
32    re.push('$');
33    Regex::new(&re).map_err(|e| ReplicatorError::Config(format!("bad selector `{pat}`: {e}")))
34}
35
36impl Selector {
37    /// Compile include and exclude glob patterns into a `Selector`.
38    ///
39    /// # Errors
40    /// Returns [`ReplicatorError::Config`] if any pattern produces an invalid regex.
41    pub fn compile(include: &[String], exclude: &[String]) -> Result<Self, ReplicatorError> {
42        Ok(Self {
43            include: include
44                .iter()
45                .map(|p| glob_to_regex(p))
46                .collect::<Result<_, _>>()?,
47            exclude: exclude
48                .iter()
49                .map(|p| glob_to_regex(p))
50                .collect::<Result<_, _>>()?,
51        })
52    }
53
54    /// Returns `true` if `name` is matched by the include list and not the exclude list.
55    #[must_use]
56    pub fn matches(&self, name: &str) -> bool {
57        if self.exclude.iter().any(|r| r.is_match(name)) {
58            return false;
59        }
60        self.include.iter().any(|r| r.is_match(name))
61    }
62}
63
64#[cfg(test)]
65mod tests {
66
67    use super::*;
68
69    #[test]
70    fn include_exclude_with_glob_and_regex() {
71        let s = Selector::compile(
72            &["orders".into(), "telemetry.*".into()],
73            &["*.internal".into()],
74        )
75        .unwrap();
76        for (name, want) in [
77            ("orders", true),
78            ("telemetry.cpu", true),
79            ("payments", false),
80            ("telemetry.internal", false), // excluded wins
81        ] {
82            assert2::assert!(s.matches(name) == want);
83        }
84    }
85
86    #[test]
87    fn empty_include_matches_nothing() {
88        let s = Selector::compile(&[], &[]).unwrap();
89        assert2::assert!(!s.matches("anything"));
90    }
91
92    #[test]
93    fn regex_metacharacters_are_escaped_to_literals() {
94        // A literal `.` in a pattern must match only a literal `.`, never an
95        // arbitrary character. If the metachar-escape guard is disabled, `a.b`
96        // compiles to the regex `^a.b$` and would wrongly match `axb`.
97        let s = Selector::compile(&["a.b".into()], &[]).unwrap();
98        assert2::assert!(s.matches("a.b"));
99        assert2::assert!(!s.matches("axb"));
100    }
101}