Skip to main content

vs_core/
app.rs

1//! The high-level application façade for coordinating core services.
2
3use 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/// Top-level application orchestrator.
33#[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    /// Creates an application from the process environment.
85    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    /// Creates an application with explicit paths.
93    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, &current.plugin);
595                if linked.exists() {
596                    linked
597                } else {
598                    install_dir(self.runtime_root(), &current.plugin, &current.version)
599                }
600            }
601            Scope::Global => {
602                let linked = global_current_dir(self.runtime_root(), &current.plugin);
603                if linked.exists() {
604                    linked
605                } else {
606                    install_dir(self.runtime_root(), &current.plugin, &current.version)
607                }
608            }
609            Scope::Session | Scope::System => {
610                install_dir(self.runtime_root(), &current.plugin, &current.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 &current_tools {
630            self.apply_tool_env(tool, &mut delta)?;
631        }
632
633        Ok(delta)
634    }
635
636    /// Adds the environment for a single resolved tool to `delta`.
637    ///
638    /// If the configured version is not installed, falls back to the globally
639    /// configured default (when it is installed) so the tool stays usable
640    /// instead of pointing PATH at a non-existent directory.
641    fn apply_tool_env(&self, tool: &CurrentTool, delta: &mut EnvDelta) -> Result<(), CoreError> {
642        // Prefer the configured version when it is actually installed.
643        if self.apply_installed_tool_env(tool, delta)? {
644            return Ok(());
645        }
646
647        // The configured version is not installed. Fall back to the global
648        // default so a project pinning an uninstalled version does not lose
649        // access to the tool entirely.
650        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        // No installed runtime found anywhere: keep the previous behaviour and
659        // surface the (missing) path so existing diagnostics still apply.
660        delta
661            .path_entries
662            .push(bin_dir(&self.effective_runtime_dir(tool)));
663        Ok(())
664    }
665
666    /// Applies the environment for `tool` when an installed runtime exists.
667    /// Returns `Ok(true)` when the tool was installed and applied.
668    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        // Relocate the runtime so that env-keys point through the
678        // scope-specific symlink (e.g. .vs/sdks/nodejs) instead of the
679        // raw cache directory.
680        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    /// Returns the globally configured tool (from `~/.vs/global/tools.toml`),
692    /// if any, as a `Global`-scoped [`CurrentTool`].
693    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        // Use the original, clean PATH saved by the activation script so that
710        // previously-injected vs entries are not duplicated on each hook call.
711        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        // On the very first call __VS_ORIG_PATH is not yet set.  Capture the
724        // current (clean) PATH so we can freeze it as __VS_ORIG_PATH.
725        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        // Determine which env-var keys the previous hook-env call exported so
739        // that we can unset any that are no longer relevant (e.g. after leaving
740        // a project directory).
741        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        // Project pins a version that is NOT installed; global default IS installed.
1204        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}