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 = 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 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 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 fn fuzzy_match_version(
157 &self,
158 plugin_name: &str,
159 version: &str,
160 installed_runtimes: &[InstalledRuntime],
161 ) -> String {
162 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 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 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
252fn 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; };
259
260 for line in content.lines() {
261 let trimmed = line.trim();
262 if trimmed == ".vs/" || trimmed == ".vs" {
263 return; }
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"))?;
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}