Skip to main content

autopulse_service/settings/
path_filter.rs

1use autopulse_utils::regex::Regex;
2use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer};
3
4#[derive(Clone, Default, Serialize, Deserialize)]
5pub struct PathFilter {
6    /// Path regex patterns to include. Empty includes all paths unless excluded.
7    #[serde(default)]
8    pub include: IncludePaths,
9    /// Path regex patterns to exclude. Excludes win over includes.
10    #[serde(default)]
11    pub exclude: ExcludePaths,
12}
13
14impl PathFilter {
15    pub fn allows(&self, path: &str) -> bool {
16        (self.include.is_empty() || self.include.matches(path)) && !self.exclude.matches(path)
17    }
18}
19
20#[derive(Clone, Default)]
21pub struct IncludePaths {
22    patterns: Vec<Regex>,
23    sources: Vec<String>,
24}
25
26impl IncludePaths {
27    pub fn is_empty(&self) -> bool {
28        self.patterns.is_empty()
29    }
30
31    pub fn matches(&self, path: &str) -> bool {
32        self.patterns.iter().any(|r| r.is_match(path))
33    }
34}
35
36impl<'de> Deserialize<'de> for IncludePaths {
37    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
38        let sources: Vec<String> = Vec::<String>::deserialize(d)?;
39        let mut patterns = Vec::with_capacity(sources.len());
40        for s in &sources {
41            let r = Regex::new(s).map_err(|e| {
42                D::Error::custom(format!("invalid filter.include regex `{s}`: {e}"))
43            })?;
44            patterns.push(r);
45        }
46        Ok(Self { patterns, sources })
47    }
48}
49
50impl Serialize for IncludePaths {
51    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
52        self.sources.serialize(s)
53    }
54}
55
56#[derive(Clone, Default)]
57pub struct ExcludePaths {
58    patterns: Vec<Regex>,
59    sources: Vec<String>,
60}
61
62impl ExcludePaths {
63    pub fn matches(&self, path: &str) -> bool {
64        self.patterns.iter().any(|r| r.is_match(path))
65    }
66}
67
68impl<'de> Deserialize<'de> for ExcludePaths {
69    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
70        let sources: Vec<String> = Vec::<String>::deserialize(d)?;
71        let mut patterns = Vec::with_capacity(sources.len());
72        for s in &sources {
73            let r = Regex::new(s).map_err(|e| {
74                D::Error::custom(format!("invalid filter.exclude regex `{s}`: {e}"))
75            })?;
76            patterns.push(r);
77        }
78        Ok(Self { patterns, sources })
79    }
80}
81
82impl Serialize for ExcludePaths {
83    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
84        self.sources.serialize(s)
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn includes_match_any_pattern() {
94        let filter: PathFilter = serde_json::from_value(serde_json::json!({
95            "include": ["^/books/", "^/podcasts/"]
96        }))
97        .unwrap();
98
99        assert!(filter.allows("/books/Novel.m4b"));
100        assert!(filter.allows("/podcasts/Episode.mp3"));
101        assert!(!filter.allows("/movies/Movie.mkv"));
102    }
103
104    #[test]
105    fn excludes_match_any_pattern() {
106        let filter: PathFilter = serde_json::from_value(serde_json::json!({
107            "exclude": ["/samples/", "\\.tmp$"]
108        }))
109        .unwrap();
110
111        assert!(!filter.allows("/books/samples/Sample.m4b"));
112        assert!(!filter.allows("/books/file.tmp"));
113        assert!(filter.allows("/books/Novel.m4b"));
114    }
115
116    #[test]
117    fn exclude_wins_over_include() {
118        let filter: PathFilter = serde_json::from_value(serde_json::json!({
119            "include": ["^/books/"],
120            "exclude": ["^/books/samples/"]
121        }))
122        .unwrap();
123
124        assert!(filter.allows("/books/Novel.m4b"));
125        assert!(!filter.allows("/books/samples/Sample.m4b"));
126    }
127
128    #[test]
129    fn invalid_include_regex_rejected_at_config_load() {
130        let res: Result<PathFilter, _> = serde_json::from_value(serde_json::json!({
131            "include": ["[unclosed"]
132        }));
133
134        assert!(res.is_err());
135        let msg = format!("{}", res.err().unwrap());
136        assert!(msg.contains("invalid filter.include regex"));
137    }
138
139    #[test]
140    fn invalid_exclude_regex_rejected_at_config_load() {
141        let res: Result<PathFilter, _> = serde_json::from_value(serde_json::json!({
142            "exclude": ["[unclosed"]
143        }));
144
145        assert!(res.is_err());
146        let msg = format!("{}", res.err().unwrap());
147        assert!(msg.contains("invalid filter.exclude regex"));
148    }
149}