Skip to main content

vs_core/service/
use_tool.rs

1//! Services for activating a tool version in a selected scope.
2
3use std::fs;
4use std::io::Write;
5use std::path::Path;
6
7use vs_plugin_api::InstalledRuntime;
8use vs_shell::{global_current_dir, link_directory, project_sdk_dir};
9
10use crate::{App, CoreError, InstalledVersion, UseScope};
11
12impl App {
13    /// Activates an installed tool version for a given scope.
14    pub fn use_tool(
15        &self,
16        plugin_name: &str,
17        version: &str,
18        scope: UseScope,
19        unlink: bool,
20    ) -> Result<InstalledVersion, CoreError> {
21        // Verify hook environment is available (session ID proves shell hooks are loaded).
22        let scope = self.verify_hook_env(scope)?;
23
24        let entry = self.resolve_registry_entry(plugin_name)?;
25        let plugin = self.load_plugin(&entry)?;
26        let previous_version = self
27            .current_tool(plugin_name)?
28            .map(|current| current.version);
29        let installed_runtimes = self.load_installed_runtimes(plugin_name)?;
30
31        let requested_version = self.resolve_requested_use_version(plugin_name, version)?;
32        let hook_version = plugin.pre_use(
33            &requested_version,
34            scope.as_str(),
35            &self.cwd,
36            previous_version.as_deref(),
37            &installed_runtimes,
38        )?;
39        let resolved_version = match hook_version {
40            Some(v) => v,
41            None => self.fuzzy_match_version(plugin_name, &requested_version, &installed_runtimes),
42        };
43
44        let runtime = self
45            .load_installed_runtime(plugin_name, &resolved_version)?
46            .ok_or_else(|| {
47                CoreError::Unsupported(format!(
48                    "{plugin_name}@{resolved_version} is not installed. Please run `vs install {plugin_name}@{resolved_version}` first"
49                ))
50            })?;
51        let installed = InstalledVersion {
52            plugin: plugin_name.to_string(),
53            version: runtime.version.clone(),
54            install_dir: runtime.root_dir.clone(),
55        };
56
57        match scope {
58            UseScope::Global => {
59                self.write_tool_assignment(
60                    &vs_config::global_tools_file(self.home()),
61                    plugin_name,
62                    Some(&runtime.version),
63                )?;
64                link_directory(
65                    &installed.install_dir,
66                    &global_current_dir(self.runtime_root(), plugin_name),
67                )?;
68            }
69            UseScope::Project => {
70                self.write_tool_assignment(
71                    &self.preferred_project_file(),
72                    plugin_name,
73                    Some(&runtime.version),
74                )?;
75                if !unlink {
76                    link_directory(
77                        &installed.install_dir,
78                        &project_sdk_dir(&self.cwd, plugin_name),
79                    )?;
80                }
81                ensure_vs_in_gitignore(&self.cwd);
82            }
83            UseScope::Session => {
84                let session_file = self.session_file()?;
85                self.write_tool_assignment(&session_file, plugin_name, Some(&runtime.version))?;
86            }
87        }
88        Ok(installed)
89    }
90
91    /// Resolves the version from the project config file when no version is
92    /// specified on the CLI (empty string). Returns `None` when no project
93    /// config is found.
94    pub fn project_tool_version_for_use(
95        &self,
96        plugin_name: &str,
97    ) -> Result<Option<String>, CoreError> {
98        Ok(self
99            .resolve_project_tool_version_internal(plugin_name)?
100            .map(|resolved| resolved.version))
101    }
102
103    /// Returns installed versions for a single plugin, sorted from newest-looking to oldest-looking.
104    pub fn installed_versions_for_plugin(
105        &self,
106        plugin_name: &str,
107    ) -> Result<Vec<InstalledVersion>, CoreError> {
108        let mut installed = self
109            .list_installed_versions()?
110            .into_iter()
111            .filter(|installed| installed.plugin == plugin_name)
112            .collect::<Vec<_>>();
113        installed.sort_by(|left, right| compare_versions_desc(&left.version, &right.version));
114        Ok(installed)
115    }
116
117    fn load_installed_runtimes(
118        &self,
119        plugin_name: &str,
120    ) -> Result<Vec<InstalledRuntime>, CoreError> {
121        let mut runtimes = Vec::new();
122        for installed in self.installed_versions_for_plugin(plugin_name)? {
123            if let Some(runtime) = self.load_installed_runtime(plugin_name, &installed.version)? {
124                runtimes.push(runtime);
125            }
126        }
127        Ok(runtimes)
128    }
129
130    fn resolve_requested_use_version(
131        &self,
132        plugin_name: &str,
133        version: &str,
134    ) -> Result<String, CoreError> {
135        if version != "latest" {
136            return Ok(version.to_string());
137        }
138
139        self.cached_available_versions(plugin_name, &[])?
140            .into_iter()
141            .next()
142            .map(|available| available.version)
143            .ok_or_else(|| {
144                CoreError::Unsupported(format!(
145                    "plugin {plugin_name} does not expose any available versions"
146                ))
147            })
148    }
149
150    /// Fuzzy-matches a version against installed runtimes.
151    ///
152    /// 1. Exact match — return immediately.
153    /// 2. Prefix match — e.g. "20" matches "20.11.1".
154    /// 3. No match — return the original version unchanged so the caller
155    ///    produces the "not installed" error.
156    fn fuzzy_match_version(
157        &self,
158        plugin_name: &str,
159        version: &str,
160        installed_runtimes: &[InstalledRuntime],
161    ) -> String {
162        // Exact match.
163        if self
164            .load_installed_runtime(plugin_name, version)
165            .ok()
166            .flatten()
167            .is_some()
168        {
169            return version.to_string();
170        }
171
172        // Prefix match — sort installed versions and pick the first match.
173        let mut versions: Vec<String> = installed_runtimes
174            .iter()
175            .map(|rt| rt.version.clone())
176            .collect();
177        versions.sort_by(|left, right| compare_versions_desc(left, right));
178
179        if let Some(matched) = select_matching_installed_version(version, &versions) {
180            return matched;
181        }
182
183        version.to_string()
184    }
185
186    /// Verifies the requested scope can be written in the current environment.
187    ///
188    /// Project and global scopes are persisted on disk and do not require shell
189    /// hooks. Session scope depends on `VS_SESSION_ID`, and on Windows we
190    /// degrade to global scope when hooks are unavailable.
191    fn verify_hook_env(&self, scope: UseScope) -> Result<UseScope, CoreError> {
192        if self.session_id.is_some() {
193            return Ok(scope);
194        }
195
196        match scope {
197            UseScope::Global | UseScope::Project => Ok(scope),
198            UseScope::Session if cfg!(windows) => {
199                eprintln!(
200                    "Warning: The current shell lacks hook support. Switching to global scope automatically."
201                );
202                Ok(UseScope::Global)
203            }
204            UseScope::Session => Err(CoreError::Unsupported(String::from(
205                "vs requires hook support. Please ensure vs is properly initialized with `eval \"$(vs activate <shell>)\"`",
206            ))),
207        }
208    }
209}
210
211fn select_matching_installed_version(selector: &str, candidates: &[String]) -> Option<String> {
212    if candidates.is_empty() {
213        return None;
214    }
215    if let Some(exact) = candidates.iter().find(|candidate| candidate == &selector) {
216        return Some(exact.clone());
217    }
218    let prefix = format!("{selector}.");
219    candidates
220        .iter()
221        .find(|candidate| candidate.starts_with(&prefix))
222        .cloned()
223}
224
225fn compare_versions_desc(left: &str, right: &str) -> std::cmp::Ordering {
226    compare_version_components(left, right).reverse()
227}
228
229fn compare_version_components(left: &str, right: &str) -> std::cmp::Ordering {
230    let left_parts = version_components(left);
231    let right_parts = version_components(right);
232    for (left_part, right_part) in left_parts.iter().zip(right_parts.iter()) {
233        let ordering = match (left_part.parse::<u64>(), right_part.parse::<u64>()) {
234            (Ok(left_num), Ok(right_num)) => left_num.cmp(&right_num),
235            _ => left_part.cmp(right_part),
236        };
237        if ordering != std::cmp::Ordering::Equal {
238            return ordering;
239        }
240    }
241    left_parts.len().cmp(&right_parts.len())
242}
243
244fn version_components(version: &str) -> Vec<&str> {
245    version
246        .trim_start_matches('v')
247        .split(|ch: char| !ch.is_ascii_alphanumeric())
248        .filter(|part| !part.is_empty())
249        .collect()
250}
251
252/// If a `.gitignore` exists in `project_dir` and does not already mention
253/// `.vs/` or `.vs`, appends `.vs/` to it.
254fn ensure_vs_in_gitignore(project_dir: &Path) {
255    let gitignore_path = project_dir.join(".gitignore");
256    let Ok(content) = fs::read_to_string(&gitignore_path) else {
257        return; // no .gitignore — don't create one
258    };
259
260    for line in content.lines() {
261        let trimmed = line.trim();
262        if trimmed == ".vs/" || trimmed == ".vs" {
263            return; // already present
264        }
265    }
266
267    let Ok(mut file) = fs::OpenOptions::new().append(true).open(&gitignore_path) else {
268        return;
269    };
270    let entry = if content.ends_with('\n') || content.is_empty() {
271        ".vs/\n"
272    } else {
273        "\n.vs/\n"
274    };
275    let _ = file.write_all(entry.as_bytes());
276}
277
278#[cfg(test)]
279mod tests {
280    use std::error::Error;
281    use std::fs;
282
283    use tempfile::TempDir;
284    use vs_config::HomeLayout;
285    use vs_plugin_api::PluginBackendKind;
286
287    use crate::{App, UseScope};
288
289    fn new_app_without_session(temp_dir: &TempDir) -> Result<App, Box<dyn Error>> {
290        let home = temp_dir.path().join("home");
291        let cwd = temp_dir.path().join("project");
292        fs::create_dir_all(&cwd)?;
293
294        Ok(App::new(
295            HomeLayout {
296                active_home: home,
297                migration_candidates: Vec::new(),
298            },
299            cwd,
300            None,
301        )?)
302    }
303
304    #[test]
305    fn project_scope_should_not_require_shell_hooks() -> Result<(), Box<dyn Error>> {
306        let temp_dir = TempDir::new()?;
307        let app = new_app_without_session(&temp_dir)?;
308
309        assert_eq!(app.verify_hook_env(UseScope::Project)?, UseScope::Project);
310        assert_eq!(app.verify_hook_env(UseScope::Global)?, UseScope::Global);
311        Ok(())
312    }
313
314    #[cfg(not(windows))]
315    #[test]
316    fn session_scope_should_require_shell_hooks_on_unix() -> Result<(), Box<dyn Error>> {
317        let temp_dir = TempDir::new()?;
318        let app = new_app_without_session(&temp_dir)?;
319
320        let error = match app.verify_hook_env(UseScope::Session) {
321            Ok(scope) => {
322                return Err(Box::new(std::io::Error::other(format!(
323                    "session scope unexpectedly succeeded with scope {scope:?}",
324                ))));
325            }
326            Err(error) => error,
327        };
328        assert!(error.to_string().contains("vs requires hook support"));
329        Ok(())
330    }
331
332    #[cfg(windows)]
333    #[test]
334    fn session_scope_should_fallback_to_global_without_shell_hooks_on_windows()
335    -> Result<(), Box<dyn Error>> {
336        let temp_dir = TempDir::new()?;
337        let app = new_app_without_session(&temp_dir)?;
338
339        assert_eq!(app.verify_hook_env(UseScope::Session)?, UseScope::Global);
340        Ok(())
341    }
342
343    #[cfg(feature = "lua")]
344    #[test]
345    fn use_tool_should_apply_pre_use_resolution() -> Result<(), Box<dyn Error>> {
346        let temp_dir = TempDir::new()?;
347        let home = temp_dir.path().join("home");
348        let cwd = temp_dir.path().join("project");
349        fs::create_dir_all(&cwd)?;
350
351        let app = App::new(
352            HomeLayout {
353                active_home: home,
354                migration_candidates: Vec::new(),
355            },
356            cwd.clone(),
357            Some(String::from("session")),
358        )?;
359
360        let source = temp_dir.path().join("nodejs-lua");
361        write_pre_use_fixture(&source)?;
362        app.add_plugin(
363            Some("nodejs"),
364            Some(source.display().to_string()),
365            Some(PluginBackendKind::Lua),
366            None,
367        )?;
368        app.install_plugin_version("nodejs", Some("20.11.1"), None)?;
369
370        let installed = app.use_tool("nodejs", "lts", UseScope::Project, false)?;
371
372        assert_eq!(installed.version, "20.11.1");
373        let config = fs::read_to_string(cwd.join(".vs.toml"))?;
374        assert!(config.contains("nodejs = \"20.11.1\""));
375        Ok(())
376    }
377
378    #[cfg(feature = "lua")]
379    #[test]
380    fn use_tool_should_fail_when_requested_version_is_not_installed() -> Result<(), Box<dyn Error>>
381    {
382        let temp_dir = TempDir::new()?;
383        let home = temp_dir.path().join("home");
384        let cwd = temp_dir.path().join("project");
385        fs::create_dir_all(&cwd)?;
386
387        let app = App::new(
388            HomeLayout {
389                active_home: home,
390                migration_candidates: Vec::new(),
391            },
392            cwd,
393            Some(String::from("session")),
394        )?;
395
396        let source = temp_dir.path().join("nodejs-lua");
397        write_pre_use_fixture(&source)?;
398        app.add_plugin(
399            Some("nodejs"),
400            Some(source.display().to_string()),
401            Some(PluginBackendKind::Lua),
402            None,
403        )?;
404
405        let error = match app.use_tool("nodejs", "20.11.1", UseScope::Project, false) {
406            Ok(_) => {
407                return Err(Box::new(std::io::Error::other(
408                    "use should fail without a matching installed runtime",
409                )));
410            }
411            Err(error) => error,
412        };
413        assert!(
414            error
415                .to_string()
416                .contains("Please run `vs install nodejs@20.11.1` first")
417        );
418        Ok(())
419    }
420
421    #[cfg(feature = "lua")]
422    fn write_pre_use_fixture(root: &std::path::Path) -> Result<(), Box<dyn Error>> {
423        fs::create_dir_all(root.join("hooks"))?;
424        fs::create_dir_all(root.join("packages/20.11.1/bin"))?;
425        fs::write(
426            root.join("metadata.lua"),
427            "PLUGIN = {}\nPLUGIN.name = 'nodejs'\nPLUGIN.version = '0.1.0'\n",
428        )?;
429        fs::write(
430            root.join("hooks/pre_install.lua"),
431            "function PLUGIN:PreInstall(ctx)\n  return { version = '20.11.1', url = 'packages/20.11.1' }\nend\n",
432        )?;
433        fs::write(
434            root.join("hooks/available.lua"),
435            "function PLUGIN:Available(ctx)\n  return { { version = '20.11.1' } }\nend\n",
436        )?;
437        fs::write(
438            root.join("hooks/env_keys.lua"),
439            "function PLUGIN:EnvKeys(ctx)\n  return { { key = 'PATH', value = ctx.path .. '/bin' } }\nend\n",
440        )?;
441        fs::write(
442            root.join("hooks/pre_use.lua"),
443            "function PLUGIN:PreUse(ctx)\n  if ctx.version == 'lts' then\n    return { version = '20.11.1' }\n  end\n  return { version = ctx.version }\nend\n",
444        )?;
445        Ok(())
446    }
447}