mcpls_core/config/command_stem.rs
1//! The file stem of a command, compared the one way every launcher rule uses.
2
3use std::path::Path;
4
5/// The file stem of a command (`/usr/bin/NPX.cmd` is `npx`), lowercased.
6///
7/// One constructor and one case rule serve the launcher rules, the TypeScript
8/// pin and builtin-server matching. The stem is lowercased once, so a
9/// comparison is case-insensitive on every host: on a case-insensitive file
10/// system (Windows, and macOS by default) a differently spelled command runs
11/// the same program, so a case-sensitive denylist (launchers) would let one
12/// slip through. For allow-style matches (the TypeScript pin, builtin-server
13/// matching) the same rule is merely generous on a case-sensitive file system:
14/// a differently cased name there fails to start rather than running anything
15/// unvetted. The names compared against must be lowercase.
16///
17/// # Examples
18///
19/// ```
20/// use mcpls_core::config::CommandStem;
21///
22/// let stem = CommandStem::of("/usr/local/bin/NPX.cmd");
23/// assert!(stem.is("npx"));
24/// assert!(stem.is_any(&["yarn", "npx"]));
25/// assert_eq!(stem.as_str(), "npx");
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CommandStem(String);
29
30impl CommandStem {
31 /// The stem of `command`'s final path component; empty when it has none.
32 #[must_use]
33 pub fn of(command: &str) -> Self {
34 Self(
35 Path::new(command)
36 .file_stem()
37 .map_or_default(|stem| stem.to_string_lossy().to_ascii_lowercase()),
38 )
39 }
40
41 /// The lowercased stem.
42 #[must_use]
43 pub fn as_str(&self) -> &str {
44 &self.0
45 }
46
47 /// Whether the stem is `name`, a lowercase name.
48 #[must_use]
49 pub fn is(&self, name: &str) -> bool {
50 debug_assert!(
51 name.bytes().all(|byte| !byte.is_ascii_uppercase()),
52 "{name} must be lowercase"
53 );
54 self.0 == name
55 }
56
57 /// Whether the stem is any of `names`, lowercase names.
58 #[must_use]
59 pub fn is_any(&self, names: &[&str]) -> bool {
60 names.iter().any(|name| self.is(name))
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 #[test]
69 fn stem_drops_directory_and_extension_and_lowercases() {
70 for command in ["npx", "/a/b/NPX", "/a/Npx.CMD", "npx.cmd"] {
71 assert_eq!(CommandStem::of(command).as_str(), "npx", "{command}");
72 }
73 assert_eq!(CommandStem::of("").as_str(), "");
74 assert_eq!(CommandStem::of("/").as_str(), "");
75 }
76
77 #[test]
78 fn is_any_matches_any_listed_name_regardless_of_case() {
79 let stem = CommandStem::of("Bunx.exe");
80 assert!(stem.is_any(&["npx", "bunx"]));
81 assert!(!stem.is_any(&["npx", "pnpx"]));
82 assert!(!stem.is_any(&[]));
83 }
84}