Skip to main content

ant_core/
install.rs

1//! How the running `ant` binary was installed.
2//!
3//! `ant update` replaces the running executable in place. That is correct for a binary the user
4//! put on disk themselves — via `install.sh`, `install.ps1`, or an unpacked release archive —
5//! and wrong for one a package manager owns, because the package manager's metadata would then
6//! describe a file that is no longer there. This module tells the two apart.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12/// The npm package that distributes `ant`.
13pub const NPM_PACKAGE: &str = "@withautonomi/ant";
14
15/// The directory npm installs dependencies into. An `ant` beneath one is npm-managed.
16const NODE_MODULES: &str = "node_modules";
17
18/// How the running binary got onto this machine.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum InstallMethod {
22    /// Installed from the npm registry as [`NPM_PACKAGE`]. Updates belong to npm.
23    Npm,
24    /// Anything else: `install.sh`, `install.ps1`, a manually unpacked release archive,
25    /// `cargo install`, or a local build. `ant update` may replace these in place.
26    SelfManaged,
27}
28
29impl InstallMethod {
30    /// Whether `ant update` is allowed to overwrite the running executable.
31    #[must_use]
32    pub fn can_self_replace(self) -> bool {
33        matches!(self, Self::SelfManaged)
34    }
35
36    /// The command that updates an installation of this kind, if not `ant update` itself.
37    #[must_use]
38    pub fn update_command(self) -> Option<String> {
39        match self {
40            Self::Npm => Some(format!("npm update -g {NPM_PACKAGE}")),
41            Self::SelfManaged => None,
42        }
43    }
44
45    /// Name of the package manager that owns this installation, for user-facing messages.
46    #[must_use]
47    pub fn package_manager(self) -> Option<&'static str> {
48        match self {
49            Self::Npm => Some("npm"),
50            Self::SelfManaged => None,
51        }
52    }
53}
54
55/// Classify the installation the current process was launched from.
56///
57/// Falls back to [`InstallMethod::SelfManaged`] when the executable path cannot be determined,
58/// which preserves today's behaviour: an undetectable install is treated as one `ant update`
59/// owns.
60#[must_use]
61pub fn detect() -> InstallMethod {
62    current_exe()
63        .as_deref()
64        .map_or(InstallMethod::SelfManaged, classify_path)
65}
66
67/// Resolve the running executable, following symlinks where possible.
68///
69/// npm puts a launcher on `PATH` that is a symlink into its `node_modules` tree, but the process
70/// this runs in was spawned from the real file, so the raw path is normally already inside
71/// `node_modules`. Canonicalising anyway costs one syscall and covers platforms where
72/// `current_exe` hands back the symlink instead.
73fn current_exe() -> Option<PathBuf> {
74    let exe = std::env::current_exe().ok()?;
75    Some(std::fs::canonicalize(&exe).unwrap_or(exe))
76}
77
78/// Classify an executable path without touching the filesystem.
79///
80/// A `node_modules` directory anywhere above the binary means npm (or a compatible client) put
81/// it there. Nothing else in a normal install lands under such a directory; the sole way to be
82/// wrong is to unpack a release archive into a directory literally named `node_modules`, which
83/// costs the user nothing worse than being told to run `npm update` when they meant to
84/// self-update.
85///
86/// Both separators are split on, rather than using [`Path::components`], which only understands
87/// the separator of the host it was compiled for. That keeps the classification — and its tests
88/// — identical everywhere, including when a Windows path is examined on a Unix host.
89#[must_use]
90pub fn classify_path(exe: &Path) -> InstallMethod {
91    let under_node_modules = exe
92        .to_string_lossy()
93        .split(['/', '\\'])
94        .any(|segment| segment == NODE_MODULES);
95
96    if under_node_modules {
97        InstallMethod::Npm
98    } else {
99        InstallMethod::SelfManaged
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn npm_global_install_is_detected() {
109        // The layout `npm install -g` produces under a Node version manager.
110        let path = Path::new(
111            "/home/user/.nvm/versions/node/v22.11.0/lib/node_modules/@withautonomi/ant-linux-x64/bin/ant",
112        );
113        assert_eq!(classify_path(path), InstallMethod::Npm);
114    }
115
116    #[test]
117    fn npm_local_install_is_detected() {
118        let path = Path::new("/srv/project/node_modules/@withautonomi/ant-linux-x64/bin/ant");
119        assert_eq!(classify_path(path), InstallMethod::Npm);
120    }
121
122    #[test]
123    fn nested_node_modules_is_detected() {
124        let path = Path::new(
125            "/srv/project/node_modules/some-tool/node_modules/@withautonomi/ant-darwin-arm64/bin/ant",
126        );
127        assert_eq!(classify_path(path), InstallMethod::Npm);
128    }
129
130    #[test]
131    fn windows_npm_install_is_detected() {
132        let path = Path::new(
133            r"C:\Users\user\AppData\Roaming\npm\node_modules\@withautonomi\ant-win32-x64\bin\ant.exe",
134        );
135        assert_eq!(classify_path(path), InstallMethod::Npm);
136    }
137
138    #[test]
139    fn install_sh_locations_are_self_managed() {
140        for path in [
141            "/home/user/.local/bin/ant",
142            "/usr/local/bin/ant",
143            "/home/user/Library/Application Support/ant/ant",
144            "/opt/ant/bin/ant",
145        ] {
146            assert_eq!(
147                classify_path(Path::new(path)),
148                InstallMethod::SelfManaged,
149                "{path} should be self-managed"
150            );
151        }
152    }
153
154    #[test]
155    fn cargo_and_local_builds_are_self_managed() {
156        for path in [
157            "/home/user/.cargo/bin/ant",
158            "/home/user/dev/ant-client/target/release/ant",
159        ] {
160            assert_eq!(classify_path(Path::new(path)), InstallMethod::SelfManaged);
161        }
162    }
163
164    #[test]
165    fn a_directory_merely_containing_the_substring_is_not_npm() {
166        // Only an exact `node_modules` path component counts, not a name that contains it.
167        let path = Path::new("/home/user/my_node_modules_backup/bin/ant");
168        assert_eq!(classify_path(path), InstallMethod::SelfManaged);
169    }
170
171    #[test]
172    fn self_managed_installs_may_self_replace() {
173        assert!(InstallMethod::SelfManaged.can_self_replace());
174        assert!(InstallMethod::SelfManaged.update_command().is_none());
175    }
176
177    #[test]
178    fn npm_installs_defer_to_npm() {
179        assert!(!InstallMethod::Npm.can_self_replace());
180        assert_eq!(
181            InstallMethod::Npm.update_command().as_deref(),
182            Some("npm update -g @withautonomi/ant")
183        );
184    }
185
186    #[test]
187    fn only_package_managed_installs_name_a_manager() {
188        assert_eq!(InstallMethod::Npm.package_manager(), Some("npm"));
189        assert_eq!(InstallMethod::SelfManaged.package_manager(), None);
190    }
191
192    #[test]
193    fn install_method_serialises_as_snake_case() {
194        assert_eq!(
195            serde_json::to_string(&InstallMethod::Npm).unwrap(),
196            "\"npm\""
197        );
198        assert_eq!(
199            serde_json::to_string(&InstallMethod::SelfManaged).unwrap(),
200            "\"self_managed\""
201        );
202    }
203}