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