use crate::common::Rect;
use crate::config::types::{MatchRule, WindowAction, WindowRule, WindowRulesConfig};
use crate::registry::types::{FloatingState, IgnoredReason, TilingState, WindowState};
#[derive(Debug, Clone)]
pub struct WindowCandidate {
pub exe: String,
pub title: String,
pub class: String,
pub process_path: String,
}
#[must_use]
pub fn matches_rule(candidate: &WindowCandidate, rule: &MatchRule) -> bool {
if let Some(ref exe) = rule.exe
&& !candidate.exe.eq_ignore_ascii_case(exe)
{
return false;
}
if let Some(ref pattern) = rule.exe_regex {
match regex::RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
{
Ok(re) => {
if !re.is_match(&candidate.exe) {
return false;
}
}
Err(e) => {
log::warn!(
"exe_regex pattern '{pattern}' failed to compile: {e}; treating as non-match"
);
return false;
}
}
}
if let Some(ref title) = rule.title
&& candidate.title != *title
{
return false;
}
if let Some(ref substr) = rule.title_contains
&& !candidate.title.contains(substr)
{
return false;
}
if let Some(ref pattern) = rule.title_regex {
match regex::Regex::new(pattern) {
Ok(re) => {
if !re.is_match(&candidate.title) {
return false;
}
}
Err(e) => {
log::warn!(
"title_regex pattern '{pattern}' failed to compile: {e}; treating as non-match"
);
return false;
}
}
}
if let Some(ref class) = rule.class
&& candidate.class != *class
{
return false;
}
if let Some(ref pattern) = rule.class_regex {
match regex::Regex::new(pattern) {
Ok(re) => {
if !re.is_match(&candidate.class) {
return false;
}
}
Err(e) => {
log::warn!(
"class_regex pattern '{pattern}' failed to compile: {e}; treating as non-match"
);
return false;
}
}
}
if let Some(ref path) = rule.process_path
&& !candidate.process_path.eq_ignore_ascii_case(path)
{
return false;
}
if let Some(ref pattern) = rule.process_path_regex {
match regex::RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
{
Ok(re) => {
if !re.is_match(&candidate.process_path) {
return false;
}
}
Err(e) => {
log::warn!(
"process_path_regex pattern '{pattern}' failed to compile: {e}; treating as non-match"
);
return false;
}
}
}
true
}
#[must_use]
fn action_to_state(action: WindowAction) -> WindowState {
match action {
WindowAction::Tile => WindowState::Tiling(TilingState::Active { col: 0, row: 0 }),
WindowAction::Float => WindowState::Floating(FloatingState::Active {
rect: Rect {
x: 0,
y: 0,
width: 0,
height: 0,
},
}),
WindowAction::Ignore => WindowState::Ignored(IgnoredReason::ExplicitRule),
}
}
enum CompiledRegex {
Unspecified,
Valid(regex::Regex),
Invalid,
}
struct CompiledRule {
rule: WindowRule,
exe_regex: CompiledRegex,
title_regex: CompiledRegex,
class_regex: CompiledRegex,
process_path_regex: CompiledRegex,
}
fn compile_regex(pattern: Option<&str>, case_insensitive: bool, field_name: &str) -> CompiledRegex {
match pattern {
None => CompiledRegex::Unspecified,
Some(p) => {
let mut builder = regex::RegexBuilder::new(p);
builder.case_insensitive(case_insensitive);
match builder.build() {
Ok(re) => CompiledRegex::Valid(re),
Err(e) => {
log::warn!(
"{field_name} pattern '{p}' failed to compile: {e}; treating as non-match"
);
CompiledRegex::Invalid
}
}
}
}
}
#[must_use]
fn matches_compiled_rule(candidate: &WindowCandidate, compiled: &CompiledRule) -> bool {
let rule = &compiled.rule.match_;
if let Some(ref exe) = rule.exe
&& !candidate.exe.eq_ignore_ascii_case(exe)
{
return false;
}
match &compiled.exe_regex {
CompiledRegex::Valid(re) => {
if !re.is_match(&candidate.exe) {
return false;
}
}
CompiledRegex::Invalid => return false,
CompiledRegex::Unspecified => {}
}
if let Some(ref title) = rule.title
&& candidate.title != *title
{
return false;
}
if let Some(ref substr) = rule.title_contains
&& !candidate.title.contains(substr)
{
return false;
}
match &compiled.title_regex {
CompiledRegex::Valid(re) => {
if !re.is_match(&candidate.title) {
return false;
}
}
CompiledRegex::Invalid => return false,
CompiledRegex::Unspecified => {}
}
if let Some(ref class) = rule.class
&& candidate.class != *class
{
return false;
}
match &compiled.class_regex {
CompiledRegex::Valid(re) => {
if !re.is_match(&candidate.class) {
return false;
}
}
CompiledRegex::Invalid => return false,
CompiledRegex::Unspecified => {}
}
if let Some(ref path) = rule.process_path
&& !candidate.process_path.eq_ignore_ascii_case(path)
{
return false;
}
match &compiled.process_path_regex {
CompiledRegex::Valid(re) => {
if !re.is_match(&candidate.process_path) {
return false;
}
}
CompiledRegex::Invalid => return false,
CompiledRegex::Unspecified => {}
}
true
}
fn compile_rules(rules: Vec<WindowRule>) -> Vec<CompiledRule> {
rules
.into_iter()
.map(|rule| {
let m = &rule.match_;
CompiledRule {
exe_regex: compile_regex(m.exe_regex.as_deref(), true, "exe_regex"),
title_regex: compile_regex(m.title_regex.as_deref(), false, "title_regex"),
class_regex: compile_regex(m.class_regex.as_deref(), false, "class_regex"),
process_path_regex: compile_regex(
m.process_path_regex.as_deref(),
true,
"process_path_regex",
),
rule,
}
})
.collect()
}
pub struct ClassificationPipeline {
user_rules: Vec<CompiledRule>,
default_rules: Vec<CompiledRule>,
learned_rules: Vec<CompiledRule>,
default_action: WindowAction,
}
impl ClassificationPipeline {
#[must_use]
pub fn new(user_rules: WindowRulesConfig, default_rules: WindowRulesConfig) -> Self {
let default_action = user_rules.default_action;
Self {
user_rules: compile_rules(user_rules.rules),
default_rules: compile_rules(default_rules.rules),
learned_rules: Vec::new(),
default_action,
}
}
#[must_use]
pub fn classify(&self, candidate: &WindowCandidate) -> WindowAction {
for compiled in &self.user_rules {
if matches_compiled_rule(candidate, compiled) {
return compiled.rule.action;
}
}
for compiled in &self.learned_rules {
if matches_compiled_rule(candidate, compiled) {
return compiled.rule.action;
}
}
for compiled in &self.default_rules {
if matches_compiled_rule(candidate, compiled) {
return compiled.rule.action;
}
}
self.default_action
}
pub fn set_learned_rules(&mut self, rules: Vec<WindowRule>) {
self.learned_rules = compile_rules(rules);
}
pub fn set_user_rules(&mut self, user_rules: WindowRulesConfig) {
self.default_action = user_rules.default_action;
self.user_rules = compile_rules(user_rules.rules);
}
}
#[must_use]
pub(super) fn classify_with_state_pipeline(
candidate: &WindowCandidate,
is_maximized: bool,
is_fullscreen: bool,
pipeline: &ClassificationPipeline,
) -> WindowState {
if is_maximized {
return WindowState::Ignored(IgnoredReason::Maximized);
}
if is_fullscreen {
return WindowState::Ignored(IgnoredReason::Fullscreen);
}
let action = pipeline.classify(candidate);
action_to_state(action)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::types::IgnoredReason;
fn candidate(exe: &str, title: &str, class: &str, process_path: &str) -> WindowCandidate {
WindowCandidate {
exe: exe.to_owned(),
title: title.to_owned(),
class: class.to_owned(),
process_path: process_path.to_owned(),
}
}
fn rule(match_rule: MatchRule, action: WindowAction) -> WindowRule {
WindowRule {
match_: match_rule,
action,
initial_width_px: None,
override_persist: false,
}
}
fn pipeline_from(
rules: Vec<WindowRule>,
default_action: WindowAction,
) -> ClassificationPipeline {
ClassificationPipeline::new(
WindowRulesConfig {
default_action,
rules,
},
WindowRulesConfig::default(),
)
}
#[test]
fn pipeline_exact_exe_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("notepad.exe".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("notepad.exe", "", "", "");
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_case_insensitive_exe_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("Explorer.EXE".into()),
..Default::default()
},
WindowAction::Ignore,
)],
WindowAction::Tile,
);
let c = candidate("explorer.exe", "", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_title_contains_case_sensitive() {
let p = pipeline_from(
vec![rule(
MatchRule {
title_contains: Some("Open File".into()),
..Default::default()
},
WindowAction::Ignore,
)],
WindowAction::Tile,
);
let c = candidate("explorer.exe", "Open File - Explorer", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_title_contains_case_sensitive_mismatch() {
let p = pipeline_from(
vec![rule(
MatchRule {
title_contains: Some("SETTINGS".into()),
..Default::default()
},
WindowAction::Float,
)],
WindowAction::Tile,
);
let c = candidate("settings.exe", "Windows Settings", "", "");
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_all_fields_and_logic() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("chrome.exe".into()),
class: Some("Chrome_WidgetWin_1".into()),
title: Some("New Tab - Google Chrome".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate(
"chrome.exe",
"New Tab - Google Chrome",
"Chrome_WidgetWin_1",
"C:\\Program Files\\Google\\Chrome\\chrome.exe",
);
assert_eq!(p.classify(&c), WindowAction::Tile);
let c2 = candidate(
"chrome.exe",
"New Tab - Google Chrome",
"SomeOtherClass",
"",
);
assert_eq!(p.classify(&c2), WindowAction::Ignore);
}
#[test]
fn pipeline_first_match_wins() {
let p = pipeline_from(
vec![
rule(
MatchRule {
exe: Some("chrome.exe".into()),
..Default::default()
},
WindowAction::Ignore,
),
rule(
MatchRule {
exe: Some("chrome.exe".into()),
..Default::default()
},
WindowAction::Tile,
),
],
WindowAction::Float,
);
let c = candidate("chrome.exe", "", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_default_action_when_no_rule_matches() {
let p = pipeline_from(vec![], WindowAction::Tile);
let c = candidate("unknown.exe", "Some Title", "", "");
assert_eq!(p.classify(&c), WindowAction::Tile);
let p2 = pipeline_from(vec![], WindowAction::Float);
assert_eq!(p2.classify(&c), WindowAction::Float);
}
#[test]
fn pipeline_window_candidate_with_empty_strings_no_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("notepad.exe".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("", "", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_rule_with_all_fields_specified_matches() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("app.exe".into()),
title: Some("Main Window".into()),
class: Some("AppClass".into()),
process_path: Some("C:\\Apps\\app.exe".into()),
..Default::default()
},
WindowAction::Float,
)],
WindowAction::Tile,
);
let c = candidate("app.exe", "Main Window", "AppClass", "C:\\Apps\\app.exe");
assert_eq!(p.classify(&c), WindowAction::Float);
}
#[test]
fn pipeline_rule_with_all_fields_specified_partial_mismatch() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("app.exe".into()),
title: Some("Main Window".into()),
class: Some("AppClass".into()),
process_path: Some("C:\\Apps\\app.exe".into()),
..Default::default()
},
WindowAction::Float,
)],
WindowAction::Tile,
);
let c = candidate("app.exe", "Main Window", "AppClass", "D:\\Other\\app.exe");
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn title_exact_case_sensitive() {
let rule = MatchRule {
title: Some("Calculator".into()),
..Default::default()
};
let c = candidate("calc.exe", "Calculator", "", "");
assert!(matches_rule(&c, &rule));
let c2 = candidate("calc.exe", "calculator", "", "");
assert!(!matches_rule(&c2, &rule));
}
#[test]
fn class_exact_case_sensitive() {
let rule = MatchRule {
class: Some("Chrome_WidgetWin_1".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "Chrome_WidgetWin_1", "");
assert!(matches_rule(&c, &rule));
let c2 = candidate("chrome.exe", "", "chrome_widgetwin_1", "");
assert!(!matches_rule(&c2, &rule));
}
#[test]
fn process_path_case_insensitive() {
let rule = MatchRule {
process_path: Some("C:\\Windows\\System32\\calc.exe".into()),
..Default::default()
};
let c = candidate("calc.exe", "", "", "C:\\Windows\\System32\\calc.exe");
assert!(matches_rule(&c, &rule));
let c2 = candidate("calc.exe", "", "", "C:\\Windows\\System32\\Calc.exe");
assert!(matches_rule(&c2, &rule));
}
#[test]
fn title_regex_matches() {
let rule = MatchRule {
title_regex: Some("^Settings".into()),
..Default::default()
};
let c = candidate("settings.exe", "Settings - Display", "", "");
assert!(matches_rule(&c, &rule));
let c2 = candidate("explorer.exe", "Windows Settings", "", "");
assert!(!matches_rule(&c2, &rule));
}
#[test]
fn exe_regex_matches_case_insensitive() {
let rule = MatchRule {
exe_regex: Some("chrome\\.exe".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "", "");
assert!(matches_rule(&c, &rule));
let c2 = candidate("Chrome.EXE", "", "", "");
assert!(matches_rule(&c2, &rule));
}
#[test]
fn class_regex_matches() {
let rule = MatchRule {
class_regex: Some("Chrome.*".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "Chrome_WidgetWin_1", "");
assert!(matches_rule(&c, &rule));
let c2 = candidate("chrome.exe", "", "chrome_widgetwin_1", "");
assert!(!matches_rule(&c2, &rule));
}
#[test]
fn process_path_regex_matches_case_insensitive() {
let rule = MatchRule {
process_path_regex: Some(".*\\\\Google\\\\Chrome\\\\.*".into()),
..Default::default()
};
let c = candidate(
"chrome.exe",
"",
"",
"C:\\Program Files\\Google\\Chrome\\chrome.exe",
);
assert!(matches_rule(&c, &rule));
let c2 = candidate(
"chrome.exe",
"",
"",
"c:\\program files\\google\\chrome\\chrome.exe",
);
assert!(matches_rule(&c2, &rule));
}
#[test]
fn invalid_title_regex_returns_false() {
let rule = MatchRule {
title_regex: Some("[invalid(".into()),
..Default::default()
};
let c = candidate("test.exe", "anything", "", "");
assert!(!matches_rule(&c, &rule));
}
#[test]
fn invalid_exe_regex_returns_false() {
let rule = MatchRule {
exe_regex: Some("[invalid(".into()),
..Default::default()
};
let c = candidate("test.exe", "", "", "");
assert!(!matches_rule(&c, &rule));
}
#[test]
fn invalid_class_regex_returns_false() {
let rule = MatchRule {
class_regex: Some("[invalid(".into()),
..Default::default()
};
let c = candidate("test.exe", "", "SomeClass", "");
assert!(!matches_rule(&c, &rule));
}
#[test]
fn invalid_process_path_regex_returns_false() {
let rule = MatchRule {
process_path_regex: Some("[invalid(".into()),
..Default::default()
};
let c = candidate("test.exe", "", "", "C:\\path\\test.exe");
assert!(!matches_rule(&c, &rule));
}
#[test]
fn class_regex_inline_flag_i_overrides_case_sensitivity() {
let rule = MatchRule {
class_regex: Some("(?i)chrome_widgetwin_1".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "chrome_widgetwin_1", "");
assert!(
matches_rule(&c, &rule),
"(?i) should make class_regex case-insensitive"
);
let c2 = candidate("chrome.exe", "", "CHROME_WIDGETWIN_1", "");
assert!(matches_rule(&c2, &rule), "(?i) should match uppercase too");
}
#[test]
fn exe_regex_inline_flag_neg_i_opts_into_case_sensitive() {
let rule = MatchRule {
exe_regex: Some("(?-i)Chrome.exe".into()),
..Default::default()
};
let c = candidate("Chrome.exe", "", "", "");
assert!(
matches_rule(&c, &rule),
"exact case should match with (?-i)"
);
let c2 = candidate("chrome.exe", "", "", "");
assert!(
!matches_rule(&c2, &rule),
"different case should not match with (?-i)"
);
}
#[test]
fn unspecified_fields_ignored() {
let rule = MatchRule {
exe: Some("code.exe".into()),
..Default::default()
};
let c = candidate("code.exe", "", "", "");
assert!(matches_rule(&c, &rule));
}
#[test]
fn classify_with_state_pipeline_maximized_override() {
let pipeline = pipeline_from(vec![], WindowAction::Tile);
let c = candidate("code.exe", "main.rs", "", "");
let state = classify_with_state_pipeline(&c, true, false, &pipeline);
assert!(matches!(
state,
WindowState::Ignored(IgnoredReason::Maximized)
));
}
#[test]
fn classify_with_state_pipeline_fullscreen_override() {
let pipeline = pipeline_from(vec![], WindowAction::Tile);
let c = candidate("game.exe", "Game", "", "");
let state = classify_with_state_pipeline(&c, false, true, &pipeline);
assert!(matches!(
state,
WindowState::Ignored(IgnoredReason::Fullscreen)
));
}
#[test]
fn classify_with_state_pipeline_maximize_takes_precedence_over_fullscreen() {
let pipeline = pipeline_from(vec![], WindowAction::Tile);
let c = candidate("code.exe", "", "", "");
let state = classify_with_state_pipeline(&c, true, true, &pipeline);
assert!(matches!(
state,
WindowState::Ignored(IgnoredReason::Maximized)
));
}
#[test]
fn classify_with_state_pipeline_tile_action() {
let pipeline = pipeline_from(
vec![rule(
MatchRule {
exe: Some("code.exe".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("code.exe", "", "", "");
let state = classify_with_state_pipeline(&c, false, false, &pipeline);
assert!(matches!(
state,
WindowState::Tiling(TilingState::Active { col: 0, row: 0 })
));
}
#[test]
fn classify_with_state_pipeline_float_action() {
let pipeline = pipeline_from(
vec![rule(
MatchRule {
exe: Some("steam.exe".into()),
..Default::default()
},
WindowAction::Float,
)],
WindowAction::Tile,
);
let c = candidate("steam.exe", "", "", "");
let state = classify_with_state_pipeline(&c, false, false, &pipeline);
assert!(matches!(
state,
WindowState::Floating(FloatingState::Active { rect: _ })
));
}
#[test]
fn classify_with_state_pipeline_ignore_action() {
let pipeline = pipeline_from(
vec![rule(
MatchRule {
exe: Some("explorer.exe".into()),
..Default::default()
},
WindowAction::Ignore,
)],
WindowAction::Tile,
);
let c = candidate("explorer.exe", "", "", "");
let state = classify_with_state_pipeline(&c, false, false, &pipeline);
assert!(matches!(
state,
WindowState::Ignored(IgnoredReason::ExplicitRule)
));
}
#[test]
fn classify_with_state_pipeline_default_used() {
let pipeline = pipeline_from(vec![], WindowAction::Tile);
let c = candidate("unknown.exe", "", "", "");
let state = classify_with_state_pipeline(&c, false, false, &pipeline);
assert!(matches!(
state,
WindowState::Tiling(TilingState::Active { col: 0, row: 0 })
));
}
#[test]
fn classify_with_state_pipeline_empty_candidate() {
let pipeline = pipeline_from(vec![], WindowAction::Tile);
let c = candidate("", "", "", "");
let state = classify_with_state_pipeline(&c, false, false, &pipeline);
assert!(matches!(
state,
WindowState::Tiling(TilingState::Active { col: 0, row: 0 })
));
}
#[test]
fn action_to_state_tile() {
let state = action_to_state(WindowAction::Tile);
assert!(matches!(
state,
WindowState::Tiling(TilingState::Active { col: 0, row: 0 })
));
}
#[test]
fn action_to_state_float() {
let state = action_to_state(WindowAction::Float);
assert!(matches!(
state,
WindowState::Floating(FloatingState::Active {
rect: Rect {
x: 0,
y: 0,
width: 0,
height: 0
}
})
));
}
#[test]
fn action_to_state_ignore() {
let state = action_to_state(WindowAction::Ignore);
assert!(matches!(
state,
WindowState::Ignored(IgnoredReason::ExplicitRule)
));
}
#[test]
fn pipeline_user_rule_takes_priority_over_default() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![rule(
MatchRule {
exe: Some("chrome.exe".into()),
..Default::default()
},
WindowAction::Ignore,
)],
};
let default_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![rule(
MatchRule {
exe: Some("chrome.exe".into()),
..Default::default()
},
WindowAction::Float,
)],
};
let pipeline = ClassificationPipeline::new(user_rules, default_rules);
let c = candidate("chrome.exe", "", "", "");
assert_eq!(pipeline.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_falls_through_to_default_rules() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![], };
let default_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![rule(
MatchRule {
exe: Some("firefox.exe".into()),
..Default::default()
},
WindowAction::Float,
)],
};
let pipeline = ClassificationPipeline::new(user_rules, default_rules);
let c = candidate("firefox.exe", "", "", "");
assert_eq!(pipeline.classify(&c), WindowAction::Float);
}
#[test]
fn pipeline_falls_through_to_default_action() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Float,
rules: vec![],
};
let default_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![],
};
let pipeline = ClassificationPipeline::new(user_rules, default_rules);
let c = candidate("unknown.exe", "", "", "");
assert_eq!(pipeline.classify(&c), WindowAction::Float);
}
#[test]
fn pipeline_learned_rules_slot_is_noop_when_empty() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Float,
rules: vec![],
};
let default_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![],
};
let pipeline = ClassificationPipeline::new(user_rules, default_rules);
let c = candidate("anything.exe", "", "", "");
assert_eq!(pipeline.classify(&c), WindowAction::Float);
}
#[test]
fn set_learned_rules_classifies_with_learned_rule() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![],
};
let default_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![],
};
let mut pipeline = ClassificationPipeline::new(user_rules, default_rules);
pipeline.set_learned_rules(vec![rule(
MatchRule {
exe: Some("test.exe".into()),
..Default::default()
},
WindowAction::Float,
)]);
let c = candidate("test.exe", "", "", "");
assert_eq!(
pipeline.classify(&c),
WindowAction::Float,
"learned rule should classify test.exe as Float"
);
}
#[test]
fn set_learned_rules_user_rules_still_win() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![rule(
MatchRule {
exe: Some("test.exe".into()),
..Default::default()
},
WindowAction::Tile,
)],
};
let default_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![],
};
let mut pipeline = ClassificationPipeline::new(user_rules, default_rules);
pipeline.set_learned_rules(vec![rule(
MatchRule {
exe: Some("test.exe".into()),
..Default::default()
},
WindowAction::Float,
)]);
let c = candidate("test.exe", "", "", "");
assert_eq!(
pipeline.classify(&c),
WindowAction::Tile,
"user rules should take priority over learned rules"
);
}
#[test]
fn set_learned_rules_beats_default_rules() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![],
};
let default_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![rule(
MatchRule {
exe: Some("test.exe".into()),
..Default::default()
},
WindowAction::Ignore,
)],
};
let mut pipeline = ClassificationPipeline::new(user_rules, default_rules);
pipeline.set_learned_rules(vec![rule(
MatchRule {
exe: Some("test.exe".into()),
..Default::default()
},
WindowAction::Float,
)]);
let c = candidate("test.exe", "", "", "");
assert_eq!(
pipeline.classify(&c),
WindowAction::Float,
"learned rules should take priority over default rules"
);
}
#[test]
fn set_learned_rules_replaces_previous() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![],
};
let default_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![],
};
let mut pipeline = ClassificationPipeline::new(user_rules, default_rules);
pipeline.set_learned_rules(vec![rule(
MatchRule {
exe: Some("test.exe".into()),
..Default::default()
},
WindowAction::Float,
)]);
let c = candidate("test.exe", "", "", "");
assert_eq!(pipeline.classify(&c), WindowAction::Float);
pipeline.set_learned_rules(vec![rule(
MatchRule {
exe: Some("test.exe".into()),
..Default::default()
},
WindowAction::Tile,
)]);
assert_eq!(
pipeline.classify(&c),
WindowAction::Tile,
"second set_learned_rules should fully replace the first"
);
}
#[test]
fn pipeline_empty_user_and_default_returns_user_default_action() {
let user_rules = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![],
};
let default_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![],
};
let pipeline = ClassificationPipeline::new(user_rules, default_rules);
let c = candidate("unknown.exe", "Some Title", "SomeClass", "");
assert_eq!(pipeline.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_embedded_default_rules_classify_taskbar_as_ignore() {
use crate::config::lifecycle::load_default_rules;
let user_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![],
};
let default_rules = load_default_rules();
let pipeline = ClassificationPipeline::new(user_rules, default_rules);
let taskbar = candidate("explorer.exe", "", "Shell_TrayWnd", "");
assert_eq!(
pipeline.classify(&taskbar),
WindowAction::Ignore,
"embedded default rules should classify Shell_TrayWnd as Ignore"
);
}
#[test]
fn pipeline_user_rule_overrides_embedded_default_for_taskbar() {
use crate::config::lifecycle::load_default_rules;
let user_rules = WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![rule(
MatchRule {
class: Some("Shell_TrayWnd".into()),
..Default::default()
},
WindowAction::Tile,
)],
};
let default_rules = load_default_rules();
let pipeline = ClassificationPipeline::new(user_rules, default_rules);
let taskbar = candidate("explorer.exe", "", "Shell_TrayWnd", "");
assert_eq!(
pipeline.classify(&taskbar),
WindowAction::Tile,
"user rule should override embedded default for Shell_TrayWnd"
);
}
#[test]
fn pipeline_exe_regex_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe_regex: Some("chrome\\.exe".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("chrome.exe", "", "", "");
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_exe_regex_case_insensitive() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe_regex: Some("CHROME\\.EXE".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("chrome.exe", "", "", "");
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_exe_regex_mismatch_falls_through() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe_regex: Some("firefox\\.exe".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("chrome.exe", "", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_title_regex_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
title_regex: Some("^Settings".into()),
..Default::default()
},
WindowAction::Float,
)],
WindowAction::Tile,
);
let c = candidate("settings.exe", "Settings - Display", "", "");
assert_eq!(p.classify(&c), WindowAction::Float);
}
#[test]
fn pipeline_title_regex_case_sensitive_mismatch() {
let p = pipeline_from(
vec![rule(
MatchRule {
title_regex: Some("^Settings".into()),
..Default::default()
},
WindowAction::Float,
)],
WindowAction::Tile,
);
let c = candidate("settings.exe", "settings - display", "", "");
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_class_regex_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
class_regex: Some("Chrome.*".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("chrome.exe", "", "Chrome_WidgetWin_1", "");
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_class_regex_case_sensitive_mismatch() {
let p = pipeline_from(
vec![rule(
MatchRule {
class_regex: Some("Chrome.*".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("chrome.exe", "", "chrome_widgetwin_1", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_process_path_regex_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
process_path_regex: Some(".*\\\\Google\\\\Chrome\\\\.*".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate(
"chrome.exe",
"",
"",
"C:\\Program Files\\Google\\Chrome\\chrome.exe",
);
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_process_path_regex_case_insensitive() {
let p = pipeline_from(
vec![rule(
MatchRule {
process_path_regex: Some(".*\\\\google\\\\chrome\\\\.*".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate(
"chrome.exe",
"",
"",
"C:\\Program Files\\Google\\Chrome\\chrome.exe",
);
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_process_path_regex_mismatch_falls_through() {
let p = pipeline_from(
vec![rule(
MatchRule {
process_path_regex: Some(".*\\\\Firefox\\\\.*".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate(
"chrome.exe",
"",
"",
"C:\\Program Files\\Google\\Chrome\\chrome.exe",
);
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_invalid_exe_regex_treated_as_non_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe_regex: Some("[invalid(".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("anything.exe", "", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_invalid_title_regex_treated_as_non_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
title_regex: Some("[invalid(".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("anything.exe", "Some Title", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_invalid_class_regex_treated_as_non_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
class_regex: Some("[invalid(".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("anything.exe", "", "SomeClass", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_invalid_process_path_regex_treated_as_non_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
process_path_regex: Some("[invalid(".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("anything.exe", "", "", "C:\\path\\anything.exe");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_all_invalid_regex_fields_treated_as_non_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe_regex: Some("[bad[".into()),
title_regex: Some("(?broken".into()),
class_regex: Some("[[[".into()),
process_path_regex: Some("*invalid".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("test.exe", "Title", "Class", "C:\\path\\test.exe");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_mixed_exact_and_regex_both_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("code.exe".into()),
title_regex: Some(".*\\.rs - .+".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("code.exe", "main.rs - My Project", "", "");
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_mixed_exact_and_regex_regex_mismatch() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("code.exe".into()),
title_regex: Some(".*\\.rs - .+".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("code.exe", "settings.json - My Project", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_mixed_exact_and_regex_exact_mismatch() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("code.exe".into()),
title_regex: Some(".*\\.rs - .+".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate("other.exe", "main.rs - My Project", "", "");
assert_eq!(p.classify(&c), WindowAction::Ignore);
}
#[test]
fn pipeline_all_regex_fields_plus_exact_match() {
let p = pipeline_from(
vec![rule(
MatchRule {
exe: Some("chrome.exe".into()),
title_regex: Some("New Tab.*".into()),
exe_regex: Some("chrome\\.exe".into()),
class_regex: Some("Chrome_WidgetWin_\\d+".into()),
process_path_regex: Some(".*\\\\Chrome\\\\.*".into()),
..Default::default()
},
WindowAction::Tile,
)],
WindowAction::Ignore,
);
let c = candidate(
"chrome.exe",
"New Tab - Google Chrome",
"Chrome_WidgetWin_1",
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
);
assert_eq!(p.classify(&c), WindowAction::Tile);
}
#[test]
fn pipeline_mixed_title_contains_and_class_regex() {
let p = pipeline_from(
vec![rule(
MatchRule {
title_contains: Some("Visual Studio Code".into()),
class_regex: Some("Chrome_WidgetWin_1".into()),
..Default::default()
},
WindowAction::Float,
)],
WindowAction::Tile,
);
let c = candidate(
"code.exe",
"main.rs - Visual Studio Code",
"Chrome_WidgetWin_1",
"",
);
assert_eq!(p.classify(&c), WindowAction::Float);
}
fn check_equivalence(match_rule: &MatchRule, c: &WindowCandidate) -> bool {
let r = rule(match_rule.clone(), WindowAction::Tile);
let compiled_rules = compile_rules(vec![r]);
assert_eq!(
compiled_rules.len(),
1,
"compile_rules should return exactly 1 rule"
);
let runtime = matches_rule(c, match_rule);
let compiled = matches_compiled_rule(c, &compiled_rules[0]);
runtime == compiled
}
#[test]
fn equivalence_exe_regex_positive() {
let mr = MatchRule {
exe_regex: Some("chrome\\.exe".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "", "");
assert!(check_equivalence(&mr, &c));
assert!(matches_rule(&c, &mr), "guard: should match");
}
#[test]
fn equivalence_exe_regex_negative() {
let mr = MatchRule {
exe_regex: Some("firefox\\.exe".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "", "");
assert!(check_equivalence(&mr, &c));
assert!(!matches_rule(&c, &mr), "guard: should not match");
}
#[test]
fn equivalence_exe_regex_case_insensitive() {
let mr = MatchRule {
exe_regex: Some("CHROME\\.EXE".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "", "");
assert!(check_equivalence(&mr, &c));
assert!(
matches_rule(&c, &mr),
"guard: case-insensitive should match"
);
}
#[test]
fn equivalence_title_regex_positive() {
let mr = MatchRule {
title_regex: Some("^Settings.*".into()),
..Default::default()
};
let c = candidate("app.exe", "Settings - Display", "", "");
assert!(check_equivalence(&mr, &c));
assert!(matches_rule(&c, &mr), "guard: should match");
}
#[test]
fn equivalence_title_regex_negative() {
let mr = MatchRule {
title_regex: Some("^Settings.*".into()),
..Default::default()
};
let c = candidate("app.exe", "Display - Settings", "", "");
assert!(check_equivalence(&mr, &c));
assert!(
!matches_rule(&c, &mr),
"guard: should not match (not at start)"
);
}
#[test]
fn equivalence_class_regex_positive() {
let mr = MatchRule {
class_regex: Some("Chrome_WidgetWin_\\d+".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "Chrome_WidgetWin_1", "");
assert!(check_equivalence(&mr, &c));
assert!(matches_rule(&c, &mr), "guard: should match");
}
#[test]
fn equivalence_class_regex_negative() {
let mr = MatchRule {
class_regex: Some("Chrome_WidgetWin_\\d+".into()),
..Default::default()
};
let c = candidate("chrome.exe", "", "Chrome_WidgetWin_", "");
assert!(check_equivalence(&mr, &c));
assert!(!matches_rule(&c, &mr), "guard: should not match (no digit)");
}
#[test]
fn equivalence_process_path_regex_positive() {
let mr = MatchRule {
process_path_regex: Some(".*\\\\Chrome\\\\.*".into()),
..Default::default()
};
let c = candidate(
"chrome.exe",
"",
"",
"C:\\Program Files\\Chrome\\chrome.exe",
);
assert!(check_equivalence(&mr, &c));
assert!(matches_rule(&c, &mr), "guard: should match");
}
#[test]
fn equivalence_process_path_regex_negative() {
let mr = MatchRule {
process_path_regex: Some(".*\\\\Chrome\\\\.*".into()),
..Default::default()
};
let c = candidate(
"chrome.exe",
"",
"",
"C:\\Program Files\\Firefox\\firefox.exe",
);
assert!(check_equivalence(&mr, &c));
assert!(!matches_rule(&c, &mr), "guard: should not match");
}
#[test]
fn equivalence_process_path_regex_case_insensitive() {
let mr = MatchRule {
process_path_regex: Some(".*\\\\chrome\\\\.*".into()),
..Default::default()
};
let c = candidate(
"chrome.exe",
"",
"",
"C:\\Program Files\\Chrome\\chrome.exe",
);
assert!(check_equivalence(&mr, &c));
assert!(
matches_rule(&c, &mr),
"guard: case-insensitive should match"
);
}
#[test]
fn equivalence_invalid_regex_both_return_false() {
let mr = MatchRule {
exe_regex: Some("[invalid(".into()),
..Default::default()
};
let c = candidate("test.exe", "", "", "");
assert!(check_equivalence(&mr, &c));
assert!(
!matches_rule(&c, &mr),
"guard: invalid regex should return false"
);
}
#[test]
fn equivalence_all_invalid_regex_fields_both_return_false() {
let mr = MatchRule {
exe_regex: Some("[bad[".into()),
title_regex: Some("(?broken".into()),
class_regex: Some("[[[".into()),
process_path_regex: Some("*invalid".into()),
..Default::default()
};
let c = candidate("test.exe", "Title", "Class", "C:\\path\\test.exe");
assert!(check_equivalence(&mr, &c));
assert!(!matches_rule(&c, &mr), "guard: all invalid → false");
}
#[test]
fn equivalence_mixed_exact_and_regex_both_match() {
let mr = MatchRule {
exe: Some("code.exe".into()),
title_regex: Some(".*\\.rs - .+".into()),
..Default::default()
};
let c = candidate("code.exe", "main.rs - My Project", "", "");
assert!(check_equivalence(&mr, &c));
assert!(matches_rule(&c, &mr), "guard: both fields match");
}
#[test]
fn equivalence_mixed_exact_and_regex_partial_mismatch() {
let mr = MatchRule {
exe: Some("code.exe".into()),
title_regex: Some(".*\\.rs - .+".into()),
..Default::default()
};
let c = candidate("code.exe", "settings.json - My Project", "", "");
assert!(check_equivalence(&mr, &c));
assert!(!matches_rule(&c, &mr), "guard: AND logic → false");
}
#[test]
fn equivalence_comprehensive_all_fields() {
let mr = MatchRule {
exe: Some("chrome.exe".into()),
exe_regex: Some("chrome\\.exe".into()),
title: Some("New Tab - Google Chrome".into()),
title_contains: Some("New Tab".into()),
title_regex: Some("New Tab.*Chrome".into()),
class: Some("Chrome_WidgetWin_1".into()),
class_regex: Some("Chrome_WidgetWin_\\d+".into()),
process_path: Some("C:\\Program Files\\Google\\Chrome\\chrome.exe".into()),
process_path_regex: Some(".*\\\\Chrome\\\\.*".into()),
};
let c = candidate(
"chrome.exe",
"New Tab - Google Chrome",
"Chrome_WidgetWin_1",
"C:\\Program Files\\Google\\Chrome\\chrome.exe",
);
assert!(check_equivalence(&mr, &c));
assert!(matches_rule(&c, &mr), "guard: all fields should match");
}
#[test]
fn set_user_rules_replaces_default_action() {
let mut pipeline = pipeline_from(vec![], WindowAction::Tile);
let unknown = candidate("unknown.exe", "", "", "");
assert_eq!(pipeline.classify(&unknown), WindowAction::Tile);
pipeline.set_user_rules(WindowRulesConfig {
default_action: WindowAction::Float,
rules: vec![],
});
assert_eq!(
pipeline.classify(&unknown),
WindowAction::Float,
"set_user_rules must refresh default_action"
);
}
#[test]
fn set_user_rules_replaces_the_user_rule_list() {
let mut pipeline = pipeline_from(
vec![rule(
MatchRule {
exe: Some("old.exe".into()),
..Default::default()
},
WindowAction::Ignore,
)],
WindowAction::Tile,
);
assert_eq!(
pipeline.classify(&candidate("old.exe", "", "", "")),
WindowAction::Ignore
);
pipeline.set_user_rules(WindowRulesConfig {
default_action: WindowAction::Tile,
rules: vec![rule(
MatchRule {
exe: Some("new.exe".into()),
..Default::default()
},
WindowAction::Float,
)],
});
assert_eq!(
pipeline.classify(&candidate("new.exe", "", "", "")),
WindowAction::Float,
"set_user_rules must install the reloaded rule list"
);
assert_eq!(
pipeline.classify(&candidate("old.exe", "", "", "")),
WindowAction::Tile,
"set_user_rules must drop rules absent from the reload"
);
}
}