Skip to main content

proc_cli/core/
name_match.rs

1//! How a name target (and a `--by` filter) is compared with a process.
2//!
3//! By default a name matches a process the way `pkill -x` does, except that case
4//! is ignored: the whole name, equal ignoring case. `--cmd` matches the way `pkill -f` does:
5//! the text anywhere in the name or the full command line. The default is the
6//! safe one: `proc kill node` must not reach "Claude Helper" or an editor
7//! whose command line happens to mention node.
8
9/// Which comparison a name target uses
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum NameMatch {
12    /// The text equals the process name, the file name of its executable, or
13    /// the file name of its first argument, ignoring case (the default)
14    #[default]
15    Exact,
16    /// The text is a substring of the process name or the full command line,
17    /// ignoring case (`--cmd`)
18    Command,
19}
20
21impl NameMatch {
22    /// The comparison a command uses, given whether `--cmd` was passed
23    pub fn from_flag(cmd: bool) -> Self {
24        if cmd {
25            NameMatch::Command
26        } else {
27            NameMatch::Exact
28        }
29    }
30}
31
32/// Everything about a process a name can be compared with
33#[derive(Debug, Clone, Copy, Default)]
34pub struct Names<'a> {
35    /// The process name the OS reports (on Linux cut to 15 characters)
36    pub name: &'a str,
37    /// Path of the executable, when the OS would tell
38    pub exe: Option<&'a str>,
39    /// The first word of the command line, when the OS would tell
40    pub argv0: Option<&'a str>,
41    /// The whole command line, when the OS would tell
42    pub command: Option<&'a str>,
43}
44
45/// How a name relates to a process
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Hit {
48    /// It matches under the comparison in use
49    Match,
50    /// It does not, but the text is in the command line, so `--cmd` would match
51    CommandLineOnly,
52    /// It matches neither way
53    No,
54}
55
56/// The last component of a path. A backslash separates components only on
57/// Windows: on Unix it can be part of a file name.
58fn base_name(path: &str, windows: bool) -> &str {
59    path.rsplit(|c| c == '/' || (windows && c == '\\'))
60        .next()
61        .unwrap_or(path)
62}
63
64/// `name` without a trailing `.exe`, in any case
65fn without_exe(name: &str) -> &str {
66    match name.len().checked_sub(4).and_then(|at| name.get(at..)) {
67        Some(tail) if tail.eq_ignore_ascii_case(".exe") => &name[..name.len() - 4],
68        _ => name,
69    }
70}
71
72/// Whether two names are the same ignoring case; on Windows a trailing `.exe`
73/// is optional on either side
74fn same_name(a: &str, b: &str, windows: bool) -> bool {
75    let (a, b) = if windows {
76        (without_exe(a), without_exe(b))
77    } else {
78        (a, b)
79    };
80    a.to_lowercase() == b.to_lowercase()
81}
82
83/// Whether `pattern` is the process name, the file name of its executable or
84/// the file name of its first argument
85///
86/// The two extra names are there because Linux cuts the process name to 15
87/// characters, so a longer executable name is only found through them.
88fn is_exact(pattern: &str, names: &Names<'_>, windows: bool) -> bool {
89    same_name(pattern, names.name, windows)
90        || names
91            .exe
92            .is_some_and(|exe| same_name(pattern, base_name(exe, windows), windows))
93        || names
94            .argv0
95            .is_some_and(|argv0| same_name(pattern, base_name(argv0, windows), windows))
96}
97
98/// Whether `pattern` is a substring of the process name or the command line
99fn in_command_line(pattern: &str, names: &Names<'_>) -> bool {
100    let pattern = pattern.to_lowercase();
101    names.name.to_lowercase().contains(&pattern)
102        || names
103            .command
104            .is_some_and(|command| command.to_lowercase().contains(&pattern))
105}
106
107/// How `pattern` relates to a process under `mode`; `windows` says whether
108/// names follow Windows rules (an optional `.exe`, `\` in paths)
109pub fn classify_with(pattern: &str, names: &Names<'_>, mode: NameMatch, windows: bool) -> Hit {
110    match mode {
111        // nothing is named "": it is not a name, and not worth counting
112        // command lines for, since every one contains it
113        NameMatch::Exact if pattern.is_empty() => Hit::No,
114        NameMatch::Exact if is_exact(pattern, names, windows) => Hit::Match,
115        NameMatch::Exact if in_command_line(pattern, names) => Hit::CommandLineOnly,
116        NameMatch::Command if in_command_line(pattern, names) => Hit::Match,
117        _ => Hit::No,
118    }
119}
120
121/// [`classify_with`] under this OS's rules
122pub fn classify(pattern: &str, names: &Names<'_>, mode: NameMatch) -> Hit {
123    classify_with(pattern, names, mode, cfg!(windows))
124}
125
126/// Whether `pattern` matches a process under `mode`, by this OS's rules
127pub fn matches_name(pattern: &str, names: &Names<'_>, mode: NameMatch) -> bool {
128    classify(pattern, names, mode) == Hit::Match
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    const EXACT: NameMatch = NameMatch::Exact;
136    const CMD: NameMatch = NameMatch::Command;
137
138    fn unix(pattern: &str, names: &Names<'_>, mode: NameMatch) -> Hit {
139        classify_with(pattern, names, mode, false)
140    }
141
142    fn windows(pattern: &str, names: &Names<'_>, mode: NameMatch) -> Hit {
143        classify_with(pattern, names, mode, true)
144    }
145
146    /// A node server as `ps` shows it: `node /srv/app/server.js --port 3000`
147    fn node_server() -> Names<'static> {
148        Names {
149            name: "node",
150            exe: Some("/usr/local/bin/node"),
151            argv0: Some("node"),
152            command: Some("node /srv/app/server.js --port 3000"),
153        }
154    }
155
156    #[test]
157    fn the_flag_picks_the_comparison() {
158        assert_eq!(NameMatch::from_flag(false), NameMatch::Exact);
159        assert_eq!(NameMatch::from_flag(true), NameMatch::Command);
160        assert_eq!(NameMatch::default(), NameMatch::Exact);
161    }
162
163    #[test]
164    fn the_whole_name_matches_and_a_part_of_it_does_not() {
165        let helper = Names {
166            name: "Claude Helper",
167            exe: Some("/Applications/Claude.app/Contents/Frameworks/Claude Helper"),
168            argv0: Some("/Applications/Claude.app/Contents/Frameworks/Claude Helper"),
169            command: None,
170        };
171        assert_eq!(unix("Claude Helper", &helper, EXACT), Hit::Match);
172        assert_eq!(unix("Claude", &helper, EXACT), Hit::CommandLineOnly);
173        assert_eq!(unix("Helper", &helper, EXACT), Hit::CommandLineOnly);
174        assert_eq!(unix("node", &helper, EXACT), Hit::No);
175        assert_eq!(unix("node", &node_server(), EXACT), Hit::Match);
176        assert_eq!(unix("nod", &node_server(), EXACT), Hit::CommandLineOnly);
177    }
178
179    #[test]
180    fn an_empty_name_matches_nothing_and_hints_at_nothing() {
181        // every command line contains the empty string, so by default it must
182        // not count as "in the command line" for a hint to count them
183        assert_eq!(unix("", &node_server(), EXACT), Hit::No);
184        assert_eq!(windows("", &node_server(), EXACT), Hit::No);
185        // an empty process name is not the empty pattern's either
186        let unnamed = Names::default();
187        assert_eq!(unix("", &unnamed, EXACT), Hit::No);
188        // --cmd is the substring comparison of `pkill -f`, which any text is in
189        assert_eq!(unix("", &node_server(), CMD), Hit::Match);
190    }
191
192    #[test]
193    fn case_never_matters() {
194        assert_eq!(unix("NODE", &node_server(), EXACT), Hit::Match);
195        assert_eq!(unix("Node", &node_server(), CMD), Hit::Match);
196        let mixed = Names {
197            name: "Obsidian",
198            ..Names::default()
199        };
200        assert_eq!(unix("oBSIDIAN", &mixed, EXACT), Hit::Match);
201    }
202
203    #[test]
204    fn a_word_in_the_command_line_is_not_the_name() {
205        // an editor opened on server.js: its command line mentions node
206        let editor = Names {
207            name: "vim",
208            exe: Some("/usr/bin/vim"),
209            argv0: Some("vim"),
210            command: Some("vim notes-about-node.md"),
211        };
212        assert_eq!(unix("node", &editor, EXACT), Hit::CommandLineOnly);
213        assert!(!matches_name("node", &editor, EXACT));
214        assert!(matches_name("node", &editor, CMD));
215    }
216
217    #[test]
218    fn the_executable_file_name_matches_when_the_name_differs() {
219        let versioned = Names {
220            name: "python3.12",
221            exe: Some("/usr/bin/python3.12"),
222            argv0: Some("python"),
223            command: Some("python app.py"),
224        };
225        // by the file name of the executable, never by a directory in its path
226        assert_eq!(unix("python3.12", &versioned, EXACT), Hit::Match);
227        assert_eq!(unix("bin", &versioned, EXACT), Hit::No);
228        assert_eq!(unix("usr", &versioned, EXACT), Hit::No);
229        let renamed = Names {
230            name: "main",
231            exe: Some("/opt/tools/serve"),
232            ..Names::default()
233        };
234        assert_eq!(unix("serve", &renamed, EXACT), Hit::Match);
235    }
236
237    #[test]
238    fn the_first_argument_file_name_matches_when_the_name_differs() {
239        // a script run through its shebang: the name is the script's, the
240        // executable the interpreter's, and argv[0] what was typed
241        let aliased = Names {
242            name: "main",
243            exe: Some("/usr/bin/busybox"),
244            argv0: Some("./tools/ls"),
245            command: Some("./tools/ls -l"),
246        };
247        assert_eq!(unix("ls", &aliased, EXACT), Hit::Match);
248        assert_eq!(unix("tools", &aliased, EXACT), Hit::CommandLineOnly);
249        assert_eq!(unix("-l", &aliased, EXACT), Hit::CommandLineOnly);
250    }
251
252    #[test]
253    fn a_long_name_cut_by_linux_matches_through_the_executable_or_argv0() {
254        // Linux cuts the name to 15 characters
255        let long = "very-long-service-name";
256        let cut = &long[..15];
257        let by_exe = Names {
258            name: cut,
259            exe: Some("/srv/bin/very-long-service-name"),
260            argv0: None,
261            command: None,
262        };
263        assert_eq!(unix(long, &by_exe, EXACT), Hit::Match);
264        assert_eq!(unix(cut, &by_exe, EXACT), Hit::Match);
265        let by_argv0 = Names {
266            name: cut,
267            exe: None,
268            argv0: Some("/srv/bin/very-long-service-name"),
269            command: Some("/srv/bin/very-long-service-name --flag"),
270        };
271        assert_eq!(unix(long, &by_argv0, EXACT), Hit::Match);
272        // neither the executable nor argv[0] known: only the cut name is left
273        let neither = Names {
274            name: cut,
275            ..Names::default()
276        };
277        assert_eq!(unix(long, &neither, EXACT), Hit::No);
278        assert_eq!(unix(cut, &neither, EXACT), Hit::Match);
279    }
280
281    #[test]
282    fn a_missing_executable_or_command_line_is_no_match_not_a_failure() {
283        let zombie = Names {
284            name: "defunct",
285            ..Names::default()
286        };
287        assert_eq!(unix("defunct", &zombie, EXACT), Hit::Match);
288        assert_eq!(unix("node", &zombie, EXACT), Hit::No);
289        assert_eq!(unix("node", &zombie, CMD), Hit::No);
290        assert_eq!(unix("defun", &zombie, CMD), Hit::Match);
291    }
292
293    #[test]
294    fn exe_is_optional_on_either_side_only_on_windows() {
295        let node = Names {
296            name: "node.exe",
297            exe: Some(r"C:\Program Files\nodejs\node.exe"),
298            argv0: Some(r"C:\Program Files\nodejs\node.exe"),
299            command: Some(r#""C:\Program Files\nodejs\node.exe" server.js"#),
300        };
301        assert_eq!(windows("node", &node, EXACT), Hit::Match);
302        assert_eq!(windows("node.exe", &node, EXACT), Hit::Match);
303        assert_eq!(windows("NODE.EXE", &node, EXACT), Hit::Match);
304        assert_eq!(windows("nodejs", &node, EXACT), Hit::CommandLineOnly);
305        assert_eq!(windows("Files", &node, EXACT), Hit::CommandLineOnly);
306        // the same process on Unix would be named node.exe, not node
307        assert_eq!(unix("node", &node, EXACT), Hit::CommandLineOnly);
308        assert_eq!(unix("node.exe", &node, EXACT), Hit::Match);
309        // the other way round: the process has no extension, the text has
310        let bare = Names {
311            name: "node",
312            ..Names::default()
313        };
314        assert_eq!(windows("node.exe", &bare, EXACT), Hit::Match);
315        assert_eq!(unix("node.exe", &bare, EXACT), Hit::No);
316    }
317
318    #[test]
319    fn a_backslash_separates_a_path_only_on_windows() {
320        let names = Names {
321            name: "x",
322            exe: Some(r"C:\tools\serve.exe"),
323            ..Names::default()
324        };
325        assert_eq!(windows("serve", &names, EXACT), Hit::Match);
326        // on Unix the whole string is one file name
327        assert_eq!(unix("serve", &names, EXACT), Hit::No);
328        assert_eq!(unix(r"C:\tools\serve.exe", &names, EXACT), Hit::Match);
329        assert_eq!(base_name("/usr/bin/node", false), "node");
330        assert_eq!(base_name("node", false), "node");
331        assert_eq!(base_name("", false), "");
332    }
333
334    #[test]
335    fn only_a_trailing_exe_is_dropped() {
336        assert_eq!(without_exe("node.exe"), "node");
337        assert_eq!(without_exe("NODE.EXE"), "NODE");
338        assert_eq!(without_exe("node.exe.bak"), "node.exe.bak");
339        assert_eq!(without_exe("exe"), "exe");
340        assert_eq!(without_exe(".exe"), "");
341        // a multi-byte character where the extension would start
342        assert_eq!(without_exe("ünode"), "ünode");
343        assert_eq!(without_exe("日本語"), "日本語");
344    }
345
346    #[test]
347    fn cmd_matches_a_part_of_the_name_or_of_the_command_line() {
348        let server = node_server();
349        assert_eq!(unix("server.js", &server, CMD), Hit::Match);
350        assert_eq!(unix("--PORT 3000", &server, CMD), Hit::Match);
351        assert_eq!(unix("nod", &server, CMD), Hit::Match);
352        assert_eq!(unix("python", &server, CMD), Hit::No);
353        // the executable path is not searched, only the name and the command line
354        assert_eq!(unix("local", &server, CMD), Hit::No);
355        // with no command line, only the name
356        let quiet = Names {
357            name: "launchd",
358            ..Names::default()
359        };
360        assert_eq!(unix("launch", &quiet, CMD), Hit::Match);
361        assert_eq!(unix("server", &quiet, CMD), Hit::No);
362    }
363
364    #[test]
365    fn matches_name_follows_this_os() {
366        let node = Names {
367            name: if cfg!(windows) { "node.exe" } else { "node" },
368            ..Names::default()
369        };
370        assert!(matches_name("node", &node, EXACT));
371        assert!(!matches_name("no", &node, EXACT));
372        assert!(matches_name("no", &node, CMD));
373        assert_eq!(classify("no", &node, EXACT), Hit::CommandLineOnly);
374    }
375}