use bon::bon;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcessNameMatch {
Any,
Exact(String),
}
impl ProcessNameMatch {
#[must_use]
pub fn matches(&self, process_name: &str) -> bool {
match self {
Self::Any => true,
Self::Exact(expected) => expected == process_name,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WindowTitleMatch {
Any,
Missing,
Present,
Exact(String),
}
impl WindowTitleMatch {
#[must_use]
pub fn matches(&self, window_title: Option<&str>) -> bool {
let is_missing = window_title.is_none_or(str::is_empty);
match self {
Self::Any => true,
Self::Missing => is_missing,
Self::Present => !is_missing,
Self::Exact(expected) => window_title.is_some_and(|t| t == expected),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IgnoreRule {
process_name: ProcessNameMatch,
window_title: WindowTitleMatch,
}
#[bon]
impl IgnoreRule {
#[builder]
pub fn new(
#[builder(
default = ProcessNameMatch::Any,
with = |name: impl Into<String>| ProcessNameMatch::Exact(name.into()),
)]
process_name: ProcessNameMatch,
#[builder(default = WindowTitleMatch::Any)] window_title: WindowTitleMatch,
) -> Self {
Self {
process_name,
window_title,
}
}
}
impl IgnoreRule {
#[must_use]
pub fn process_name_match(&self) -> &ProcessNameMatch {
&self.process_name
}
#[must_use]
pub fn window_title_match(&self) -> &WindowTitleMatch {
&self.window_title
}
#[must_use]
pub fn matches(&self, process_name: &str, window_title: Option<&str>) -> bool {
self.process_name.matches(process_name) && self.window_title.matches(window_title)
}
}
#[derive(Debug, Clone, Default)]
pub struct IgnoreRules {
rules: Vec<IgnoreRule>,
}
impl IgnoreRules {
pub fn new<I>(rules: I) -> Self
where
I: IntoIterator<Item = IgnoreRule>,
{
Self {
rules: rules.into_iter().collect(),
}
}
#[must_use]
pub fn matches(&self, process_name: &str, window_title: Option<&str>) -> bool {
self.rules
.iter()
.any(|rule| rule.matches(process_name, window_title))
}
#[must_use]
pub fn len(&self) -> usize {
self.rules.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &IgnoreRule> {
self.rules.iter()
}
}
impl FromIterator<IgnoreRule> for IgnoreRules {
fn from_iter<I: IntoIterator<Item = IgnoreRule>>(iter: I) -> Self {
Self::new(iter)
}
}
impl<'a> IntoIterator for &'a IgnoreRules {
type Item = &'a IgnoreRule;
type IntoIter = std::slice::Iter<'a, IgnoreRule>;
fn into_iter(self) -> Self::IntoIter {
self.rules.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_name_any_matches_anything() {
let m = ProcessNameMatch::Any;
assert!(m.matches(""));
assert!(m.matches("firefox"));
assert!(m.matches("Firefox"));
}
#[test]
fn process_name_exact_is_byte_exact() {
let m = ProcessNameMatch::Exact("firefox".into());
assert!(m.matches("firefox"));
assert!(!m.matches("Firefox"));
assert!(!m.matches("firefox.exe"));
assert!(!m.matches(""));
}
#[test]
fn window_title_any_matches_anything() {
let m = WindowTitleMatch::Any;
assert!(m.matches(None));
assert!(m.matches(Some("")));
assert!(m.matches(Some("hello")));
}
#[test]
fn window_title_missing_treats_none_and_empty_alike() {
let m = WindowTitleMatch::Missing;
assert!(m.matches(None));
assert!(m.matches(Some("")));
assert!(!m.matches(Some("hello")));
assert!(!m.matches(Some(" ")));
}
#[test]
fn window_title_present_excludes_none_and_empty() {
let m = WindowTitleMatch::Present;
assert!(!m.matches(None));
assert!(!m.matches(Some("")));
assert!(m.matches(Some("hello")));
assert!(m.matches(Some(" ")));
}
#[test]
fn window_title_exact_is_byte_exact_and_never_matches_missing() {
let m = WindowTitleMatch::Exact("Inbox".into());
assert!(m.matches(Some("Inbox")));
assert!(!m.matches(Some("inbox")));
assert!(!m.matches(Some("Inbox ")));
assert!(!m.matches(Some("")));
assert!(!m.matches(None));
}
#[test]
fn builder_defaults_to_any_any() {
let rule = IgnoreRule::builder().build();
assert_eq!(rule.process_name_match(), &ProcessNameMatch::Any);
assert_eq!(rule.window_title_match(), &WindowTitleMatch::Any);
assert!(rule.matches("anything", None));
assert!(rule.matches("anything", Some("titled")));
}
#[test]
fn builder_process_name_with_any_title() {
let rule = IgnoreRule::builder().process_name("firefox").build();
assert!(rule.matches("firefox", None));
assert!(rule.matches("firefox", Some("")));
assert!(rule.matches("firefox", Some("News")));
assert!(!rule.matches("Firefox", None));
assert!(!rule.matches("chrome", Some("News")));
}
#[test]
fn builder_process_name_with_title_missing_matches_the_user_case() {
let rule = IgnoreRule::builder()
.process_name("whatever")
.window_title(WindowTitleMatch::Missing)
.build();
assert!(rule.matches("whatever", None));
assert!(rule.matches("whatever", Some("")));
assert!(!rule.matches("whatever", Some("Doc")));
assert!(!rule.matches("other", None));
}
#[test]
fn builder_process_name_with_title_present() {
let rule = IgnoreRule::builder()
.process_name("whatever")
.window_title(WindowTitleMatch::Present)
.build();
assert!(!rule.matches("whatever", None));
assert!(!rule.matches("whatever", Some("")));
assert!(rule.matches("whatever", Some("Doc")));
assert!(!rule.matches("other", Some("Doc")));
}
#[test]
fn builder_process_name_with_title_exact() {
let rule = IgnoreRule::builder()
.process_name("whatever")
.window_title(WindowTitleMatch::Exact("Splash".into()))
.build();
assert!(rule.matches("whatever", Some("Splash")));
assert!(!rule.matches("whatever", Some("splash")));
assert!(!rule.matches("whatever", None));
assert!(!rule.matches("whatever", Some("")));
assert!(!rule.matches("other", Some("Splash")));
}
#[test]
fn builder_any_process_with_title_missing() {
let rule = IgnoreRule::builder()
.window_title(WindowTitleMatch::Missing)
.build();
assert!(rule.matches("anything", None));
assert!(rule.matches("anything-else", Some("")));
assert!(!rule.matches("anything", Some("Titled")));
}
#[test]
fn builder_accepts_string_and_str() {
let rule_from_str = IgnoreRule::builder().process_name("p").build();
let rule_from_string = IgnoreRule::builder()
.process_name(String::from("p"))
.build();
assert_eq!(rule_from_str, rule_from_string);
}
#[test]
fn rule_accessors_expose_matchers() {
let rule = IgnoreRule::builder()
.process_name("p")
.window_title(WindowTitleMatch::Missing)
.build();
assert_eq!(
rule.process_name_match(),
&ProcessNameMatch::Exact("p".into())
);
assert_eq!(rule.window_title_match(), &WindowTitleMatch::Missing);
}
#[test]
fn rules_default_is_empty_and_matches_nothing() {
let rules = IgnoreRules::default();
assert!(rules.is_empty());
assert_eq!(rules.len(), 0);
assert!(!rules.matches("anything", None));
assert!(!rules.matches("anything", Some("x")));
}
#[test]
fn rules_or_across_rules() {
let rules = IgnoreRules::new([
IgnoreRule::builder()
.process_name("whatever")
.window_title(WindowTitleMatch::Missing)
.build(),
IgnoreRule::builder().process_name("chrome").build(),
]);
assert!(rules.matches("whatever", None));
assert!(rules.matches("chrome", Some("News")));
assert!(!rules.matches("whatever", Some("Doc")));
assert!(!rules.matches("other", None));
}
#[test]
fn rules_len_reflects_input() {
let rules = IgnoreRules::new([
IgnoreRule::builder().process_name("a").build(),
IgnoreRule::builder().process_name("b").build(),
IgnoreRule::builder().process_name("a").build(),
]);
assert_eq!(rules.len(), 3);
}
#[test]
fn rules_iter_preserves_insertion_order() {
let rules = IgnoreRules::new([
IgnoreRule::builder().process_name("a").build(),
IgnoreRule::builder().process_name("b").build(),
]);
let names: Vec<_> = rules
.iter()
.map(|r| match r.process_name_match() {
ProcessNameMatch::Exact(s) => s.as_str(),
ProcessNameMatch::Any => "",
})
.collect();
assert_eq!(names, ["a", "b"]);
}
#[test]
fn rules_from_iterator() {
let rules: IgnoreRules = [
IgnoreRule::builder().process_name("a").build(),
IgnoreRule::builder().process_name("b").build(),
]
.into_iter()
.collect();
assert_eq!(rules.len(), 2);
assert!(rules.matches("a", None));
assert!(rules.matches("b", Some("x")));
}
#[test]
fn rules_into_iter_by_reference() {
let rules = IgnoreRules::new([IgnoreRule::builder().process_name("a").build()]);
let count = (&rules).into_iter().count();
assert_eq!(count, 1);
}
}