1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum NameMatch {
12 #[default]
15 Exact,
16 Command,
19}
20
21impl NameMatch {
22 pub fn from_flag(cmd: bool) -> Self {
24 if cmd {
25 NameMatch::Command
26 } else {
27 NameMatch::Exact
28 }
29 }
30}
31
32#[derive(Debug, Clone, Copy, Default)]
34pub struct Names<'a> {
35 pub name: &'a str,
37 pub exe: Option<&'a str>,
39 pub argv0: Option<&'a str>,
41 pub command: Option<&'a str>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Hit {
48 Match,
50 CommandLineOnly,
52 No,
54}
55
56fn base_name(path: &str, windows: bool) -> &str {
59 path.rsplit(|c| c == '/' || (windows && c == '\\'))
60 .next()
61 .unwrap_or(path)
62}
63
64fn 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
72fn 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
83fn 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
98fn 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
107pub fn classify_with(pattern: &str, names: &Names<'_>, mode: NameMatch, windows: bool) -> Hit {
110 match mode {
111 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
121pub fn classify(pattern: &str, names: &Names<'_>, mode: NameMatch) -> Hit {
123 classify_with(pattern, names, mode, cfg!(windows))
124}
125
126pub 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 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 assert_eq!(unix("", &node_server(), EXACT), Hit::No);
184 assert_eq!(windows("", &node_server(), EXACT), Hit::No);
185 let unnamed = Names::default();
187 assert_eq!(unix("", &unnamed, EXACT), Hit::No);
188 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 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 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 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 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 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 assert_eq!(unix("node", &node, EXACT), Hit::CommandLineOnly);
308 assert_eq!(unix("node.exe", &node, EXACT), Hit::Match);
309 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 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 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 assert_eq!(unix("local", &server, CMD), Hit::No);
355 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}