Skip to main content

vs_core/service/
exec.rs

1//! Services for executing commands with resolved runtime environments.
2
3use 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    /// Executes a command with the resolved runtime environment for a specific SDK.
14    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))?;
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}