use crate::domain::AllowedContext; pub fn domain_allowed(context: &AllowedContext, candidate: &str) -> bool { let candidate = normalize_domain(candidate); if candidate.is_empty() { return false; } context.domains.iter().any(|allowed| { let allowed = normalize_domain(allowed); !allowed.is_empty() && (candidate == allowed || candidate .strip_suffix(&allowed) .is_some_and(|prefix| prefix.ends_with('.'))) }) } pub fn window_class_allowed(context: &AllowedContext, candidate: &str) -> bool { let candidate = normalize_casefolded(candidate); if candidate.is_empty() { return false; } context .window_classes .iter() .any(|allowed| normalize_casefolded(allowed) == candidate) } pub fn window_title_allowed(context: &AllowedContext, candidate: &str) -> bool { let candidate = normalize_casefolded(candidate); if candidate.is_empty() { return false; } context.window_title_substrings.iter().any(|allowed| { let allowed = normalize_casefolded(allowed); !allowed.is_empty() && candidate.contains(&allowed) }) } pub fn command_allowed(context: &AllowedContext, candidate: &str) -> bool { let candidate = executable_basename(candidate); if candidate.is_empty() { return false; } context .commands .iter() .any(|allowed| executable_basename(allowed) == candidate) } fn normalize_domain(value: &str) -> String { value.trim().trim_end_matches('.').to_lowercase() } fn normalize_casefolded(value: &str) -> String { value.trim().to_lowercase() } fn executable_basename(command: &str) -> String { let executable = command.split_whitespace().next().unwrap_or_default(); executable .trim() .rsplit(['/', '\\']) .next() .unwrap_or_default() .to_lowercase() } #[cfg(test)] mod tests { use super::*; use crate::domain::AllowedContext; #[test] fn domains_match_exact_or_subdomain() { let context = AllowedContext { domains: vec!["github.com".to_string()], ..AllowedContext::default() }; assert!(domain_allowed(&context, "github.com")); assert!(domain_allowed(&context, "docs.github.com")); assert!(!domain_allowed(&context, "evilgithub.com")); } #[test] fn window_classes_match_case_insensitively_after_normalization() { let context = AllowedContext { window_classes: vec!["code".to_string()], ..AllowedContext::default() }; assert!(window_class_allowed(&context, "Code")); assert!(!window_class_allowed(&context, "firefox")); } #[test] fn titles_match_by_substring() { let context = AllowedContext { window_title_substrings: vec!["antidrift".to_string()], ..AllowedContext::default() }; assert!(window_title_allowed(&context, "Commitment OS - AntiDrift")); assert!(!window_title_allowed(&context, "random video")); } #[test] fn domains_ignore_whitespace_case_and_trailing_dots() { let context = AllowedContext { domains: vec![" GitHub.COM. ".to_string()], ..AllowedContext::default() }; assert!(domain_allowed(&context, " DOCS.GITHUB.COM. ")); } #[test] fn window_class_matching_trims_allowed_and_candidate_values() { let context = AllowedContext { window_classes: vec![" code ".to_string()], ..AllowedContext::default() }; assert!(window_class_allowed(&context, " Code ")); } #[test] fn title_matching_trims_allowed_substrings() { let context = AllowedContext { window_title_substrings: vec![" antidrift ".to_string()], ..AllowedContext::default() }; assert!(window_title_allowed(&context, "Commitment OS - AntiDrift")); } #[test] fn commands_match_executable_basename() { let context = AllowedContext { commands: vec!["cargo".to_string()], ..AllowedContext::default() }; assert!(command_allowed( &context, "/home/felixm/.cargo/bin/cargo test" )); assert!(command_allowed(&context, "cargo")); assert!(!command_allowed(&context, "/usr/bin/git status")); } }