autopulse_service/settings/
path_filter.rs1use autopulse_utils::regex::Regex;
2use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer};
3
4#[derive(Clone, Default, Serialize, Deserialize)]
5pub struct PathFilter {
6 #[serde(default)]
8 pub include: IncludePaths,
9 #[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}