Skip to main content

crabka_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    use assert2::assert;
67
68    use super::*;
69
70    #[test]
71    fn include_exclude_with_glob_and_regex() {
72        let s = Selector::compile(
73            &["orders".into(), "telemetry.*".into()],
74            &["*.internal".into()],
75        )
76        .unwrap();
77        assert!(s.matches("orders"));
78        assert!(s.matches("telemetry.cpu"));
79        assert!(!s.matches("payments"));
80        assert!(!s.matches("telemetry.internal")); // excluded wins
81    }
82
83    #[test]
84    fn empty_include_matches_nothing() {
85        let s = Selector::compile(&[], &[]).unwrap();
86        assert!(!s.matches("anything"));
87    }
88
89    #[test]
90    fn regex_metacharacters_are_escaped_to_literals() {
91        // A literal `.` in a pattern must match only a literal `.`, never an
92        // arbitrary character. If the metachar-escape guard is disabled, `a.b`
93        // compiles to the regex `^a.b$` and would wrongly match `axb`.
94        let s = Selector::compile(&["a.b".into()], &[]).unwrap();
95        assert!(s.matches("a.b"));
96        assert!(!s.matches("axb"));
97    }
98}