Skip to main content

zoi_install/
shim.rs

1use std::path::PathBuf;
2use std::{env, fs};
3
4use anyhow::{Result, anyhow};
5use walkdir::WalkDir;
6use zoi_core::config;
7use zoi_core::types::Scope;
8use zoi_core::utils::{ask_for_confirmation, symlink_file};
9use zoi_db as db;
10use zoi_plugins::PluginManager;
11use zoi_project as project;
12use zoi_resolver::{local, resolve};
13#[cfg(target_os = "linux")]
14use zoi_sandbox as sandbox;
15
16/// Runs a binary through the Zoi shim mechanism.
17///
18/// # Errors
19///
20/// Returns an error if the binary cannot be resolved, if sandboxing fails,
21/// or if execution of the binary fails.
22pub fn run_shim(
23    bin_name: &str,
24    args: Vec<String>,
25    plugin_manager: Option<&PluginManager>,
26    auto_install: Option<&dyn Fn(&str, &str) -> Result<()>>
27) -> Result<()> {
28    let bin_path =
29        resolve_to_installed_bin(bin_name, plugin_manager, auto_install)?;
30
31    #[cfg(target_os = "linux")]
32    {
33        let mut current = bin_path.parent();
34        let mut manifest: Option<zoi_core::types::InstallManifest> = None;
35        let mut pkg_version_dir = None;
36
37        while let Some(path) = current {
38            let mut manifest_path = None;
39            if let Ok(entries) = fs::read_dir(path) {
40                for entry in entries.flatten() {
41                    let name = entry.file_name().to_string_lossy().to_string();
42                    if name.starts_with("manifest")
43                        && std::path::Path::new(&name)
44                            .extension()
45                            .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
46                    {
47                        manifest_path = Some(entry.path());
48                        break;
49                    }
50                }
51            }
52
53            if let Some(mp) = manifest_path
54                && let Ok(content) = fs::read_to_string(mp)
55                && let Ok(m) = serde_yaml::from_str::<
56                    zoi_core::types::InstallManifest
57                >(&content)
58            {
59                manifest = Some(m);
60                pkg_version_dir = Some(path.to_path_buf());
61                break;
62            }
63            current = path.parent();
64        }
65
66        if let Some(m) = manifest
67            && let Some(sandbox) = m.sandbox
68            && sandbox.enabled
69            && let Some(version_dir) = pkg_version_dir
70        {
71            use std::os::unix::process::CommandExt;
72            let mut cmd = sandbox::wrap_command(
73                &bin_path,
74                &args,
75                &sandbox,
76                &version_dir
77            )?;
78            let err = cmd.exec();
79            return Err(anyhow!(
80                "Failed to execute sandboxed binary '{bin_name}': {err}"
81            ));
82        }
83    }
84
85    let mut cmd = std::process::Command::new(bin_path);
86    cmd.args(args);
87
88    #[cfg(unix)]
89    {
90        use std::os::unix::process::CommandExt;
91        let err = cmd.exec();
92        Err(anyhow!("Failed to execute binary '{bin_name}': {err}"))
93    }
94
95    #[cfg(windows)]
96    {
97        let mut child = cmd.spawn()?;
98        let status = child.wait()?;
99        std::process::exit(status.code().unwrap_or(0));
100    }
101}
102
103/// Resolves a binary name to its installed path in the Zoi store.
104///
105/// # Errors
106///
107/// Returns an error if the binary cannot be found in the store, if the
108/// desired version cannot be determined, or if automatic installation fails.
109pub fn resolve_to_installed_bin(
110    bin_name: &str,
111    plugin_manager: Option<&PluginManager>,
112    auto_install: Option<&dyn Fn(&str, &str) -> Result<()>>
113) -> Result<PathBuf> {
114    let desired_version = get_desired_version(bin_name, plugin_manager)?;
115
116    let providers = db::find_provides("local", bin_name)?;
117
118    if let Some(version) = &desired_version {
119        for (pkg, _) in &providers {
120            if let Some(path) =
121                search_store_for_version(&pkg.name, version, bin_name)?
122            {
123                return Ok(path);
124            }
125        }
126
127        if let Some(install) = auto_install
128            && ask_for_confirmation(
129                &format!(
130                    "Binary '{bin_name}' v{version} is required but not \
131                     installed. Install it now?"
132                ),
133                false
134            )
135        {
136            install(bin_name, version)?;
137            let providers = db::find_provides("local", bin_name)?;
138            for (pkg, _) in &providers {
139                if let Some(path) =
140                    search_store_for_version(&pkg.name, version, bin_name)?
141                {
142                    return Ok(path);
143                }
144            }
145        }
146    }
147
148    if providers.is_empty() {
149        return Err(anyhow!(
150            "No installed package provides binary '{bin_name}'. Run 'zoi \
151             provides {bin_name}' to find providers."
152        ));
153    }
154
155    if let Some(version) = &desired_version {
156        for (pkg, _) in &providers {
157            if let Some(path) =
158                search_store_for_version(&pkg.name, version, bin_name)?
159            {
160                return Ok(path);
161            }
162        }
163    }
164
165    let Some((pkg, _)) = providers.first() else {
166        return Err(anyhow!(
167            "No installed package provides binary '{bin_name}'."
168        ));
169    };
170
171    if let Some(path) = search_store_for_version(&pkg.name, "latest", bin_name)?
172    {
173        return Ok(path);
174    }
175
176    let version = pkg.version.as_deref().ok_or_else(|| {
177        anyhow!("Package '{}' has no version info in DB", pkg.name)
178    })?;
179
180    if let Some(path) = search_store_for_version(&pkg.name, version, bin_name)?
181    {
182        return Ok(path);
183    }
184
185    for scope in [Scope::Project, Scope::User, Scope::System] {
186        let store_root = local::get_store_base_dir(scope)?;
187        if !store_root.exists() {
188            continue;
189        }
190
191        for entry in fs::read_dir(store_root)? {
192            let entry = entry?;
193            let path = entry.path();
194            if !path.is_dir() {
195                continue;
196            }
197
198            if let Some(dir_name) = path.file_name().and_then(|s| s.to_str())
199                && dir_name.ends_with(&format!("-{}", pkg.name))
200            {
201                let latest_dir = path.join("latest");
202                if latest_dir.exists()
203                    && let Some(p) = find_bin_in_dir(&latest_dir, bin_name)
204                {
205                    return Ok(p);
206                }
207            }
208        }
209    }
210
211    Err(anyhow!(
212        "Could not locate binary '{bin_name}' in the Zoi store. Try \
213         reinstalling the provider package."
214    ))
215}
216
217/// Finds the version for a binary in a `.tool-versions` file.
218fn find_tool_versions_version(bin_name: &str) -> Result<Option<String>> {
219    let mut current_dir = env::current_dir()?;
220    loop {
221        let tool_versions_path = current_dir.join(".tool-versions");
222        if tool_versions_path.exists() {
223            let content = fs::read_to_string(&tool_versions_path)?;
224            for line in content.lines() {
225                let line = line.trim();
226                if line.is_empty() || line.starts_with('#') {
227                    continue;
228                }
229                let mut parts = line.split_whitespace();
230                if let (Some(p0), Some(p1)) = (parts.next(), parts.next())
231                    && p0 == bin_name
232                {
233                    return Ok(Some(p1.to_string()));
234                }
235            }
236        }
237        if !current_dir.pop() {
238            break;
239        }
240    }
241    Ok(None)
242}
243
244/// Gets the desired version for a binary from various sources.
245fn get_desired_version(
246    bin_name: &str,
247    plugin_manager: Option<&PluginManager>
248) -> Result<Option<String>> {
249    let env_var_name =
250        format!("ZOI_{}_VERSION", bin_name.to_uppercase().replace('-', "_"));
251    if let Ok(v) = env::var(&env_var_name) {
252        return Ok(Some(v));
253    }
254
255    if let Some(pm) = plugin_manager
256        && let Ok(Some(v)) = pm.trigger_resolve_shim_version(bin_name)
257    {
258        return Ok(Some(v));
259    }
260
261    if let Ok(project_cfg) = project::config::load() {
262        for pkg_spec in project_cfg.pkgs {
263            if let Ok(req) = resolve::parse_source_string(&pkg_spec) {
264                let is_match = req.name == bin_name || {
265                    if let Ok(providers) = db::find_provides("local", bin_name)
266                    {
267                        providers.iter().any(|(p, _)| p.name == req.name)
268                    } else {
269                        false
270                    }
271                };
272
273                if is_match && let Some(v) = req.version_spec {
274                    return Ok(Some(v));
275                }
276            }
277        }
278    }
279
280    if let Ok(Some(v)) = find_tool_versions_version(bin_name) {
281        return Ok(Some(v));
282    }
283
284    let cfg = config::read_config()?;
285    if let Some(v) = cfg.versions.get(bin_name) {
286        return Ok(Some(v.clone()));
287    }
288
289    Ok(None)
290}
291
292/// Searches the Zoi store for a specific version of a package that provides a
293/// binary.
294fn search_store_for_version(
295    pkg_name: &str,
296    version: &str,
297    bin_name: &str
298) -> Result<Option<PathBuf>> {
299    for scope in [Scope::Project, Scope::User, Scope::System] {
300        let store_root = local::get_store_base_dir(scope)?;
301        if !store_root.exists() {
302            continue;
303        }
304
305        for entry in fs::read_dir(store_root)? {
306            let entry = entry?;
307            let path = entry.path();
308            if !path.is_dir() {
309                continue;
310            }
311
312            if let Some(dir_name) = path.file_name().and_then(|s| s.to_str())
313                && dir_name.ends_with(&format!("-{pkg_name}"))
314            {
315                let latest_dir = path.join("latest");
316                if latest_dir.exists()
317                    && (version == "latest" || version.is_empty())
318                    && let Some(p) = find_bin_in_dir(&latest_dir, bin_name)
319                {
320                    return Ok(Some(p));
321                }
322
323                if version != "latest" && !version.is_empty() {
324                    let version_dir = path.join(version);
325                    if version_dir.exists()
326                        && let Some(p) = find_bin_in_dir(&version_dir, bin_name)
327                    {
328                        return Ok(Some(p));
329                    }
330
331                    for v_entry in fs::read_dir(&path)? {
332                        let v_entry = v_entry?;
333                        let v_name =
334                            v_entry.file_name().to_string_lossy().to_string();
335                        if v_name.starts_with(version)
336                            && v_name != "latest"
337                            && v_name != "dependents"
338                        {
339                            let v_dir = path.join(v_name);
340                            if let Some(p) = find_bin_in_dir(&v_dir, bin_name) {
341                                return Ok(Some(p));
342                            }
343                        }
344                    }
345                }
346            }
347        }
348    }
349    Ok(None)
350}
351
352/// Finds a binary in a directory, checking `bin/` first and then walking the
353/// directory.
354fn find_bin_in_dir(dir: &std::path::Path, bin_name: &str) -> Option<PathBuf> {
355    let bin_path = dir.join("bin").join(bin_name);
356    if bin_path.exists() {
357        return Some(bin_path);
358    }
359
360    for entry in WalkDir::new(dir)
361        .into_iter()
362        .filter_map(std::result::Result::ok)
363    {
364        if entry.file_type().is_file()
365            && entry.file_name().to_string_lossy() == bin_name
366        {
367            return Some(entry.path().to_path_buf());
368        }
369    }
370    None
371}
372
373/// Creates a shim for the current Zoi executable at the specified path.
374///
375/// # Errors
376///
377/// Returns an error if the current executable path cannot be determined
378/// or if creating the symbolic link fails.
379pub fn create_shim(link_path: &std::path::Path) -> Result<()> {
380    let zoi_exe = env::current_exe()?;
381    symlink_file(&zoi_exe, link_path)
382        .map_err(|e| anyhow!("Failed to create shim: {e}"))
383}