Skip to main content

zoi_install/
shim.rs

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