Skip to main content

entracte_lib/
proc_match.rs

1//! Shared process-name matching. Used by the run loop's app-pause guard and
2//! by the local-context detector probes, so both agree on what "process X is
3//! running" means — in particular both reject substring collisions
4//! (`zoom` must not match `zoominfo`) while allowing digit-versioned
5//! binaries (`obs` matches `obs64`).
6
7/// Case-insensitive token match assuming **both** arguments are already
8/// lowercased. A single-token target (all alphanumeric, e.g. `zoom`) matches
9/// a whole token of the process name, or a token that differs only by a
10/// trailing digit run (`obs64`); a multi-token target (e.g. `osascript -e`)
11/// falls back to substring so power-users can match a distinctive snippet.
12///
13/// The run loop pre-lowercases its target list once at settings load
14/// (`derived.app_pause_targets_lower`) and only lowercases the live process
15/// name per scan, so the hot path never re-lowercases every configured
16/// target for every running process.
17pub fn process_match_lower(running_lower: &str, target_lower: &str) -> bool {
18    if target_lower.is_empty() {
19        return false;
20    }
21    let target_is_single_token = target_lower.chars().all(|c| c.is_alphanumeric());
22    if !target_is_single_token {
23        return running_lower.contains(target_lower);
24    }
25    running_lower
26        .split(|c: char| !c.is_alphanumeric())
27        .any(|tok| {
28            if tok == target_lower {
29                return true;
30            }
31            if let Some(suffix) = tok.strip_prefix(target_lower) {
32                !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
33            } else {
34                false
35            }
36        })
37}
38
39#[cfg(test)]
40mod tests {
41    use super::process_match_lower;
42
43    /// Convenience wrapper that lowercases both sides, so the cases below read
44    /// naturally regardless of the production hot path's pre-lowercasing.
45    fn process_match(running: &str, target: &str) -> bool {
46        process_match_lower(&running.to_lowercase(), &target.to_lowercase())
47    }
48
49    #[test]
50    fn matches_whole_token() {
51        assert!(process_match("zoom.us", "zoom"));
52        assert!(process_match("OBS Studio", "obs"));
53        assert!(process_match("zoom", "zoom"));
54        assert!(process_match("Zoom Meeting Helper", "zoom"));
55    }
56
57    #[test]
58    fn rejects_substring_collisions() {
59        assert!(!process_match("zoominfo.exe", "zoom"));
60        assert!(!process_match("azoomatic", "zoom"));
61        assert!(!process_match("doomsday", "doom"));
62    }
63
64    #[test]
65    fn allows_digit_versioned_binaries() {
66        assert!(process_match("obs64.exe", "obs"));
67        assert!(process_match("OBS32.exe", "obs"));
68        assert!(process_match("firefox64.exe", "firefox"));
69        assert!(!process_match("firefoxnightly.exe", "firefox"));
70    }
71
72    #[test]
73    fn rejects_unrelated_apps() {
74        assert!(!process_match("safari", "zoom"));
75        assert!(!process_match("", "zoom"));
76    }
77
78    #[test]
79    fn falls_back_to_substring_for_multi_token_targets() {
80        assert!(process_match("/usr/bin/osascript -e foo", "osascript -e"));
81        assert!(!process_match("osascript", "osascript -e"));
82    }
83
84    #[test]
85    fn empty_target_never_matches() {
86        assert!(!process_match("zoom.us", ""));
87        assert!(!process_match("", ""));
88    }
89}