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            let runtime_dir = self.effective_runtime_dir(tool);
631            if let Some(runtime) = self.load_installed_runtime(&tool.plugin, &tool.version)? {
632                // Relocate the runtime so that env-keys point through the
633                // scope-specific symlink (e.g. .vs/sdks/nodejs) instead of the
634                // raw cache directory.
635                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        // Use the original, clean PATH saved by the activation script so that
654        // previously-injected vs entries are not duplicated on each hook call.
655        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        // On the very first call __VS_ORIG_PATH is not yet set.  Capture the
668        // current (clean) PATH so we can freeze it as __VS_ORIG_PATH.
669        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        // Determine which env-var keys the previous hook-env call exported so
683        // that we can unset any that are no longer relevant (e.g. after leaving
684        // a project directory).
685        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}