1use bon::bon;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ProcessNameMatch {
44 Any,
46 Exact(String),
48}
49
50impl ProcessNameMatch {
51 #[must_use]
52 pub fn matches(&self, process_name: &str) -> bool {
53 match self {
54 Self::Any => true,
55 Self::Exact(expected) => expected == process_name,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum WindowTitleMatch {
68 Any,
70 Missing,
72 Present,
74 Exact(String),
76}
77
78impl WindowTitleMatch {
79 #[must_use]
80 pub fn matches(&self, window_title: Option<&str>) -> bool {
81 let is_missing = window_title.is_none_or(str::is_empty);
82 match self {
83 Self::Any => true,
84 Self::Missing => is_missing,
85 Self::Present => !is_missing,
86 Self::Exact(expected) => window_title.is_some_and(|t| t == expected),
87 }
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct IgnoreRule {
116 process_name: ProcessNameMatch,
117 window_title: WindowTitleMatch,
118}
119
120#[bon]
121impl IgnoreRule {
122 #[builder]
129 pub fn new(
130 #[builder(
131 default = ProcessNameMatch::Any,
132 with = |name: impl Into<String>| ProcessNameMatch::Exact(name.into()),
133 )]
134 process_name: ProcessNameMatch,
135 #[builder(default = WindowTitleMatch::Any)] window_title: WindowTitleMatch,
136 ) -> Self {
137 Self {
138 process_name,
139 window_title,
140 }
141 }
142}
143
144impl IgnoreRule {
145 #[must_use]
147 pub fn process_name_match(&self) -> &ProcessNameMatch {
148 &self.process_name
149 }
150
151 #[must_use]
153 pub fn window_title_match(&self) -> &WindowTitleMatch {
154 &self.window_title
155 }
156
157 #[must_use]
159 pub fn matches(&self, process_name: &str, window_title: Option<&str>) -> bool {
160 self.process_name.matches(process_name) && self.window_title.matches(window_title)
161 }
162}
163
164#[derive(Debug, Clone, Default)]
168pub struct IgnoreRules {
169 rules: Vec<IgnoreRule>,
170}
171
172impl IgnoreRules {
173 pub fn new<I>(rules: I) -> Self
175 where
176 I: IntoIterator<Item = IgnoreRule>,
177 {
178 Self {
179 rules: rules.into_iter().collect(),
180 }
181 }
182
183 #[must_use]
185 pub fn matches(&self, process_name: &str, window_title: Option<&str>) -> bool {
186 self.rules
187 .iter()
188 .any(|rule| rule.matches(process_name, window_title))
189 }
190
191 #[must_use]
193 pub fn len(&self) -> usize {
194 self.rules.len()
195 }
196
197 #[must_use]
199 pub fn is_empty(&self) -> bool {
200 self.rules.is_empty()
201 }
202
203 pub fn iter(&self) -> impl Iterator<Item = &IgnoreRule> {
205 self.rules.iter()
206 }
207}
208
209impl FromIterator<IgnoreRule> for IgnoreRules {
210 fn from_iter<I: IntoIterator<Item = IgnoreRule>>(iter: I) -> Self {
211 Self::new(iter)
212 }
213}
214
215impl<'a> IntoIterator for &'a IgnoreRules {
216 type Item = &'a IgnoreRule;
217 type IntoIter = std::slice::Iter<'a, IgnoreRule>;
218
219 fn into_iter(self) -> Self::IntoIter {
220 self.rules.iter()
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn process_name_any_matches_anything() {
230 let m = ProcessNameMatch::Any;
231 assert!(m.matches(""));
232 assert!(m.matches("firefox"));
233 assert!(m.matches("Firefox"));
234 }
235
236 #[test]
237 fn process_name_exact_is_byte_exact() {
238 let m = ProcessNameMatch::Exact("firefox".into());
239 assert!(m.matches("firefox"));
240 assert!(!m.matches("Firefox"));
241 assert!(!m.matches("firefox.exe"));
242 assert!(!m.matches(""));
243 }
244
245 #[test]
246 fn window_title_any_matches_anything() {
247 let m = WindowTitleMatch::Any;
248 assert!(m.matches(None));
249 assert!(m.matches(Some("")));
250 assert!(m.matches(Some("hello")));
251 }
252
253 #[test]
254 fn window_title_missing_treats_none_and_empty_alike() {
255 let m = WindowTitleMatch::Missing;
256 assert!(m.matches(None));
257 assert!(m.matches(Some("")));
258 assert!(!m.matches(Some("hello")));
259 assert!(!m.matches(Some(" ")));
260 }
261
262 #[test]
263 fn window_title_present_excludes_none_and_empty() {
264 let m = WindowTitleMatch::Present;
265 assert!(!m.matches(None));
266 assert!(!m.matches(Some("")));
267 assert!(m.matches(Some("hello")));
268 assert!(m.matches(Some(" ")));
269 }
270
271 #[test]
272 fn window_title_exact_is_byte_exact_and_never_matches_missing() {
273 let m = WindowTitleMatch::Exact("Inbox".into());
274 assert!(m.matches(Some("Inbox")));
275 assert!(!m.matches(Some("inbox")));
276 assert!(!m.matches(Some("Inbox ")));
277 assert!(!m.matches(Some("")));
278 assert!(!m.matches(None));
279 }
280
281 #[test]
282 fn builder_defaults_to_any_any() {
283 let rule = IgnoreRule::builder().build();
284 assert_eq!(rule.process_name_match(), &ProcessNameMatch::Any);
285 assert_eq!(rule.window_title_match(), &WindowTitleMatch::Any);
286 assert!(rule.matches("anything", None));
287 assert!(rule.matches("anything", Some("titled")));
288 }
289
290 #[test]
291 fn builder_process_name_with_any_title() {
292 let rule = IgnoreRule::builder().process_name("firefox").build();
293 assert!(rule.matches("firefox", None));
294 assert!(rule.matches("firefox", Some("")));
295 assert!(rule.matches("firefox", Some("News")));
296 assert!(!rule.matches("Firefox", None));
297 assert!(!rule.matches("chrome", Some("News")));
298 }
299
300 #[test]
301 fn builder_process_name_with_title_missing_matches_the_user_case() {
302 let rule = IgnoreRule::builder()
303 .process_name("whatever")
304 .window_title(WindowTitleMatch::Missing)
305 .build();
306 assert!(rule.matches("whatever", None));
307 assert!(rule.matches("whatever", Some("")));
308 assert!(!rule.matches("whatever", Some("Doc")));
309 assert!(!rule.matches("other", None));
310 }
311
312 #[test]
313 fn builder_process_name_with_title_present() {
314 let rule = IgnoreRule::builder()
315 .process_name("whatever")
316 .window_title(WindowTitleMatch::Present)
317 .build();
318 assert!(!rule.matches("whatever", None));
319 assert!(!rule.matches("whatever", Some("")));
320 assert!(rule.matches("whatever", Some("Doc")));
321 assert!(!rule.matches("other", Some("Doc")));
322 }
323
324 #[test]
325 fn builder_process_name_with_title_exact() {
326 let rule = IgnoreRule::builder()
327 .process_name("whatever")
328 .window_title(WindowTitleMatch::Exact("Splash".into()))
329 .build();
330 assert!(rule.matches("whatever", Some("Splash")));
331 assert!(!rule.matches("whatever", Some("splash")));
332 assert!(!rule.matches("whatever", None));
333 assert!(!rule.matches("whatever", Some("")));
334 assert!(!rule.matches("other", Some("Splash")));
335 }
336
337 #[test]
338 fn builder_any_process_with_title_missing() {
339 let rule = IgnoreRule::builder()
340 .window_title(WindowTitleMatch::Missing)
341 .build();
342 assert!(rule.matches("anything", None));
343 assert!(rule.matches("anything-else", Some("")));
344 assert!(!rule.matches("anything", Some("Titled")));
345 }
346
347 #[test]
348 fn builder_accepts_string_and_str() {
349 let rule_from_str = IgnoreRule::builder().process_name("p").build();
350 let rule_from_string = IgnoreRule::builder()
351 .process_name(String::from("p"))
352 .build();
353 assert_eq!(rule_from_str, rule_from_string);
354 }
355
356 #[test]
357 fn rule_accessors_expose_matchers() {
358 let rule = IgnoreRule::builder()
359 .process_name("p")
360 .window_title(WindowTitleMatch::Missing)
361 .build();
362 assert_eq!(
363 rule.process_name_match(),
364 &ProcessNameMatch::Exact("p".into())
365 );
366 assert_eq!(rule.window_title_match(), &WindowTitleMatch::Missing);
367 }
368
369 #[test]
370 fn rules_default_is_empty_and_matches_nothing() {
371 let rules = IgnoreRules::default();
372 assert!(rules.is_empty());
373 assert_eq!(rules.len(), 0);
374 assert!(!rules.matches("anything", None));
375 assert!(!rules.matches("anything", Some("x")));
376 }
377
378 #[test]
379 fn rules_or_across_rules() {
380 let rules = IgnoreRules::new([
381 IgnoreRule::builder()
382 .process_name("whatever")
383 .window_title(WindowTitleMatch::Missing)
384 .build(),
385 IgnoreRule::builder().process_name("chrome").build(),
386 ]);
387 assert!(rules.matches("whatever", None));
388 assert!(rules.matches("chrome", Some("News")));
389 assert!(!rules.matches("whatever", Some("Doc")));
390 assert!(!rules.matches("other", None));
391 }
392
393 #[test]
394 fn rules_len_reflects_input() {
395 let rules = IgnoreRules::new([
396 IgnoreRule::builder().process_name("a").build(),
397 IgnoreRule::builder().process_name("b").build(),
398 IgnoreRule::builder().process_name("a").build(),
399 ]);
400 assert_eq!(rules.len(), 3);
403 }
404
405 #[test]
406 fn rules_iter_preserves_insertion_order() {
407 let rules = IgnoreRules::new([
408 IgnoreRule::builder().process_name("a").build(),
409 IgnoreRule::builder().process_name("b").build(),
410 ]);
411 let names: Vec<_> = rules
412 .iter()
413 .map(|r| match r.process_name_match() {
414 ProcessNameMatch::Exact(s) => s.as_str(),
415 ProcessNameMatch::Any => "",
416 })
417 .collect();
418 assert_eq!(names, ["a", "b"]);
419 }
420
421 #[test]
422 fn rules_from_iterator() {
423 let rules: IgnoreRules = [
424 IgnoreRule::builder().process_name("a").build(),
425 IgnoreRule::builder().process_name("b").build(),
426 ]
427 .into_iter()
428 .collect();
429 assert_eq!(rules.len(), 2);
430 assert!(rules.matches("a", None));
431 assert!(rules.matches("b", Some("x")));
432 }
433
434 #[test]
435 fn rules_into_iter_by_reference() {
436 let rules = IgnoreRules::new([IgnoreRule::builder().process_name("a").build()]);
437 let count = (&rules).into_iter().count();
438 assert_eq!(count, 1);
439 }
440}