1use 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 pub fn use_tool(
15 &self,
16 plugin_name: &str,
17 version: &str,
18 scope: UseScope,
19 unlink: bool,
20 ) -> Result<InstalledVersion, CoreError> {
21 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 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 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 fn fuzzy_match_version(
161 &self,
162 plugin_name: &str,
163 version: &str,
164 installed_runtimes: &[InstalledRuntime],
165 ) -> String {
166 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 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 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
221fn 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; };
228
229 for line in content.lines() {
230 let trimmed = line.trim();
231 if trimmed == ".vs/" || trimmed == ".vs" {
232 return; }
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}