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
16pub 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
103pub 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
217fn 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
244fn 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
292fn 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
352fn 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
373pub 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}