1use std::collections::BTreeSet;
4use std::env::{join_paths, split_paths};
5use std::fs;
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use vs_config::{
11 AppConfig, HomeLayout, ResolvedToolVersion, Scope, ToolVersions, find_project_file,
12 global_tools_file, preferred_project_file, read_app_config, read_tool_versions, resolve_home,
13 session_tools_file, supported_legacy_files, write_tool_versions,
14};
15use vs_installer::{Installer, InstallerOptions};
16use vs_plugin_api::{AvailableVersion, EnvKey, Plugin, PluginBackendKind};
17#[cfg(feature = "lua")]
18use vs_plugin_lua::LuaBackend;
19#[cfg(feature = "wasi")]
20use vs_plugin_wasi::WasiBackend;
21use vs_registry::{RegistryEntry, RegistryService};
22use vs_shell::{
23 EnvDelta, HomePaths, ShellKind, bin_dir, global_current_dir, home_paths, install_dir,
24 project_sdk_dir,
25};
26
27use crate::error::CoreError;
28use crate::models::CurrentTool;
29#[cfg(feature = "lua")]
30use crate::registry_source::DEFAULT_VFOX_REGISTRY_SOURCE;
31
32#[derive(Debug, Clone)]
34pub struct App {
35 pub(crate) home_layout: HomeLayout,
36 pub(crate) cwd: PathBuf,
37 pub(crate) session_id: Option<String>,
38 pub(crate) runtime_settings: RuntimeSettings,
39 pub(crate) registry: RegistryService,
40 pub(crate) installer: Installer,
41 #[cfg(feature = "lua")]
42 pub(crate) lua_backend: LuaBackend,
43 #[cfg(feature = "wasi")]
44 pub(crate) wasi_backend: WasiBackend,
45}
46
47#[derive(Debug, Clone)]
48pub(crate) struct RuntimeSettings {
49 runtime_root: PathBuf,
50 proxy_url: Option<String>,
51 legacy_enabled: bool,
52 legacy_strategy: String,
53 available_hook_cache_ttl: Option<Duration>,
54}
55
56#[derive(Debug, serde::Serialize, serde::Deserialize)]
57struct AvailableVersionsCacheEntry {
58 cached_at_epoch_secs: u64,
59 versions: Vec<AvailableVersion>,
60}
61
62impl RuntimeSettings {
63 fn from_config(home: &Path, config: &AppConfig) -> Self {
64 let runtime_root = normalize_runtime_root(home, &config.storage.sdk_path);
65 let proxy_url = config
66 .proxy
67 .enable
68 .then(|| config.proxy.url.trim())
69 .filter(|value| !value.is_empty())
70 .map(ToString::to_string);
71 let available_hook_cache_ttl = parse_duration_spec(&config.cache.available_hook_duration);
72
73 Self {
74 runtime_root,
75 proxy_url,
76 legacy_enabled: config.legacy_version_file.enable,
77 legacy_strategy: normalize_legacy_strategy(&config.legacy_version_file.strategy),
78 available_hook_cache_ttl,
79 }
80 }
81}
82
83impl App {
84 pub fn from_env() -> Result<Self, CoreError> {
86 let home_layout = resolve_home()?;
87 let cwd = std::env::current_dir().map_err(vs_config::ConfigError::from)?;
88 let session_id = std::env::var("VS_SESSION_ID").ok();
89 Self::new(home_layout, cwd, session_id)
90 }
91
92 pub fn new(
94 home_layout: HomeLayout,
95 cwd: PathBuf,
96 session_id: Option<String>,
97 ) -> Result<Self, CoreError> {
98 let config = read_app_config(&home_layout.active_home)?;
99 let runtime_settings = RuntimeSettings::from_config(&home_layout.active_home, &config);
100 let registry = RegistryService::new(home_layout.active_home.clone());
101 let installer = Installer::with_options(
102 home_layout.active_home.clone(),
103 InstallerOptions {
104 runtime_root: Some(runtime_settings.runtime_root.clone()),
105 proxy_url: runtime_settings.proxy_url.clone(),
106 },
107 );
108 let app = Self {
109 home_layout,
110 cwd,
111 session_id,
112 runtime_settings,
113 registry,
114 installer,
115 #[cfg(feature = "lua")]
116 lua_backend: LuaBackend::with_proxy(configured_proxy_url(&config)),
117 #[cfg(feature = "wasi")]
118 wasi_backend: WasiBackend,
119 };
120 app.ensure_home_layout()?;
121 Ok(app)
122 }
123
124 pub(crate) fn home(&self) -> &Path {
125 &self.home_layout.active_home
126 }
127
128 pub(crate) fn runtime_root(&self) -> &Path {
129 &self.runtime_settings.runtime_root
130 }
131
132 pub(crate) fn proxy_url(&self) -> Option<&str> {
133 self.runtime_settings.proxy_url.as_deref()
134 }
135
136 pub(crate) fn legacy_strategy(&self) -> &str {
137 &self.runtime_settings.legacy_strategy
138 }
139
140 pub(crate) fn home_paths(&self) -> HomePaths {
141 home_paths(self.home(), self.runtime_root())
142 }
143
144 pub(crate) fn app_config(&self) -> Result<AppConfig, CoreError> {
145 let mut config = read_app_config(self.home())?;
146 if config.registry.address.is_empty() {
147 if let Some(default_source) = self.default_registry_source() {
148 config.registry.address = default_source.to_string();
149 }
150 }
151 Ok(config)
152 }
153
154 pub(crate) fn ensure_home_layout(&self) -> Result<(), CoreError> {
155 let layout = self.home_paths();
156 fs::create_dir_all(&layout.home)?;
157 fs::create_dir_all(&layout.registry_dir)?;
158 fs::create_dir_all(&layout.plugins_dir)?;
159 fs::create_dir_all(layout.plugins_dir.join("sources"))?;
160 fs::create_dir_all(&layout.cache_dir)?;
161 fs::create_dir_all(&layout.runtime_dir)?;
162 fs::create_dir_all(layout.home.join("downloads"))?;
163 fs::create_dir_all(&layout.shims_dir)?;
164 fs::create_dir_all(&layout.sessions_dir)?;
165 fs::create_dir_all(&layout.global_dir)?;
166 Ok(())
167 }
168
169 pub(crate) fn normalize_source_path(&self, source: &str) -> PathBuf {
170 let path = PathBuf::from(source);
171 if path.is_absolute() {
172 path
173 } else {
174 self.cwd.join(path)
175 }
176 }
177
178 pub(crate) fn resolve_registry_entry(&self, name: &str) -> Result<RegistryEntry, CoreError> {
179 if let Some(entry) = self
180 .registry
181 .added_plugins()?
182 .into_iter()
183 .find(|entry| entry.matches(name))
184 {
185 return Ok(entry);
186 }
187
188 self.refresh_registry_index_with_fallback()?;
189 self.registry
190 .available_plugins()?
191 .into_iter()
192 .find(|entry| entry.matches(name))
193 .ok_or_else(|| CoreError::UnknownPlugin(name.to_string()))
194 }
195
196 pub(crate) fn refresh_registry_index_with_fallback(&self) -> Result<(), CoreError> {
197 let config = self.app_config()?;
198 if config.registry.address.is_empty() {
199 return Ok(());
200 }
201
202 match self.update_registry() {
203 Ok(_) => Ok(()),
204 Err(error) => {
205 if self.registry.available_plugins()?.is_empty() {
206 Err(error)
207 } else {
208 Ok(())
209 }
210 }
211 }
212 }
213
214 pub(crate) fn load_plugin(&self, entry: &RegistryEntry) -> Result<Box<dyn Plugin>, CoreError> {
215 let entry = self.materialize_plugin_entry(entry)?;
216 #[cfg(any(feature = "lua", feature = "wasi"))]
217 let source = self.normalize_source_path(&entry.source);
218 match entry.backend {
219 PluginBackendKind::Lua => {
220 #[cfg(feature = "lua")]
221 {
222 self.lua_backend.load(&source).map_err(Into::into)
223 }
224 #[cfg(not(feature = "lua"))]
225 {
226 Err(CoreError::UnsupportedBackend {
227 backend: "lua",
228 feature: "lua",
229 })
230 }
231 }
232 PluginBackendKind::Wasi => {
233 #[cfg(feature = "wasi")]
234 {
235 self.wasi_backend.load(&source).map_err(Into::into)
236 }
237 #[cfg(not(feature = "wasi"))]
238 {
239 Err(CoreError::UnsupportedBackend {
240 backend: "wasi",
241 feature: "wasi",
242 })
243 }
244 }
245 }
246 }
247
248 pub(crate) fn ensure_backend_supported(
249 &self,
250 backend: PluginBackendKind,
251 ) -> Result<(), CoreError> {
252 match backend {
253 PluginBackendKind::Lua => {
254 #[cfg(feature = "lua")]
255 {
256 Ok(())
257 }
258 #[cfg(not(feature = "lua"))]
259 {
260 Err(CoreError::UnsupportedBackend {
261 backend: "lua",
262 feature: "lua",
263 })
264 }
265 }
266 PluginBackendKind::Wasi => {
267 #[cfg(feature = "wasi")]
268 {
269 Ok(())
270 }
271 #[cfg(not(feature = "wasi"))]
272 {
273 Err(CoreError::UnsupportedBackend {
274 backend: "wasi",
275 feature: "wasi",
276 })
277 }
278 }
279 }
280 }
281
282 pub(crate) fn default_backend(&self) -> Result<PluginBackendKind, CoreError> {
283 #[cfg(all(feature = "lua", feature = "wasi"))]
284 {
285 Ok(PluginBackendKind::Lua)
286 }
287 #[cfg(all(feature = "lua", not(feature = "wasi")))]
288 {
289 Ok(PluginBackendKind::Lua)
290 }
291 #[cfg(all(feature = "wasi", not(feature = "lua")))]
292 {
293 Ok(PluginBackendKind::Wasi)
294 }
295 #[cfg(not(any(feature = "lua", feature = "wasi")))]
296 {
297 Err(CoreError::Unsupported(String::from(
298 "no plugin backend is enabled in this build",
299 )))
300 }
301 }
302
303 pub(crate) fn default_registry_source(&self) -> Option<&'static str> {
304 #[cfg(feature = "lua")]
305 {
306 Some(DEFAULT_VFOX_REGISTRY_SOURCE)
307 }
308 #[cfg(not(feature = "lua"))]
309 {
310 None
311 }
312 }
313
314 pub(crate) fn write_tool_assignment(
315 &self,
316 path: &Path,
317 plugin: &str,
318 version: Option<&str>,
319 ) -> Result<(), CoreError> {
320 let mut tools = if path.exists() {
321 read_tool_versions(path)?
322 } else {
323 ToolVersions::default()
324 };
325 match version {
326 Some(version) => {
327 tools.tools.insert(plugin.to_string(), version.to_string());
328 }
329 None => {
330 tools.tools.remove(plugin);
331 }
332 }
333 write_tool_versions(path, &tools)?;
334 Ok(())
335 }
336
337 pub(crate) fn collect_current_tools(&self) -> Result<Vec<CurrentTool>, CoreError> {
338 let mut tools = self
339 .collect_known_tool_names()?
340 .into_iter()
341 .map(|plugin| {
342 self.resolve_configured_tool_version(&plugin)
343 .map(|resolved| {
344 resolved.map(|resolved| CurrentTool {
345 plugin: resolved.plugin,
346 version: resolved.version,
347 scope: resolved.scope,
348 source: resolved.source,
349 })
350 })
351 })
352 .collect::<Result<Vec<_>, _>>()?
353 .into_iter()
354 .flatten()
355 .collect::<Vec<_>>();
356
357 tools.sort_by(|left, right| left.plugin.cmp(&right.plugin));
358 Ok(tools)
359 }
360
361 pub(crate) fn resolve_configured_tool_version(
362 &self,
363 plugin: &str,
364 ) -> Result<Option<ResolvedToolVersion>, CoreError> {
365 if let Some(resolved) = self.resolve_project_tool_version_internal(plugin)? {
366 return Ok(Some(resolved));
367 }
368
369 if let Some(session_id) = self.session_id.as_deref() {
370 let path = session_tools_file(self.home(), session_id);
371 if path.exists() {
372 let versions = read_tool_versions(&path)?;
373 if let Some(version) = versions.tools.get(plugin) {
374 return Ok(Some(ResolvedToolVersion {
375 plugin: plugin.to_string(),
376 version: version.clone(),
377 scope: Scope::Session,
378 source: path,
379 }));
380 }
381 }
382 }
383
384 let path = global_tools_file(self.home());
385 if path.exists() {
386 let versions = read_tool_versions(&path)?;
387 if let Some(version) = versions.tools.get(plugin) {
388 return Ok(Some(ResolvedToolVersion {
389 plugin: plugin.to_string(),
390 version: version.clone(),
391 scope: Scope::Global,
392 source: path,
393 }));
394 }
395 }
396
397 Ok(None)
398 }
399
400 pub(crate) fn resolve_project_tool_version_internal(
401 &self,
402 plugin: &str,
403 ) -> Result<Option<ResolvedToolVersion>, CoreError> {
404 if let Some(path) = find_project_file(&self.cwd) {
405 let versions = read_tool_versions(&path)?;
406 if let Some(version) = versions.tools.get(plugin) {
407 return Ok(Some(ResolvedToolVersion {
408 plugin: plugin.to_string(),
409 version: version.clone(),
410 scope: Scope::Project,
411 source: path,
412 }));
413 }
414 }
415
416 self.resolve_legacy_tool_version(plugin)
417 }
418
419 fn collect_known_tool_names(&self) -> Result<BTreeSet<String>, CoreError> {
420 let mut names = BTreeSet::new();
421 if let Some(path) = find_project_file(&self.cwd) {
422 names.extend(read_tool_versions(&path)?.tools.into_keys());
423 }
424
425 if self.runtime_settings.legacy_enabled {
426 names.extend(self.collect_generic_legacy_tool_names()?);
427 names.extend(self.added_plugins()?.into_iter().map(|entry| entry.name));
428 names.extend(
429 self.list_installed_versions()?
430 .into_iter()
431 .map(|installed| installed.plugin),
432 );
433 }
434
435 let session_path = self
436 .session_id
437 .as_deref()
438 .map(|session_id| session_tools_file(self.home(), session_id));
439 if let Some(path) = session_path.as_deref()
440 && path.exists()
441 {
442 names.extend(read_tool_versions(path)?.tools.into_keys());
443 }
444
445 let global_path = global_tools_file(self.home());
446 if global_path.exists() {
447 names.extend(read_tool_versions(&global_path)?.tools.into_keys());
448 }
449
450 Ok(names)
451 }
452
453 fn collect_generic_legacy_tool_names(&self) -> Result<BTreeSet<String>, CoreError> {
454 let mut names = BTreeSet::new();
455 for directory in self.cwd.ancestors() {
456 for file_name in supported_legacy_files() {
457 let path = directory.join(file_name);
458 if !path.exists() {
459 continue;
460 }
461 let content = fs::read_to_string(&path)?;
462 names.extend(parse_generic_legacy_tool_names(file_name, &content));
463 }
464 }
465 Ok(names)
466 }
467
468 fn resolve_legacy_tool_version(
469 &self,
470 plugin: &str,
471 ) -> Result<Option<ResolvedToolVersion>, CoreError> {
472 if !self.runtime_settings.legacy_enabled {
473 return Ok(None);
474 }
475
476 let installed_versions = self
477 .installed_versions_for_plugin(plugin)?
478 .into_iter()
479 .map(|installed| installed.version)
480 .collect::<Vec<_>>();
481 let plugin_impl = self.load_added_plugin_for_legacy(plugin)?;
482
483 for directory in self.cwd.ancestors() {
484 for file_name in legacy_candidate_file_names(plugin_impl.as_deref()) {
485 let path = directory.join(&file_name);
486 if !path.exists() {
487 continue;
488 }
489 let content = fs::read_to_string(&path)?;
490 if let Some(version) = self.parse_legacy_tool_version(
491 plugin,
492 plugin_impl.as_deref(),
493 &file_name,
494 &path,
495 &content,
496 &installed_versions,
497 )? {
498 return Ok(Some(ResolvedToolVersion {
499 plugin: plugin.to_string(),
500 version,
501 scope: Scope::Project,
502 source: path,
503 }));
504 }
505 }
506 }
507
508 Ok(None)
509 }
510
511 fn load_added_plugin_for_legacy(
512 &self,
513 plugin: &str,
514 ) -> Result<Option<Box<dyn Plugin>>, CoreError> {
515 let entry = self
516 .added_plugins()?
517 .into_iter()
518 .find(|entry| entry.matches(plugin));
519 match entry {
520 Some(entry) => self.load_plugin(&entry).map(Some),
521 None => Ok(None),
522 }
523 }
524
525 fn parse_legacy_tool_version(
526 &self,
527 plugin_name: &str,
528 plugin: Option<&dyn Plugin>,
529 file_name: &str,
530 file_path: &Path,
531 content: &str,
532 installed_versions: &[String],
533 ) -> Result<Option<String>, CoreError> {
534 if let Some(plugin) = plugin
535 && let Some(version) = plugin.parse_legacy_file(
536 file_name,
537 file_path,
538 content,
539 installed_versions,
540 self.legacy_strategy(),
541 )?
542 {
543 return Ok(Some(version));
544 }
545
546 self.parse_generic_legacy_tool_version(plugin_name, file_name, content, installed_versions)
547 }
548
549 fn parse_generic_legacy_tool_version(
550 &self,
551 plugin_name: &str,
552 file_name: &str,
553 content: &str,
554 installed_versions: &[String],
555 ) -> Result<Option<String>, CoreError> {
556 let parsed = match file_name {
557 ".tool-versions" => parse_tool_versions_content(content)
558 .remove(plugin_name)
559 .filter(|value| !value.is_empty()),
560 ".nvmrc" | ".node-version" if plugin_name == "nodejs" => {
561 let version = content.trim();
562 (!version.is_empty()).then(|| version.to_string())
563 }
564 ".sdkmanrc" => parse_sdkmanrc_content(content)
565 .remove(plugin_name)
566 .filter(|value| !value.is_empty()),
567 _ => None,
568 };
569
570 let Some(parsed) = parsed else {
571 return Ok(None);
572 };
573
574 match self.legacy_strategy() {
575 "latest_installed" => {
576 Ok(select_matching_version(&parsed, installed_versions).or(Some(parsed)))
577 }
578 "latest_available" => {
579 let available = self
580 .cached_available_versions(plugin_name, &[])
581 .unwrap_or_default()
582 .into_iter()
583 .map(|version| version.version)
584 .collect::<Vec<_>>();
585 Ok(select_matching_version(&parsed, &available).or(Some(parsed)))
586 }
587 _ => Ok(Some(parsed)),
588 }
589 }
590
591 pub(crate) fn effective_runtime_dir(&self, current: &CurrentTool) -> PathBuf {
592 match current.scope {
593 Scope::Project => {
594 let linked = project_sdk_dir(&self.cwd, ¤t.plugin);
595 if linked.exists() {
596 linked
597 } else {
598 install_dir(self.runtime_root(), ¤t.plugin, ¤t.version)
599 }
600 }
601 Scope::Global => {
602 let linked = global_current_dir(self.runtime_root(), ¤t.plugin);
603 if linked.exists() {
604 linked
605 } else {
606 install_dir(self.runtime_root(), ¤t.plugin, ¤t.version)
607 }
608 }
609 Scope::Session | Scope::System => {
610 install_dir(self.runtime_root(), ¤t.plugin, ¤t.version)
611 }
612 }
613 }
614
615 pub(crate) fn load_installed_runtime(
616 &self,
617 plugin: &str,
618 version: &str,
619 ) -> Result<Option<vs_plugin_api::InstalledRuntime>, CoreError> {
620 self.installer
621 .read_receipt(plugin, version)
622 .map_err(Into::into)
623 }
624
625 pub(crate) fn build_env(&self) -> Result<EnvDelta, CoreError> {
626 let current_tools = self.collect_current_tools()?;
627 let mut delta = EnvDelta::default();
628
629 for tool in ¤t_tools {
630 self.apply_tool_env(tool, &mut delta)?;
631 }
632
633 Ok(delta)
634 }
635
636 fn apply_tool_env(&self, tool: &CurrentTool, delta: &mut EnvDelta) -> Result<(), CoreError> {
642 if self.apply_installed_tool_env(tool, delta)? {
644 return Ok(());
645 }
646
647 if tool.scope != Scope::Global
651 && let Some(global) = self.global_tool(&tool.plugin)?
652 && global.version != tool.version
653 && self.apply_installed_tool_env(&global, delta)?
654 {
655 return Ok(());
656 }
657
658 delta
661 .path_entries
662 .push(bin_dir(&self.effective_runtime_dir(tool)));
663 Ok(())
664 }
665
666 fn apply_installed_tool_env(
669 &self,
670 tool: &CurrentTool,
671 delta: &mut EnvDelta,
672 ) -> Result<bool, CoreError> {
673 let Some(runtime) = self.load_installed_runtime(&tool.plugin, &tool.version)? else {
674 return Ok(false);
675 };
676
677 let runtime = runtime.relocate(&self.effective_runtime_dir(tool));
681 if let Ok(entry) = self.resolve_registry_entry(&tool.plugin) {
682 let plugin = self.load_plugin(&entry)?;
683 let env_keys = plugin.env_keys(&runtime)?;
684 apply_env_keys(delta, env_keys);
685 } else {
686 delta.path_entries.push(bin_dir(runtime.main_path()));
687 }
688 Ok(true)
689 }
690
691 fn global_tool(&self, plugin: &str) -> Result<Option<CurrentTool>, CoreError> {
694 let path = global_tools_file(self.home());
695 if !path.exists() {
696 return Ok(None);
697 }
698 let versions = read_tool_versions(&path)?;
699 Ok(versions.tools.get(plugin).map(|version| CurrentTool {
700 plugin: plugin.to_string(),
701 version: version.clone(),
702 scope: Scope::Global,
703 source: path,
704 }))
705 }
706
707 pub(crate) fn path_with_delta(&self, delta: &EnvDelta) -> Result<String, CoreError> {
708 let mut entries = delta.path_entries.clone();
709 let base_path = std::env::var_os("__VS_ORIG_PATH").or_else(|| std::env::var_os("PATH"));
712 let existing_entries = base_path
713 .map(|paths| split_paths(&paths).collect::<Vec<_>>())
714 .unwrap_or_default();
715 entries.extend(existing_entries);
716 let joined = join_paths(entries).map_err(|error| {
717 CoreError::Unsupported(format!("failed to join PATH entries: {error}"))
718 })?;
719 Ok(joined.to_string_lossy().into_owned())
720 }
721
722 pub(crate) fn render_hook_env(&self, shell: ShellKind) -> Result<String, CoreError> {
723 let orig_path_needs_export = std::env::var_os("__VS_ORIG_PATH").is_none();
726 let orig_path_value = std::env::var("__VS_ORIG_PATH")
727 .or_else(|_| std::env::var("PATH"))
728 .unwrap_or_default();
729
730 let delta = self.build_env()?;
731 let path_value = self.path_with_delta(&delta)?;
732 let state_hash = compute_env_state_hash(&delta, &path_value);
733 let prev_hash = std::env::var("__VS_STATE_HASH").unwrap_or_default();
734 if !prev_hash.is_empty() && state_hash == prev_hash {
735 return Ok(String::new());
736 }
737
738 let prev_keys: Vec<String> = std::env::var("__VS_VARS")
742 .unwrap_or_default()
743 .split(':')
744 .filter(|s| !s.is_empty())
745 .map(String::from)
746 .collect();
747 Ok(render_shell_env_lines(
748 shell,
749 orig_path_needs_export,
750 &orig_path_value,
751 &prev_keys,
752 &delta,
753 &path_value,
754 &state_hash,
755 )
756 .join("\n"))
757 }
758
759 pub(crate) fn preferred_project_file(&self) -> PathBuf {
760 preferred_project_file(&self.cwd)
761 }
762
763 pub(crate) fn session_file(&self) -> Result<PathBuf, CoreError> {
764 let session_id = self
765 .session_id
766 .as_deref()
767 .ok_or(CoreError::MissingSessionId)?;
768 Ok(session_tools_file(self.home(), session_id))
769 }
770
771 pub(crate) fn cached_available_versions(
772 &self,
773 plugin_name: &str,
774 args: &[String],
775 ) -> Result<Vec<AvailableVersion>, CoreError> {
776 if let Some(versions) = self.read_available_versions_cache(plugin_name, args)? {
777 return Ok(versions);
778 }
779
780 let entry = self.resolve_registry_entry(plugin_name)?;
781 let plugin = self.load_plugin(&entry)?;
782 let versions = plugin.available_versions(args)?;
783 self.write_available_versions_cache(plugin_name, args, &versions)?;
784 Ok(versions)
785 }
786
787 fn available_versions_cache_path(&self, plugin_name: &str, args: &[String]) -> PathBuf {
788 let mut hasher = std::collections::hash_map::DefaultHasher::new();
789 plugin_name.hash(&mut hasher);
790 args.hash(&mut hasher);
791 let key = format!("{:x}", hasher.finish());
792 self.home()
793 .join("cache")
794 .join("available-hooks")
795 .join(plugin_name)
796 .join(format!("{key}.json"))
797 }
798
799 fn read_available_versions_cache(
800 &self,
801 plugin_name: &str,
802 args: &[String],
803 ) -> Result<Option<Vec<AvailableVersion>>, CoreError> {
804 let Some(ttl) = self.runtime_settings.available_hook_cache_ttl else {
805 return Ok(None);
806 };
807 let path = self.available_versions_cache_path(plugin_name, args);
808 if !path.exists() {
809 return Ok(None);
810 }
811
812 let content = fs::read_to_string(&path)?;
813 let entry =
814 serde_json::from_str::<AvailableVersionsCacheEntry>(&content).map_err(|error| {
815 CoreError::Unsupported(format!("failed to parse available cache: {error}"))
816 })?;
817 let cached_at = UNIX_EPOCH + Duration::from_secs(entry.cached_at_epoch_secs);
818 let is_fresh = SystemTime::now()
819 .duration_since(cached_at)
820 .map(|age| age <= ttl)
821 .unwrap_or(false);
822 if is_fresh {
823 Ok(Some(entry.versions))
824 } else {
825 Ok(None)
826 }
827 }
828
829 fn write_available_versions_cache(
830 &self,
831 plugin_name: &str,
832 args: &[String],
833 versions: &[AvailableVersion],
834 ) -> Result<(), CoreError> {
835 if self.runtime_settings.available_hook_cache_ttl.is_none() {
836 return Ok(());
837 }
838
839 let path = self.available_versions_cache_path(plugin_name, args);
840 if let Some(parent) = path.parent() {
841 fs::create_dir_all(parent)?;
842 }
843 let cached_at_epoch_secs = SystemTime::now()
844 .duration_since(UNIX_EPOCH)
845 .unwrap_or_default()
846 .as_secs();
847 let rendered = serde_json::to_string_pretty(&AvailableVersionsCacheEntry {
848 cached_at_epoch_secs,
849 versions: versions.to_vec(),
850 })
851 .map_err(|error| {
852 CoreError::Unsupported(format!("failed to render available cache: {error}"))
853 })?;
854 fs::write(path, rendered)?;
855 Ok(())
856 }
857
858 pub(crate) fn copy_tree(&self, source: &Path, destination: &Path) -> Result<(), CoreError> {
859 if !source.exists() {
860 return Ok(());
861 }
862 for entry in walkdir::WalkDir::new(source) {
863 let entry = entry.map_err(|error| CoreError::Unsupported(error.to_string()))?;
864 let relative = entry
865 .path()
866 .strip_prefix(source)
867 .map_err(|error| CoreError::Unsupported(error.to_string()))?;
868 let target = destination.join(relative);
869 if entry.file_type().is_dir() {
870 fs::create_dir_all(&target)?;
871 } else {
872 if let Some(parent) = target.parent() {
873 fs::create_dir_all(parent)?;
874 }
875 fs::copy(entry.path(), &target)?;
876 }
877 }
878 Ok(())
879 }
880}
881
882fn configured_proxy_url(config: &AppConfig) -> Option<String> {
883 config
884 .proxy
885 .enable
886 .then(|| config.proxy.url.trim())
887 .filter(|value| !value.is_empty())
888 .map(ToString::to_string)
889}
890
891fn normalize_runtime_root(home: &Path, configured_path: &str) -> PathBuf {
892 let trimmed = configured_path.trim();
893 if trimmed.is_empty() {
894 return home.join("cache");
895 }
896
897 let path = PathBuf::from(trimmed);
898 if path.is_absolute() {
899 path
900 } else {
901 home.join(path)
902 }
903}
904
905fn normalize_legacy_strategy(strategy: &str) -> String {
906 match strategy {
907 "latest_installed" | "latest_available" => strategy.to_string(),
908 _ => String::from("specified"),
909 }
910}
911
912fn parse_duration_spec(value: &str) -> Option<Duration> {
913 let trimmed = value.trim();
914 if trimmed.is_empty() || trimmed == "0" {
915 return None;
916 }
917
918 let split_at = trimmed
919 .find(|ch: char| !ch.is_ascii_digit())
920 .unwrap_or(trimmed.len());
921 let (amount, unit) = trimmed.split_at(split_at);
922 let amount = amount.parse::<u64>().ok()?;
923 if amount == 0 {
924 return None;
925 }
926
927 let seconds = match unit {
928 "" | "s" => amount,
929 "m" => amount.saturating_mul(60),
930 "h" => amount.saturating_mul(60 * 60),
931 "d" => amount.saturating_mul(60 * 60 * 24),
932 _ => return None,
933 };
934 Some(Duration::from_secs(seconds))
935}
936
937fn legacy_candidate_file_names(plugin: Option<&dyn Plugin>) -> Vec<String> {
938 let mut candidates = supported_legacy_files()
939 .iter()
940 .map(|file_name| (*file_name).to_string())
941 .collect::<Vec<_>>();
942 if let Some(plugin) = plugin {
943 for file_name in &plugin.manifest().legacy_filenames {
944 if !candidates.iter().any(|existing| existing == file_name) {
945 candidates.push(file_name.clone());
946 }
947 }
948 }
949 candidates
950}
951
952fn parse_generic_legacy_tool_names(file_name: &str, content: &str) -> BTreeSet<String> {
953 match file_name {
954 ".tool-versions" => parse_tool_versions_content(content).into_keys().collect(),
955 ".nvmrc" | ".node-version" => {
956 let mut names = BTreeSet::new();
957 if !content.trim().is_empty() {
958 names.insert(String::from("nodejs"));
959 }
960 names
961 }
962 ".sdkmanrc" => parse_sdkmanrc_content(content).into_keys().collect(),
963 _ => BTreeSet::new(),
964 }
965}
966
967fn parse_tool_versions_content(content: &str) -> std::collections::BTreeMap<String, String> {
968 content
969 .lines()
970 .map(str::trim)
971 .filter(|line| !line.is_empty() && !line.starts_with('#'))
972 .filter_map(|line| {
973 let mut parts = line.split_whitespace();
974 Some((parts.next()?.to_string(), parts.next()?.to_string()))
975 })
976 .collect()
977}
978
979fn parse_sdkmanrc_content(content: &str) -> std::collections::BTreeMap<String, String> {
980 content
981 .lines()
982 .map(str::trim)
983 .filter(|line| !line.is_empty() && !line.starts_with('#'))
984 .filter_map(|line| {
985 let (plugin, version) = line.split_once('=')?;
986 Some((plugin.trim().to_string(), version.trim().to_string()))
987 })
988 .collect()
989}
990
991fn select_matching_version(selector: &str, candidates: &[String]) -> Option<String> {
992 if candidates.is_empty() {
993 return None;
994 }
995 let selector = selector.trim();
996 if selector.is_empty() {
997 return candidates.first().cloned();
998 }
999
1000 if let Some(candidate) = candidates
1001 .iter()
1002 .find(|candidate| candidate.as_str() == selector)
1003 {
1004 return Some(candidate.clone());
1005 }
1006
1007 let prefix = format!("{selector}.");
1008 candidates
1009 .iter()
1010 .find(|candidate| candidate.starts_with(&prefix))
1011 .cloned()
1012}
1013
1014fn apply_env_keys(delta: &mut EnvDelta, env_keys: Vec<EnvKey>) {
1015 for env_key in env_keys {
1016 if env_key.key == "PATH" {
1017 delta.path_entries.push(PathBuf::from(env_key.value));
1018 } else {
1019 delta.vars.push((env_key.key, env_key.value));
1020 }
1021 }
1022}
1023
1024fn compute_env_state_hash(delta: &EnvDelta, path_value: &str) -> String {
1025 use std::collections::hash_map::DefaultHasher;
1026 use std::hash::{Hash, Hasher};
1027
1028 let mut hasher = DefaultHasher::new();
1029 path_value.hash(&mut hasher);
1030 delta.vars.hash(&mut hasher);
1031 delta.path_entries.hash(&mut hasher);
1032 format!("{:x}", hasher.finish())
1033}
1034
1035fn render_shell_env_lines(
1036 shell: ShellKind,
1037 orig_path_needs_export: bool,
1038 orig_path_value: &str,
1039 prev_keys: &[String],
1040 delta: &EnvDelta,
1041 path_value: &str,
1042 state_hash: &str,
1043) -> Vec<String> {
1044 let new_keys: Vec<&str> = delta.vars.iter().map(|(key, _)| key.as_str()).collect();
1045 let stale_keys: Vec<&String> = prev_keys
1046 .iter()
1047 .filter(|key| !new_keys.contains(&key.as_str()))
1048 .collect();
1049 let new_keys_joined = new_keys.join(":");
1050 let mut lines = Vec::new();
1051
1052 match shell {
1053 ShellKind::Bash | ShellKind::Zsh => {
1054 if orig_path_needs_export {
1055 lines.push(format!(
1056 "export __VS_ORIG_PATH='{}'",
1057 orig_path_value.replace('\'', "'\"'\"'")
1058 ));
1059 }
1060 for key in &stale_keys {
1061 lines.push(format!("unset {key}"));
1062 }
1063 for (key, value) in &delta.vars {
1064 lines.push(format!("export {key}='{}'", value.replace('\'', "'\"'\"'")));
1065 }
1066 lines.push(format!(
1067 "export PATH='{}'",
1068 path_value.replace('\'', "'\"'\"'")
1069 ));
1070 lines.push(format!("export __VS_VARS='{new_keys_joined}'"));
1071 lines.push(format!("export __VS_STATE_HASH='{state_hash}'"));
1072 }
1073 ShellKind::Fish => {
1074 if orig_path_needs_export {
1075 lines.push(format!(
1076 "set -gx __VS_ORIG_PATH '{}'",
1077 orig_path_value.replace('\'', "\\'")
1078 ));
1079 }
1080 for key in &stale_keys {
1081 lines.push(format!("set -e {key}"));
1082 }
1083 for (key, value) in &delta.vars {
1084 lines.push(format!("set -gx {key} '{}'", value.replace('\'', "\\'")));
1085 }
1086 lines.push(format!(
1087 "set -gx PATH '{}'",
1088 path_value.replace('\'', "\\'")
1089 ));
1090 lines.push(format!("set -gx __VS_VARS '{new_keys_joined}'"));
1091 lines.push(format!("set -gx __VS_STATE_HASH '{state_hash}'"));
1092 }
1093 ShellKind::Nushell => {
1094 if orig_path_needs_export {
1095 lines.push(serde_json::json!({ "__VS_ORIG_PATH": orig_path_value }).to_string());
1096 }
1097 for key in &stale_keys {
1098 lines.push(serde_json::json!({ "__VS_UNSET": key }).to_string());
1099 }
1100 for (key, value) in &delta.vars {
1101 lines.push(serde_json::json!({ key: value }).to_string());
1102 }
1103 lines.push(serde_json::json!({ "PATH": path_value }).to_string());
1104 lines.push(serde_json::json!({ "__VS_VARS": new_keys_joined }).to_string());
1105 lines.push(serde_json::json!({ "__VS_STATE_HASH": state_hash }).to_string());
1106 }
1107 ShellKind::Pwsh => {
1108 if orig_path_needs_export {
1109 lines.push(format!(
1110 "$env:__VS_ORIG_PATH = '{}'",
1111 orig_path_value.replace('\'', "''")
1112 ));
1113 }
1114 for key in &stale_keys {
1115 lines.push(format!(
1116 "Remove-Item Env:\\{key} -ErrorAction SilentlyContinue"
1117 ));
1118 }
1119 for (key, value) in &delta.vars {
1120 lines.push(format!("$env:{key} = '{}'", value.replace('\'', "''")));
1121 }
1122 lines.push(format!("$env:PATH = '{}'", path_value.replace('\'', "''")));
1123 lines.push(format!("$env:__VS_VARS = '{new_keys_joined}'"));
1124 lines.push(format!("$env:__VS_STATE_HASH = '{state_hash}'"));
1125 }
1126 ShellKind::Clink => {
1127 if orig_path_needs_export {
1128 lines.push(format!("set __VS_ORIG_PATH={orig_path_value}"));
1129 }
1130 for key in &stale_keys {
1131 lines.push(format!("set {key}="));
1132 }
1133 for (key, value) in &delta.vars {
1134 lines.push(format!("set {key}={value}"));
1135 }
1136 lines.push(format!("set PATH={path_value}"));
1137 lines.push(format!("set __VS_VARS={new_keys_joined}"));
1138 lines.push(format!("set __VS_STATE_HASH={state_hash}"));
1139 }
1140 }
1141
1142 lines
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use std::fs;
1148
1149 use tempfile::TempDir;
1150 use vs_config::HomeLayout;
1151 use vs_plugin_api::{InstalledArtifact, InstalledRuntime};
1152 use vs_shell::{EnvDelta, ShellKind, bin_dir, install_dir};
1153
1154 use super::{compute_env_state_hash, render_shell_env_lines};
1155 use crate::App;
1156
1157 fn write_tools_file(path: &std::path::Path, plugin: &str, version: &str) {
1158 if let Some(parent) = path.parent() {
1159 fs::create_dir_all(parent).unwrap();
1160 }
1161 fs::write(path, format!("[tools]\n{plugin} = \"{version}\"\n")).unwrap();
1162 }
1163
1164 fn write_receipt(runtime_root: &std::path::Path, plugin: &str, version: &str) {
1165 let dir = install_dir(runtime_root, plugin, version);
1166 fs::create_dir_all(&dir).unwrap();
1167 let runtime = InstalledRuntime {
1168 plugin: plugin.to_string(),
1169 version: version.to_string(),
1170 root_dir: dir.clone(),
1171 main: InstalledArtifact {
1172 name: plugin.to_string(),
1173 version: version.to_string(),
1174 path: dir.clone(),
1175 note: None,
1176 },
1177 additions: Vec::new(),
1178 };
1179 fs::write(
1180 dir.join(".vs-receipt.json"),
1181 serde_json::to_string(&runtime).unwrap(),
1182 )
1183 .unwrap();
1184 }
1185
1186 #[test]
1187 fn build_env_should_fall_back_to_global_when_project_version_is_not_installed() {
1188 let temp_dir = TempDir::new().unwrap();
1189 let home = temp_dir.path().join("home");
1190 let cwd = temp_dir.path().join("project");
1191 fs::create_dir_all(&cwd).unwrap();
1192
1193 let app = App::new(
1194 HomeLayout {
1195 active_home: home.clone(),
1196 migration_candidates: Vec::new(),
1197 },
1198 cwd.clone(),
1199 None,
1200 )
1201 .unwrap();
1202
1203 write_tools_file(&cwd.join(".vs.toml"), "nodejs", "24.12.0");
1205 write_tools_file(&home.join("global/tools.toml"), "nodejs", "25.8.2");
1206 write_receipt(app.runtime_root(), "nodejs", "25.8.2");
1207
1208 let delta = app.build_env().unwrap();
1209
1210 let global_bin = bin_dir(&install_dir(app.runtime_root(), "nodejs", "25.8.2"));
1211 let missing_bin = bin_dir(&install_dir(app.runtime_root(), "nodejs", "24.12.0"));
1212
1213 assert!(
1214 delta.path_entries.contains(&global_bin),
1215 "expected fallback to global default on PATH, got: {:?}",
1216 delta.path_entries
1217 );
1218 assert!(
1219 !delta.path_entries.contains(&missing_bin),
1220 "should not put the uninstalled project version on PATH, got: {:?}",
1221 delta.path_entries
1222 );
1223 }
1224
1225 #[test]
1226 fn env_state_hash_should_change_when_env_changes() {
1227 let first = compute_env_state_hash(
1228 &EnvDelta {
1229 vars: vec![(String::from("NODEJS_HOME"), String::from("/a"))],
1230 path_entries: Vec::new(),
1231 },
1232 "/a/bin:/usr/bin",
1233 );
1234 let second = compute_env_state_hash(
1235 &EnvDelta {
1236 vars: vec![(String::from("NODEJS_HOME"), String::from("/b"))],
1237 path_entries: Vec::new(),
1238 },
1239 "/b/bin:/usr/bin",
1240 );
1241
1242 assert_ne!(first, second);
1243 }
1244
1245 #[test]
1246 fn nushell_rendering_should_emit_unset_markers_for_stale_vars() {
1247 let lines = render_shell_env_lines(
1248 ShellKind::Nushell,
1249 false,
1250 "",
1251 &[String::from("OLD_HOME"), String::from("KEEP_HOME")],
1252 &EnvDelta {
1253 vars: vec![(String::from("KEEP_HOME"), String::from("/tool"))],
1254 path_entries: Vec::new(),
1255 },
1256 "/tool/bin:/usr/bin",
1257 "hash",
1258 );
1259
1260 assert!(
1261 lines
1262 .iter()
1263 .any(|line| line.contains("\"__VS_UNSET\":\"OLD_HOME\""))
1264 );
1265 assert!(!lines.iter().any(|line| line.contains("\"OLD_HOME\":\"\"")));
1266 }
1267}