Skip to main content

vs_core/service/
exec.rs

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    /// Executes a command with the resolved runtime environment for a specific SDK.
12    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}