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