Skip to main content

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}