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 let runtime_dir = self.effective_runtime_dir(tool);
631 if let Some(runtime) = self.load_installed_runtime(&tool.plugin, &tool.version)? {
632 let runtime = runtime.relocate(&runtime_dir);
636 if let Ok(entry) = self.resolve_registry_entry(&tool.plugin) {
637 let plugin = self.load_plugin(&entry)?;
638 let env_keys = plugin.env_keys(&runtime)?;
639 apply_env_keys(&mut delta, env_keys);
640 } else {
641 delta.path_entries.push(bin_dir(runtime.main_path()));
642 }
643 } else {
644 delta.path_entries.push(bin_dir(&runtime_dir));
645 }
646 }
647
648 Ok(delta)
649 }
650
651 pub(crate) fn path_with_delta(&self, delta: &EnvDelta) -> Result<String, CoreError> {
652 let mut entries = delta.path_entries.clone();
653 let base_path = std::env::var_os("__VS_ORIG_PATH").or_else(|| std::env::var_os("PATH"));
656 let existing_entries = base_path
657 .map(|paths| split_paths(&paths).collect::<Vec<_>>())
658 .unwrap_or_default();
659 entries.extend(existing_entries);
660 let joined = join_paths(entries).map_err(|error| {
661 CoreError::Unsupported(format!("failed to join PATH entries: {error}"))
662 })?;
663 Ok(joined.to_string_lossy().into_owned())
664 }
665
666 pub(crate) fn render_hook_env(&self, shell: ShellKind) -> Result<String, CoreError> {
667 let orig_path_needs_export = std::env::var_os("__VS_ORIG_PATH").is_none();
670 let orig_path_value = std::env::var("__VS_ORIG_PATH")
671 .or_else(|_| std::env::var("PATH"))
672 .unwrap_or_default();
673
674 let delta = self.build_env()?;
675 let path_value = self.path_with_delta(&delta)?;
676 let state_hash = compute_env_state_hash(&delta, &path_value);
677 let prev_hash = std::env::var("__VS_STATE_HASH").unwrap_or_default();
678 if !prev_hash.is_empty() && state_hash == prev_hash {
679 return Ok(String::new());
680 }
681
682 let prev_keys: Vec<String> = std::env::var("__VS_VARS")
686 .unwrap_or_default()
687 .split(':')
688 .filter(|s| !s.is_empty())
689 .map(String::from)
690 .collect();
691 Ok(render_shell_env_lines(
692 shell,
693 orig_path_needs_export,
694 &orig_path_value,
695 &prev_keys,
696 &delta,
697 &path_value,
698 &state_hash,
699 )
700 .join("\n"))
701 }
702
703 pub(crate) fn preferred_project_file(&self) -> PathBuf {
704 preferred_project_file(&self.cwd)
705 }
706
707 pub(crate) fn session_file(&self) -> Result<PathBuf, CoreError> {
708 let session_id = self
709 .session_id
710 .as_deref()
711 .ok_or(CoreError::MissingSessionId)?;
712 Ok(session_tools_file(self.home(), session_id))
713 }
714
715 pub(crate) fn cached_available_versions(
716 &self,
717 plugin_name: &str,
718 args: &[String],
719 ) -> Result<Vec<AvailableVersion>, CoreError> {
720 if let Some(versions) = self.read_available_versions_cache(plugin_name, args)? {
721 return Ok(versions);
722 }
723
724 let entry = self.resolve_registry_entry(plugin_name)?;
725 let plugin = self.load_plugin(&entry)?;
726 let versions = plugin.available_versions(args)?;
727 self.write_available_versions_cache(plugin_name, args, &versions)?;
728 Ok(versions)
729 }
730
731 fn available_versions_cache_path(&self, plugin_name: &str, args: &[String]) -> PathBuf {
732 let mut hasher = std::collections::hash_map::DefaultHasher::new();
733 plugin_name.hash(&mut hasher);
734 args.hash(&mut hasher);
735 let key = format!("{:x}", hasher.finish());
736 self.home()
737 .join("cache")
738 .join("available-hooks")
739 .join(plugin_name)
740 .join(format!("{key}.json"))
741 }
742
743 fn read_available_versions_cache(
744 &self,
745 plugin_name: &str,
746 args: &[String],
747 ) -> Result<Option<Vec<AvailableVersion>>, CoreError> {
748 let Some(ttl) = self.runtime_settings.available_hook_cache_ttl else {
749 return Ok(None);
750 };
751 let path = self.available_versions_cache_path(plugin_name, args);
752 if !path.exists() {
753 return Ok(None);
754 }
755
756 let content = fs::read_to_string(&path)?;
757 let entry =
758 serde_json::from_str::<AvailableVersionsCacheEntry>(&content).map_err(|error| {
759 CoreError::Unsupported(format!("failed to parse available cache: {error}"))
760 })?;
761 let cached_at = UNIX_EPOCH + Duration::from_secs(entry.cached_at_epoch_secs);
762 let is_fresh = SystemTime::now()
763 .duration_since(cached_at)
764 .map(|age| age <= ttl)
765 .unwrap_or(false);
766 if is_fresh {
767 Ok(Some(entry.versions))
768 } else {
769 Ok(None)
770 }
771 }
772
773 fn write_available_versions_cache(
774 &self,
775 plugin_name: &str,
776 args: &[String],
777 versions: &[AvailableVersion],
778 ) -> Result<(), CoreError> {
779 if self.runtime_settings.available_hook_cache_ttl.is_none() {
780 return Ok(());
781 }
782
783 let path = self.available_versions_cache_path(plugin_name, args);
784 if let Some(parent) = path.parent() {
785 fs::create_dir_all(parent)?;
786 }
787 let cached_at_epoch_secs = SystemTime::now()
788 .duration_since(UNIX_EPOCH)
789 .unwrap_or_default()
790 .as_secs();
791 let rendered = serde_json::to_string_pretty(&AvailableVersionsCacheEntry {
792 cached_at_epoch_secs,
793 versions: versions.to_vec(),
794 })
795 .map_err(|error| {
796 CoreError::Unsupported(format!("failed to render available cache: {error}"))
797 })?;
798 fs::write(path, rendered)?;
799 Ok(())
800 }
801
802 pub(crate) fn copy_tree(&self, source: &Path, destination: &Path) -> Result<(), CoreError> {
803 if !source.exists() {
804 return Ok(());
805 }
806 for entry in walkdir::WalkDir::new(source) {
807 let entry = entry.map_err(|error| CoreError::Unsupported(error.to_string()))?;
808 let relative = entry
809 .path()
810 .strip_prefix(source)
811 .map_err(|error| CoreError::Unsupported(error.to_string()))?;
812 let target = destination.join(relative);
813 if entry.file_type().is_dir() {
814 fs::create_dir_all(&target)?;
815 } else {
816 if let Some(parent) = target.parent() {
817 fs::create_dir_all(parent)?;
818 }
819 fs::copy(entry.path(), &target)?;
820 }
821 }
822 Ok(())
823 }
824}
825
826fn configured_proxy_url(config: &AppConfig) -> Option<String> {
827 config
828 .proxy
829 .enable
830 .then(|| config.proxy.url.trim())
831 .filter(|value| !value.is_empty())
832 .map(ToString::to_string)
833}
834
835fn normalize_runtime_root(home: &Path, configured_path: &str) -> PathBuf {
836 let trimmed = configured_path.trim();
837 if trimmed.is_empty() {
838 return home.join("cache");
839 }
840
841 let path = PathBuf::from(trimmed);
842 if path.is_absolute() {
843 path
844 } else {
845 home.join(path)
846 }
847}
848
849fn normalize_legacy_strategy(strategy: &str) -> String {
850 match strategy {
851 "latest_installed" | "latest_available" => strategy.to_string(),
852 _ => String::from("specified"),
853 }
854}
855
856fn parse_duration_spec(value: &str) -> Option<Duration> {
857 let trimmed = value.trim();
858 if trimmed.is_empty() || trimmed == "0" {
859 return None;
860 }
861
862 let split_at = trimmed
863 .find(|ch: char| !ch.is_ascii_digit())
864 .unwrap_or(trimmed.len());
865 let (amount, unit) = trimmed.split_at(split_at);
866 let amount = amount.parse::<u64>().ok()?;
867 if amount == 0 {
868 return None;
869 }
870
871 let seconds = match unit {
872 "" | "s" => amount,
873 "m" => amount.saturating_mul(60),
874 "h" => amount.saturating_mul(60 * 60),
875 "d" => amount.saturating_mul(60 * 60 * 24),
876 _ => return None,
877 };
878 Some(Duration::from_secs(seconds))
879}
880
881fn legacy_candidate_file_names(plugin: Option<&dyn Plugin>) -> Vec<String> {
882 let mut candidates = supported_legacy_files()
883 .iter()
884 .map(|file_name| (*file_name).to_string())
885 .collect::<Vec<_>>();
886 if let Some(plugin) = plugin {
887 for file_name in &plugin.manifest().legacy_filenames {
888 if !candidates.iter().any(|existing| existing == file_name) {
889 candidates.push(file_name.clone());
890 }
891 }
892 }
893 candidates
894}
895
896fn parse_generic_legacy_tool_names(file_name: &str, content: &str) -> BTreeSet<String> {
897 match file_name {
898 ".tool-versions" => parse_tool_versions_content(content).into_keys().collect(),
899 ".nvmrc" | ".node-version" => {
900 let mut names = BTreeSet::new();
901 if !content.trim().is_empty() {
902 names.insert(String::from("nodejs"));
903 }
904 names
905 }
906 ".sdkmanrc" => parse_sdkmanrc_content(content).into_keys().collect(),
907 _ => BTreeSet::new(),
908 }
909}
910
911fn parse_tool_versions_content(content: &str) -> std::collections::BTreeMap<String, String> {
912 content
913 .lines()
914 .map(str::trim)
915 .filter(|line| !line.is_empty() && !line.starts_with('#'))
916 .filter_map(|line| {
917 let mut parts = line.split_whitespace();
918 Some((parts.next()?.to_string(), parts.next()?.to_string()))
919 })
920 .collect()
921}
922
923fn parse_sdkmanrc_content(content: &str) -> std::collections::BTreeMap<String, String> {
924 content
925 .lines()
926 .map(str::trim)
927 .filter(|line| !line.is_empty() && !line.starts_with('#'))
928 .filter_map(|line| {
929 let (plugin, version) = line.split_once('=')?;
930 Some((plugin.trim().to_string(), version.trim().to_string()))
931 })
932 .collect()
933}
934
935fn select_matching_version(selector: &str, candidates: &[String]) -> Option<String> {
936 if candidates.is_empty() {
937 return None;
938 }
939 let selector = selector.trim();
940 if selector.is_empty() {
941 return candidates.first().cloned();
942 }
943
944 if let Some(candidate) = candidates
945 .iter()
946 .find(|candidate| candidate.as_str() == selector)
947 {
948 return Some(candidate.clone());
949 }
950
951 let prefix = format!("{selector}.");
952 candidates
953 .iter()
954 .find(|candidate| candidate.starts_with(&prefix))
955 .cloned()
956}
957
958fn apply_env_keys(delta: &mut EnvDelta, env_keys: Vec<EnvKey>) {
959 for env_key in env_keys {
960 if env_key.key == "PATH" {
961 delta.path_entries.push(PathBuf::from(env_key.value));
962 } else {
963 delta.vars.push((env_key.key, env_key.value));
964 }
965 }
966}
967
968fn compute_env_state_hash(delta: &EnvDelta, path_value: &str) -> String {
969 use std::collections::hash_map::DefaultHasher;
970 use std::hash::{Hash, Hasher};
971
972 let mut hasher = DefaultHasher::new();
973 path_value.hash(&mut hasher);
974 delta.vars.hash(&mut hasher);
975 delta.path_entries.hash(&mut hasher);
976 format!("{:x}", hasher.finish())
977}
978
979fn render_shell_env_lines(
980 shell: ShellKind,
981 orig_path_needs_export: bool,
982 orig_path_value: &str,
983 prev_keys: &[String],
984 delta: &EnvDelta,
985 path_value: &str,
986 state_hash: &str,
987) -> Vec<String> {
988 let new_keys: Vec<&str> = delta.vars.iter().map(|(key, _)| key.as_str()).collect();
989 let stale_keys: Vec<&String> = prev_keys
990 .iter()
991 .filter(|key| !new_keys.contains(&key.as_str()))
992 .collect();
993 let new_keys_joined = new_keys.join(":");
994 let mut lines = Vec::new();
995
996 match shell {
997 ShellKind::Bash | ShellKind::Zsh => {
998 if orig_path_needs_export {
999 lines.push(format!(
1000 "export __VS_ORIG_PATH='{}'",
1001 orig_path_value.replace('\'', "'\"'\"'")
1002 ));
1003 }
1004 for key in &stale_keys {
1005 lines.push(format!("unset {key}"));
1006 }
1007 for (key, value) in &delta.vars {
1008 lines.push(format!("export {key}='{}'", value.replace('\'', "'\"'\"'")));
1009 }
1010 lines.push(format!(
1011 "export PATH='{}'",
1012 path_value.replace('\'', "'\"'\"'")
1013 ));
1014 lines.push(format!("export __VS_VARS='{new_keys_joined}'"));
1015 lines.push(format!("export __VS_STATE_HASH='{state_hash}'"));
1016 }
1017 ShellKind::Fish => {
1018 if orig_path_needs_export {
1019 lines.push(format!(
1020 "set -gx __VS_ORIG_PATH '{}'",
1021 orig_path_value.replace('\'', "\\'")
1022 ));
1023 }
1024 for key in &stale_keys {
1025 lines.push(format!("set -e {key}"));
1026 }
1027 for (key, value) in &delta.vars {
1028 lines.push(format!("set -gx {key} '{}'", value.replace('\'', "\\'")));
1029 }
1030 lines.push(format!(
1031 "set -gx PATH '{}'",
1032 path_value.replace('\'', "\\'")
1033 ));
1034 lines.push(format!("set -gx __VS_VARS '{new_keys_joined}'"));
1035 lines.push(format!("set -gx __VS_STATE_HASH '{state_hash}'"));
1036 }
1037 ShellKind::Nushell => {
1038 if orig_path_needs_export {
1039 lines.push(serde_json::json!({ "__VS_ORIG_PATH": orig_path_value }).to_string());
1040 }
1041 for key in &stale_keys {
1042 lines.push(serde_json::json!({ "__VS_UNSET": key }).to_string());
1043 }
1044 for (key, value) in &delta.vars {
1045 lines.push(serde_json::json!({ key: value }).to_string());
1046 }
1047 lines.push(serde_json::json!({ "PATH": path_value }).to_string());
1048 lines.push(serde_json::json!({ "__VS_VARS": new_keys_joined }).to_string());
1049 lines.push(serde_json::json!({ "__VS_STATE_HASH": state_hash }).to_string());
1050 }
1051 ShellKind::Pwsh => {
1052 if orig_path_needs_export {
1053 lines.push(format!(
1054 "$env:__VS_ORIG_PATH = '{}'",
1055 orig_path_value.replace('\'', "''")
1056 ));
1057 }
1058 for key in &stale_keys {
1059 lines.push(format!(
1060 "Remove-Item Env:\\{key} -ErrorAction SilentlyContinue"
1061 ));
1062 }
1063 for (key, value) in &delta.vars {
1064 lines.push(format!("$env:{key} = '{}'", value.replace('\'', "''")));
1065 }
1066 lines.push(format!("$env:PATH = '{}'", path_value.replace('\'', "''")));
1067 lines.push(format!("$env:__VS_VARS = '{new_keys_joined}'"));
1068 lines.push(format!("$env:__VS_STATE_HASH = '{state_hash}'"));
1069 }
1070 ShellKind::Clink => {
1071 if orig_path_needs_export {
1072 lines.push(format!("set __VS_ORIG_PATH={orig_path_value}"));
1073 }
1074 for key in &stale_keys {
1075 lines.push(format!("set {key}="));
1076 }
1077 for (key, value) in &delta.vars {
1078 lines.push(format!("set {key}={value}"));
1079 }
1080 lines.push(format!("set PATH={path_value}"));
1081 lines.push(format!("set __VS_VARS={new_keys_joined}"));
1082 lines.push(format!("set __VS_STATE_HASH={state_hash}"));
1083 }
1084 }
1085
1086 lines
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use vs_shell::{EnvDelta, ShellKind};
1092
1093 use super::{compute_env_state_hash, render_shell_env_lines};
1094
1095 #[test]
1096 fn env_state_hash_should_change_when_env_changes() {
1097 let first = compute_env_state_hash(
1098 &EnvDelta {
1099 vars: vec![(String::from("NODEJS_HOME"), String::from("/a"))],
1100 path_entries: Vec::new(),
1101 },
1102 "/a/bin:/usr/bin",
1103 );
1104 let second = compute_env_state_hash(
1105 &EnvDelta {
1106 vars: vec![(String::from("NODEJS_HOME"), String::from("/b"))],
1107 path_entries: Vec::new(),
1108 },
1109 "/b/bin:/usr/bin",
1110 );
1111
1112 assert_ne!(first, second);
1113 }
1114
1115 #[test]
1116 fn nushell_rendering_should_emit_unset_markers_for_stale_vars() {
1117 let lines = render_shell_env_lines(
1118 ShellKind::Nushell,
1119 false,
1120 "",
1121 &[String::from("OLD_HOME"), String::from("KEEP_HOME")],
1122 &EnvDelta {
1123 vars: vec![(String::from("KEEP_HOME"), String::from("/tool"))],
1124 path_entries: Vec::new(),
1125 },
1126 "/tool/bin:/usr/bin",
1127 "hash",
1128 );
1129
1130 assert!(
1131 lines
1132 .iter()
1133 .any(|line| line.contains("\"__VS_UNSET\":\"OLD_HOME\""))
1134 );
1135 assert!(!lines.iter().any(|line| line.contains("\"OLD_HOME\":\"\"")));
1136 }
1137}