From a7f9ef9e4231230906ab6c352c52bf1117008ecb Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 25 May 2026 13:04:18 -0400 Subject: [PATCH] Add allowed context matching --- src/context.rs | 157 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/src/context.rs b/src/context.rs index 4e8c66d..b772a1f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1 +1,156 @@ -// Placeholder for upcoming context module. +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")); + } +}