1use crate::paths;
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum InstallOrigin {
11 Aube,
12 Mise,
13}
14
15impl InstallOrigin {
16 pub fn label(self) -> &'static str {
17 match self {
18 InstallOrigin::Aube => "aube",
19 InstallOrigin::Mise => "mise",
20 }
21 }
22}
23
24#[derive(Debug, Clone)]
26pub struct InstalledNode {
27 pub version: node_semver::Version,
28 pub install_dir: PathBuf,
29 pub bin_dir: PathBuf,
32 pub node_bin: PathBuf,
33 pub origin: InstallOrigin,
34}
35
36pub fn list_installed() -> Vec<InstalledNode> {
40 let mut by_version: BTreeMap<node_semver::Version, InstalledNode> = BTreeMap::new();
41 if let Some(dir) = mise_node_installs_dir() {
43 for node in scan_install_dir(&dir, InstallOrigin::Mise) {
44 by_version.insert(node.version.clone(), node);
45 }
46 }
47 if let Some(dir) = paths::runtime_dir() {
48 for node in scan_install_dir(&dir, InstallOrigin::Aube) {
49 by_version.insert(node.version.clone(), node);
50 }
51 }
52 by_version.into_values().collect()
53}
54
55pub fn mise_node_installs_dir() -> Option<PathBuf> {
57 mise_tool_installs_dir("node")
58}
59
60pub fn mise_tool_installs_dir(tool: &str) -> Option<PathBuf> {
64 let installs = if let Some(dir) = std::env::var_os("MISE_INSTALLS_DIR") {
65 PathBuf::from(dir)
66 } else if let Some(dir) = std::env::var_os("MISE_DATA_DIR") {
67 PathBuf::from(dir).join("installs")
68 } else {
69 let data_home = aube_util::env::xdg_data_home()
70 .or_else(|| aube_util::env::home_dir().map(|h| h.join(".local/share")))?;
71 data_home.join("mise/installs")
72 };
73 Some(installs.join(tool))
74}
75
76fn scan_install_dir(root: &Path, origin: InstallOrigin) -> Vec<InstalledNode> {
82 let Ok(entries) = std::fs::read_dir(root) else {
83 return Vec::new();
84 };
85 let mut out = Vec::new();
86 for entry in entries.flatten() {
87 let path = entry.path();
88 let Ok(file_type) = entry.file_type() else {
89 continue;
90 };
91 if !file_type.is_dir() {
92 continue;
95 }
96 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
97 continue;
98 };
99 let Ok(version) = node_semver::Version::parse(name.trim_start_matches('v')) else {
100 continue;
101 };
102 if let Some(node) = validate_install(&path, version, origin) {
103 out.push(node);
104 }
105 }
106 out
107}
108
109pub(crate) fn validate_install(
113 dir: &Path,
114 version: node_semver::Version,
115 origin: InstallOrigin,
116) -> Option<InstalledNode> {
117 if dir.join("incomplete").exists() {
118 return None;
119 }
120 let (bin_dir, node_bin) = node_paths_in(dir);
121 if !is_executable_file(&node_bin) {
122 return None;
123 }
124 Some(InstalledNode {
125 version,
126 install_dir: dir.to_path_buf(),
127 bin_dir,
128 node_bin,
129 origin,
130 })
131}
132
133pub(crate) fn is_executable_file(path: &Path) -> bool {
138 let Ok(meta) = std::fs::metadata(path) else {
139 return false;
140 };
141 if !meta.is_file() {
142 return false;
143 }
144 #[cfg(unix)]
145 {
146 use std::os::unix::fs::PermissionsExt;
147 meta.permissions().mode() & 0o111 != 0
148 }
149 #[cfg(not(unix))]
150 true
151}
152
153pub(crate) fn node_paths_in(dir: &Path) -> (PathBuf, PathBuf) {
156 if cfg!(windows) {
157 let root_exe = dir.join("node.exe");
160 if root_exe.is_file() {
161 return (dir.to_path_buf(), root_exe);
162 }
163 let bin = dir.join("bin");
164 let exe = bin.join("node.exe");
165 (bin, exe)
166 } else {
167 let bin = dir.join("bin");
168 let exe = bin.join("node");
169 (bin, exe)
170 }
171}
172
173pub fn probe_path_node() -> Option<(node_semver::Version, PathBuf)> {
177 static PROBED: std::sync::OnceLock<Option<(node_semver::Version, PathBuf)>> =
178 std::sync::OnceLock::new();
179 PROBED.get_or_init(probe_path_node_uncached).clone()
180}
181
182fn probe_path_node_uncached() -> Option<(node_semver::Version, PathBuf)> {
183 let exe = find_on_path(if cfg!(windows) { "node.exe" } else { "node" })?;
184 let output = std::process::Command::new(&exe)
185 .arg("--version")
186 .output()
187 .ok()?;
188 if !output.status.success() {
189 return None;
190 }
191 let raw = String::from_utf8(output.stdout).ok()?;
192 let version = node_semver::Version::parse(raw.trim().trim_start_matches('v')).ok()?;
193 Some((version, exe))
194}
195
196pub(crate) fn find_on_path(bin_name: &str) -> Option<PathBuf> {
199 let path = std::env::var_os("PATH")?;
200 for dir in std::env::split_paths(&path) {
201 if dir.as_os_str().is_empty() {
202 continue;
203 }
204 let candidate = dir.join(bin_name);
205 if candidate.is_file() {
206 return Some(candidate);
207 }
208 #[cfg(windows)]
209 {
210 let with_exe = dir.join(format!("{bin_name}.exe"));
214 if with_exe.is_file() {
215 return Some(with_exe);
216 }
217 }
218 }
219 None
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn fab_install(root: &Path, version: &str) {
227 let dir = root.join(version);
228 let bin = if cfg!(windows) {
229 dir.clone()
230 } else {
231 dir.join("bin")
232 };
233 std::fs::create_dir_all(&bin).unwrap();
234 let exe = bin.join(if cfg!(windows) { "node.exe" } else { "node" });
235 std::fs::write(&exe, "#!/bin/sh\necho v0.0.0\n").unwrap();
236 #[cfg(unix)]
237 {
238 use std::os::unix::fs::PermissionsExt;
239 std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
240 }
241 }
242
243 #[cfg(unix)]
244 #[test]
245 fn non_executable_node_is_rejected() {
246 let tmp = tempfile::tempdir().unwrap();
247 fab_install(tmp.path(), "22.1.0");
248 use std::os::unix::fs::PermissionsExt;
249 let exe = tmp.path().join("22.1.0/bin/node");
250 std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o644)).unwrap();
251 assert!(scan_install_dir(tmp.path(), InstallOrigin::Aube).is_empty());
252 }
253
254 #[test]
255 fn scans_and_validates() {
256 let tmp = tempfile::tempdir().unwrap();
257 fab_install(tmp.path(), "22.1.0");
258 fab_install(tmp.path(), "24.4.1");
259 fab_install(tmp.path(), "26.0.0");
261 std::fs::write(tmp.path().join("26.0.0/incomplete"), "").unwrap();
262 std::fs::create_dir_all(tmp.path().join("20.0.0")).unwrap();
264 std::fs::create_dir_all(tmp.path().join(".downloads")).unwrap();
266
267 let found = scan_install_dir(tmp.path(), InstallOrigin::Aube);
268 let mut versions: Vec<String> = found.iter().map(|n| n.version.to_string()).collect();
269 versions.sort();
270 assert_eq!(versions, vec!["22.1.0", "24.4.1"]);
271 }
272
273 #[cfg(unix)]
274 #[test]
275 fn alias_symlinks_are_skipped() {
276 let tmp = tempfile::tempdir().unwrap();
277 fab_install(tmp.path(), "22.1.0");
278 std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("22")).unwrap();
279 std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("latest")).unwrap();
280 let found = scan_install_dir(tmp.path(), InstallOrigin::Aube);
281 assert_eq!(found.len(), 1, "{found:?}");
282 }
283}