Skip to main content

focus_tracker_core/
ignore_rules.rs

1//! Composable ignore rules for focus events.
2//!
3//! Each [`IgnoreRule`] is a conjunction of two predicates — one over the
4//! process name, one over the window title. A focus event is suppressed when
5//! **any** rule in an [`IgnoreRules`] set matches (i.e. logical OR across
6//! rules, logical AND inside a rule).
7//!
8//! Matching against the process name and the window title is byte-exact and
9//! case-sensitive, matching the rest of this crate's "provide every spelling"
10//! philosophy: process-name and title sources vary per platform and locale,
11//! so silent fuzzy matching would mask bugs.
12//!
13//! [`WindowTitleMatch::Missing`] deliberately collapses `None` and
14//! `Some("")` into the same category: "no meaningful title". Platforms
15//! disagree about which of those they emit for a titleless window, so the
16//! API hides that divergence.
17//!
18//! # Example
19//!
20//! ```
21//! use focus_tracker_core::{IgnoreRule, IgnoreRules, WindowTitleMatch};
22//!
23//! // Suppress "whatever" only when it has no title; keep it when titled.
24//! let rules = IgnoreRules::new([
25//!     IgnoreRule::builder()
26//!         .process_name("whatever")
27//!         .window_title(WindowTitleMatch::Missing)
28//!         .build(),
29//! ]);
30//!
31//! assert!(rules.matches("whatever", None));
32//! assert!(rules.matches("whatever", Some("")));
33//! assert!(!rules.matches("whatever", Some("Untitled Document")));
34//! assert!(!rules.matches("something-else", None));
35//! ```
36
37use bon::bon;
38
39/// Predicate over [`FocusedWindow::process_name`].
40///
41/// [`FocusedWindow::process_name`]: crate::FocusedWindow::process_name
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ProcessNameMatch {
44    /// Matches any process name.
45    Any,
46    /// Byte-exact, case-sensitive match.
47    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/// Predicate over [`FocusedWindow::window_title`].
61///
62/// `None` and `Some("")` are treated identically as "missing"; platforms
63/// disagree on which they emit for a titleless window.
64///
65/// [`FocusedWindow::window_title`]: crate::FocusedWindow::window_title
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum WindowTitleMatch {
68    /// Matches whether or not a window title is present.
69    Any,
70    /// Matches when the title is absent or empty (`None` or `Some("")`).
71    Missing,
72    /// Matches when a non-empty title is present (any value).
73    Present,
74    /// Byte-exact, case-sensitive match against a non-empty title.
75    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/// A single ignore predicate: matches when both the process-name and
92/// window-title predicates match (logical AND).
93///
94/// Construct with [`IgnoreRule::builder`]. Both fields default to their
95/// `Any` variant, so omitting a setter means "don't constrain on that
96/// dimension". `.process_name(s)` accepts any `Into<String>` and is sugar
97/// for [`ProcessNameMatch::Exact`]; `.window_title(m)` takes a
98/// [`WindowTitleMatch`] directly.
99///
100/// # Example
101///
102/// ```
103/// use focus_tracker_core::{IgnoreRule, WindowTitleMatch};
104///
105/// // Ignore Explorer.EXE only when it has no title.
106/// let rule = IgnoreRule::builder()
107///     .process_name("Explorer.EXE")
108///     .window_title(WindowTitleMatch::Missing)
109///     .build();
110///
111/// assert!(rule.matches("Explorer.EXE", None));
112/// assert!(!rule.matches("Explorer.EXE", Some("Documents")));
113/// ```
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct IgnoreRule {
116    process_name: ProcessNameMatch,
117    window_title: WindowTitleMatch,
118}
119
120#[bon]
121impl IgnoreRule {
122    /// Builds an ignore rule. Both fields default to their `Any` variant.
123    ///
124    /// `.process_name(s)` takes a string and stores
125    /// [`ProcessNameMatch::Exact`]; omit the setter to leave the
126    /// process-name predicate as [`ProcessNameMatch::Any`]. To match any
127    /// process and constrain only on title, omit `.process_name(...)`.
128    #[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    /// Returns the rule's process-name predicate.
146    #[must_use]
147    pub fn process_name_match(&self) -> &ProcessNameMatch {
148        &self.process_name
149    }
150
151    /// Returns the rule's window-title predicate.
152    #[must_use]
153    pub fn window_title_match(&self) -> &WindowTitleMatch {
154        &self.window_title
155    }
156
157    /// Returns `true` when the rule matches the given focus event fields.
158    #[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/// A set of ignore rules. A focus event is ignored when **any** rule matches.
165///
166/// Order is preserved for debugging; matching is order-independent.
167#[derive(Debug, Clone, Default)]
168pub struct IgnoreRules {
169    rules: Vec<IgnoreRule>,
170}
171
172impl IgnoreRules {
173    /// Builds a rule set from an iterator of rules.
174    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    /// Returns `true` when at least one rule matches.
184    #[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    /// Returns the number of rules in the set.
192    #[must_use]
193    pub fn len(&self) -> usize {
194        self.rules.len()
195    }
196
197    /// Returns `true` when the set has no rules.
198    #[must_use]
199    pub fn is_empty(&self) -> bool {
200        self.rules.is_empty()
201    }
202
203    /// Iterates over the rules in insertion order.
204    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        // Rules are not deduplicated — duplicates are preserved so callers
401        // can reason about debug output. Matching semantics are unaffected.
402        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}