1use std::env::split_paths;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use vs_plugin_api::InstalledRuntime;
8use vs_shell::{EnvDelta, bin_dir};
9
10use crate::{App, CoreError};
11
12impl App {
13 pub fn exec(
15 &self,
16 plugin_name: &str,
17 requested_version: Option<&str>,
18 command: &str,
19 args: &[String],
20 ) -> Result<i32, CoreError> {
21 let runtime = self.resolve_exec_runtime(plugin_name, requested_version)?;
22 let entry = self.resolve_registry_entry(plugin_name)?;
23 let plugin = self.load_plugin(&entry)?;
24 let mut delta = EnvDelta::default();
25 delta.path_entries.push(bin_dir(runtime.main_path()));
26 apply_exec_env_keys(&mut delta, plugin.env_keys(&runtime)?);
27 let path_value = self.path_with_delta(&delta)?;
28 let resolved_command =
29 resolve_command_path(command, &delta.path_entries).ok_or_else(|| {
30 CoreError::CommandExecution {
31 command: command.to_string(),
32 message: format!("command not found in {} environment", runtime.plugin),
33 }
34 })?;
35
36 let mut child = Command::new(resolved_command);
37 child.args(args);
38 child.env("PATH", path_value);
39 for (key, value) in delta.vars {
40 child.env(key, value);
41 }
42
43 let status = child
44 .status()
45 .map_err(|error| CoreError::CommandExecution {
46 command: command.to_string(),
47 message: error.to_string(),
48 })?;
49
50 Ok(status.code().unwrap_or(1))
51 }
52
53 fn resolve_exec_runtime(
54 &self,
55 plugin_name: &str,
56 requested_version: Option<&str>,
57 ) -> Result<InstalledRuntime, CoreError> {
58 let version = if let Some(version) = requested_version {
59 if let Some(runtime) = self.load_installed_runtime(plugin_name, version)? {
60 return Ok(runtime);
61 }
62 let installed = self.install_plugin_version(plugin_name, Some(version), None)?;
63 installed.version
64 } else {
65 self.current_tool(plugin_name)?
66 .map(|current| current.version)
67 .ok_or_else(|| {
68 CoreError::Unsupported(format!(
69 "no version configured for {plugin_name}. Please use `vs use` first"
70 ))
71 })?
72 };
73
74 self.load_installed_runtime(plugin_name, &version)?
75 .ok_or_else(|| {
76 CoreError::Unsupported(format!(
77 "failed to load installed runtime for {plugin_name}@{version}"
78 ))
79 })
80 }
81}
82
83fn resolve_command_path(command: &str, preferred_paths: &[PathBuf]) -> Option<PathBuf> {
84 let command_path = Path::new(command);
85 if command_path.is_absolute() || command.contains(std::path::MAIN_SEPARATOR) {
86 return Some(command_path.to_path_buf());
87 }
88 #[cfg(windows)]
89 if command.contains('/') || command.contains('\\') {
90 return Some(command_path.to_path_buf());
91 }
92
93 let mut search_paths = preferred_paths.to_vec();
94 if let Some(path) = std::env::var_os("PATH") {
95 search_paths.extend(split_paths(&path));
96 }
97
98 #[cfg(windows)]
99 {
100 let pathext = std::env::var_os("PATHEXT")
101 .map(|extensions| {
102 extensions
103 .to_string_lossy()
104 .split(';')
105 .filter(|extension| !extension.is_empty())
106 .map(|extension| extension.to_string())
107 .collect::<Vec<_>>()
108 })
109 .unwrap_or_else(|| {
110 [".COM", ".EXE", ".BAT", ".CMD"]
111 .into_iter()
112 .map(String::from)
113 .collect()
114 });
115
116 for directory in search_paths {
117 if command_path.extension().is_some() {
118 let direct_match = directory.join(command);
119 if direct_match.is_file() {
120 return Some(direct_match);
121 }
122 continue;
123 }
124
125 for extension in &pathext {
126 let candidate = directory.join(format!("{command}{extension}"));
127 if candidate.is_file() {
128 return Some(candidate);
129 }
130 }
131 }
132 }
133
134 #[cfg(not(windows))]
135 {
136 for directory in search_paths {
137 let candidate = directory.join(command);
138 if is_executable_file(&candidate) {
139 return Some(candidate);
140 }
141 }
142 }
143
144 None
145}
146
147fn apply_exec_env_keys(delta: &mut EnvDelta, env_keys: Vec<vs_plugin_api::EnvKey>) {
148 for env_key in env_keys {
149 if env_key.key == "PATH" {
150 delta.path_entries.push(PathBuf::from(env_key.value));
151 } else {
152 delta.vars.push((env_key.key, env_key.value));
153 }
154 }
155}
156
157#[cfg(unix)]
158fn is_executable_file(path: &Path) -> bool {
159 use std::os::unix::fs::PermissionsExt;
160
161 path.is_file()
162 && std::fs::metadata(path)
163 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
164 .unwrap_or(false)
165}
166
167#[cfg(test)]
168mod tests {
169 use std::error::Error;
170 use std::fs;
171 use std::path::Path;
172
173 use tempfile::TempDir;
174
175 use super::resolve_command_path;
176
177 #[test]
178 fn resolve_command_path_should_prefer_runtime_bin_directory() -> Result<(), Box<dyn Error>> {
179 let temp_dir = TempDir::new()?;
180 let preferred = temp_dir.path().join("preferred");
181 let fallback = temp_dir.path().join("fallback");
182 fs::create_dir_all(&preferred)?;
183 fs::create_dir_all(&fallback)?;
184
185 #[cfg(windows)]
186 let preferred_script = preferred.join("node");
187 #[cfg(windows)]
188 let preferred_binary = preferred.join("node.cmd");
189 #[cfg(not(windows))]
190 let preferred_binary = preferred.join("node");
191
192 #[cfg(windows)]
193 let fallback_script = fallback.join("node");
194 #[cfg(windows)]
195 let fallback_binary = fallback.join("node.cmd");
196 #[cfg(not(windows))]
197 let fallback_binary = fallback.join("node");
198
199 #[cfg(windows)]
200 fs::write(&preferred_script, "fixture preferred script")?;
201 #[cfg(not(windows))]
202 make_executable(&preferred_binary, b"#!/bin/sh\necho preferred\n")?;
203 #[cfg(windows)]
204 fs::write(&preferred_binary, "fixture preferred")?;
205 #[cfg(windows)]
206 fs::write(&fallback_script, "fixture fallback script")?;
207 #[cfg(not(windows))]
208 make_executable(&fallback_binary, b"#!/bin/sh\necho fallback\n")?;
209 #[cfg(windows)]
210 fs::write(&fallback_binary, "fixture fallback")?;
211
212 let resolved = resolve_command_path("node", &[preferred, fallback])
213 .ok_or_else(|| std::io::Error::other("missing resolved command"))?;
214
215 assert_path_matches(&resolved, &preferred_binary);
216 Ok(())
217 }
218
219 #[cfg(windows)]
220 fn assert_path_matches(actual: &Path, expected: &Path) {
221 assert!(
222 actual
223 .to_string_lossy()
224 .eq_ignore_ascii_case(expected.to_string_lossy().as_ref()),
225 "assertion failed: actual path {actual:?} does not match expected path {expected:?}",
226 );
227 }
228
229 #[cfg(not(windows))]
230 fn assert_path_matches(actual: &Path, expected: &Path) {
231 assert_eq!(actual, expected);
232 }
233
234 #[cfg(unix)]
235 #[test]
236 fn resolve_command_path_should_skip_non_executable_candidates() -> Result<(), Box<dyn Error>> {
237 let temp_dir = TempDir::new()?;
238 let preferred = temp_dir.path().join("preferred");
239 let fallback = temp_dir.path().join("fallback");
240 fs::create_dir_all(&preferred)?;
241 fs::create_dir_all(&fallback)?;
242
243 fs::write(preferred.join("node"), "not executable")?;
244 make_executable(&fallback.join("node"), b"#!/bin/sh\necho fallback\n")?;
245
246 let resolved = resolve_command_path("node", &[preferred, fallback])
247 .ok_or_else(|| std::io::Error::other("missing resolved command"))?;
248
249 assert_eq!(resolved, temp_dir.path().join("fallback/node"));
250 Ok(())
251 }
252
253 #[cfg(unix)]
254 fn make_executable(path: &Path, contents: &[u8]) -> Result<(), Box<dyn Error>> {
255 use std::os::unix::fs::PermissionsExt;
256
257 fs::write(path, contents)?;
258 let mut permissions = fs::metadata(path)?.permissions();
259 permissions.set_mode(0o755);
260 fs::set_permissions(path, permissions)?;
261 Ok(())
262 }
263}