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::path::{Path, PathBuf};
7
8use vs_config::{
9    AppConfig, HomeLayout, Scope, ToolVersions, find_legacy_file, find_project_file,
10    global_tools_file, preferred_project_file, read_app_config, read_legacy_versions,
11    read_tool_versions, resolve_home, resolve_tool_version, session_tools_file,
12    write_tool_versions,
13};
14use vs_installer::Installer;
15use vs_plugin_api::{EnvKey, Plugin, PluginBackendKind};
16#[cfg(feature = "lua")]
17use vs_plugin_lua::LuaBackend;
18#[cfg(feature = "wasi")]
19use vs_plugin_wasi::WasiBackend;
20use vs_registry::{RegistryEntry, RegistryService};
21use vs_shell::{
22    EnvDelta, HomePaths, ShellKind, bin_dir, global_current_dir, home_paths, install_dir,
23    project_sdk_dir,
24};
25
26use crate::error::CoreError;
27use crate::models::CurrentTool;
28#[cfg(feature = "lua")]
29use crate::registry_source::DEFAULT_VFOX_REGISTRY_SOURCE;
30
31/// Top-level application orchestrator.
32#[derive(Debug, Clone)]
33pub struct App {
34    pub(crate) home_layout: HomeLayout,
35    pub(crate) cwd: PathBuf,
36    pub(crate) session_id: Option<String>,
37    pub(crate) registry: RegistryService,
38    pub(crate) installer: Installer,
39    #[cfg(feature = "lua")]
40    pub(crate) lua_backend: LuaBackend,
41    #[cfg(feature = "wasi")]
42    pub(crate) wasi_backend: WasiBackend,
43}
44
45impl App {
46    /// Creates an application from the process environment.
47    pub fn from_env() -> Result<Self, CoreError> {
48        let home_layout = resolve_home()?;
49        let cwd = std::env::current_dir().map_err(vs_config::ConfigError::from)?;
50        let session_id = std::env::var("VS_SESSION_ID").ok();
51        Self::new(home_layout, cwd, session_id)
52    }
53
54    /// Creates an application with explicit paths.
55    pub fn new(
56        home_layout: HomeLayout,
57        cwd: PathBuf,
58        session_id: Option<String>,
59    ) -> Result<Self, CoreError> {
60        let registry = RegistryService::new(home_layout.active_home.clone());
61        let installer = Installer::new(home_layout.active_home.clone());
62        let app = Self {
63            home_layout,
64            cwd,
65            session_id,
66            registry,
67            installer,
68            #[cfg(feature = "lua")]
69            lua_backend: LuaBackend,
70            #[cfg(feature = "wasi")]
71            wasi_backend: WasiBackend,
72        };
73        app.ensure_home_layout()?;
74        Ok(app)
75    }
76
77    pub(crate) fn home(&self) -> &Path {
78        &self.home_layout.active_home
79    }
80
81    pub(crate) fn home_paths(&self) -> HomePaths {
82        home_paths(self.home())
83    }
84
85    pub(crate) fn app_config(&self) -> Result<AppConfig, CoreError> {
86        let mut config = read_app_config(self.home())?;
87        if config.registry.address.is_empty() {
88            if let Some(default_source) = self.default_registry_source() {
89                config.registry.address = default_source.to_string();
90            }
91        }
92        Ok(config)
93    }
94
95    pub(crate) fn ensure_home_layout(&self) -> Result<(), CoreError> {
96        let layout = self.home_paths();
97        fs::create_dir_all(&layout.home)?;
98        fs::create_dir_all(&layout.registry_dir)?;
99        fs::create_dir_all(&layout.plugins_dir)?;
100        fs::create_dir_all(layout.plugins_dir.join("sources"))?;
101        fs::create_dir_all(&layout.cache_dir)?;
102        fs::create_dir_all(layout.home.join("downloads"))?;
103        fs::create_dir_all(&layout.shims_dir)?;
104        fs::create_dir_all(&layout.sessions_dir)?;
105        fs::create_dir_all(&layout.global_dir)?;
106        Ok(())
107    }
108
109    pub(crate) fn normalize_source_path(&self, source: &str) -> PathBuf {
110        let path = PathBuf::from(source);
111        if path.is_absolute() {
112            path
113        } else {
114            self.cwd.join(path)
115        }
116    }
117
118    pub(crate) fn resolve_registry_entry(&self, name: &str) -> Result<RegistryEntry, CoreError> {
119        if let Some(entry) = self
120            .registry
121            .added_plugins()?
122            .into_iter()
123            .find(|entry| entry.matches(name))
124        {
125            return Ok(entry);
126        }
127
128        self.refresh_registry_index_with_fallback()?;
129        self.registry
130            .available_plugins()?
131            .into_iter()
132            .find(|entry| entry.matches(name))
133            .ok_or_else(|| CoreError::UnknownPlugin(name.to_string()))
134    }
135
136    pub(crate) fn refresh_registry_index_with_fallback(&self) -> Result<(), CoreError> {
137        let config = self.app_config()?;
138        if config.registry.address.is_empty() {
139            return Ok(());
140        }
141
142        match self.update_registry() {
143            Ok(_) => Ok(()),
144            Err(error) => {
145                if self.registry.available_plugins()?.is_empty() {
146                    Err(error)
147                } else {
148                    Ok(())
149                }
150            }
151        }
152    }
153
154    pub(crate) fn load_plugin(&self, entry: &RegistryEntry) -> Result<Box<dyn Plugin>, CoreError> {
155        let entry = self.materialize_plugin_entry(entry)?;
156        #[cfg(any(feature = "lua", feature = "wasi"))]
157        let source = self.normalize_source_path(&entry.source);
158        match entry.backend {
159            PluginBackendKind::Lua => {
160                #[cfg(feature = "lua")]
161                {
162                    self.lua_backend.load(&source).map_err(Into::into)
163                }
164                #[cfg(not(feature = "lua"))]
165                {
166                    Err(CoreError::UnsupportedBackend {
167                        backend: "lua",
168                        feature: "lua",
169                    })
170                }
171            }
172            PluginBackendKind::Wasi => {
173                #[cfg(feature = "wasi")]
174                {
175                    self.wasi_backend.load(&source).map_err(Into::into)
176                }
177                #[cfg(not(feature = "wasi"))]
178                {
179                    Err(CoreError::UnsupportedBackend {
180                        backend: "wasi",
181                        feature: "wasi",
182                    })
183                }
184            }
185        }
186    }
187
188    pub(crate) fn ensure_backend_supported(
189        &self,
190        backend: PluginBackendKind,
191    ) -> Result<(), CoreError> {
192        match backend {
193            PluginBackendKind::Lua => {
194                #[cfg(feature = "lua")]
195                {
196                    Ok(())
197                }
198                #[cfg(not(feature = "lua"))]
199                {
200                    Err(CoreError::UnsupportedBackend {
201                        backend: "lua",
202                        feature: "lua",
203                    })
204                }
205            }
206            PluginBackendKind::Wasi => {
207                #[cfg(feature = "wasi")]
208                {
209                    Ok(())
210                }
211                #[cfg(not(feature = "wasi"))]
212                {
213                    Err(CoreError::UnsupportedBackend {
214                        backend: "wasi",
215                        feature: "wasi",
216                    })
217                }
218            }
219        }
220    }
221
222    pub(crate) fn default_backend(&self) -> Result<PluginBackendKind, CoreError> {
223        #[cfg(all(feature = "lua", feature = "wasi"))]
224        {
225            Ok(PluginBackendKind::Lua)
226        }
227        #[cfg(all(feature = "lua", not(feature = "wasi")))]
228        {
229            Ok(PluginBackendKind::Lua)
230        }
231        #[cfg(all(feature = "wasi", not(feature = "lua")))]
232        {
233            Ok(PluginBackendKind::Wasi)
234        }
235        #[cfg(not(any(feature = "lua", feature = "wasi")))]
236        {
237            Err(CoreError::Unsupported(String::from(
238                "no plugin backend is enabled in this build",
239            )))
240        }
241    }
242
243    pub(crate) fn default_registry_source(&self) -> Option<&'static str> {
244        #[cfg(feature = "lua")]
245        {
246            Some(DEFAULT_VFOX_REGISTRY_SOURCE)
247        }
248        #[cfg(not(feature = "lua"))]
249        {
250            None
251        }
252    }
253
254    pub(crate) fn write_tool_assignment(
255        &self,
256        path: &Path,
257        plugin: &str,
258        version: Option<&str>,
259    ) -> Result<(), CoreError> {
260        let mut tools = if path.exists() {
261            read_tool_versions(path)?
262        } else {
263            ToolVersions::default()
264        };
265        match version {
266            Some(version) => {
267                tools.tools.insert(plugin.to_string(), version.to_string());
268            }
269            None => {
270                tools.tools.remove(plugin);
271            }
272        }
273        write_tool_versions(path, &tools)?;
274        Ok(())
275    }
276
277    pub(crate) fn collect_current_tools(&self) -> Result<Vec<CurrentTool>, CoreError> {
278        let mut names = BTreeSet::new();
279        if let Some(path) = find_project_file(&self.cwd) {
280            names.extend(read_tool_versions(&path)?.tools.into_keys());
281        }
282        if let Some(path) = find_legacy_file(&self.cwd) {
283            names.extend(read_legacy_versions(&path)?.tools.into_keys());
284        }
285        let session_path = self
286            .session_id
287            .as_deref()
288            .map(|session_id| session_tools_file(self.home(), session_id));
289        if let Some(path) = session_path.as_deref() {
290            if path.exists() {
291                names.extend(read_tool_versions(path)?.tools.into_keys());
292            }
293        }
294        let global_path = global_tools_file(self.home());
295        if global_path.exists() {
296            names.extend(read_tool_versions(&global_path)?.tools.into_keys());
297        }
298
299        let mut tools = names
300            .into_iter()
301            .filter_map(|plugin| {
302                resolve_tool_version(self.home(), &self.cwd, self.session_id.as_deref(), &plugin)
303                    .transpose()
304                    .map(|resolved| {
305                        resolved.map(|resolved| CurrentTool {
306                            plugin: resolved.plugin,
307                            version: resolved.version,
308                            scope: resolved.scope,
309                            source: resolved.source,
310                        })
311                    })
312            })
313            .collect::<Result<Vec<_>, _>>()?;
314
315        tools.sort_by(|left, right| left.plugin.cmp(&right.plugin));
316        Ok(tools)
317    }
318
319    pub(crate) fn effective_runtime_dir(&self, current: &CurrentTool) -> PathBuf {
320        match current.scope {
321            Scope::Project => {
322                let linked = project_sdk_dir(&self.cwd, &current.plugin);
323                if linked.exists() {
324                    linked
325                } else {
326                    install_dir(self.home(), &current.plugin, &current.version)
327                }
328            }
329            Scope::Global => {
330                let linked = global_current_dir(self.home(), &current.plugin);
331                if linked.exists() {
332                    linked
333                } else {
334                    install_dir(self.home(), &current.plugin, &current.version)
335                }
336            }
337            Scope::Session | Scope::System => {
338                install_dir(self.home(), &current.plugin, &current.version)
339            }
340        }
341    }
342
343    pub(crate) fn load_installed_runtime(
344        &self,
345        plugin: &str,
346        version: &str,
347    ) -> Result<Option<vs_plugin_api::InstalledRuntime>, CoreError> {
348        self.installer
349            .read_receipt(plugin, version)
350            .map_err(Into::into)
351    }
352
353    pub(crate) fn build_env(&self) -> Result<EnvDelta, CoreError> {
354        let current_tools = self.collect_current_tools()?;
355        let mut delta = EnvDelta::default();
356
357        for tool in &current_tools {
358            let runtime_dir = self.effective_runtime_dir(tool);
359            if let Some(runtime) = self.load_installed_runtime(&tool.plugin, &tool.version)? {
360                // Relocate the runtime so that env-keys point through the
361                // scope-specific symlink (e.g. .vs/sdks/nodejs) instead of the
362                // raw cache directory.
363                let runtime = runtime.relocate(&runtime_dir);
364                if let Ok(entry) = self.resolve_registry_entry(&tool.plugin) {
365                    let plugin = self.load_plugin(&entry)?;
366                    let env_keys = plugin.env_keys(&runtime)?;
367                    apply_env_keys(&mut delta, env_keys);
368                } else {
369                    delta.path_entries.push(bin_dir(runtime.main_path()));
370                }
371            } else {
372                delta.path_entries.push(bin_dir(&runtime_dir));
373            }
374        }
375
376        Ok(delta)
377    }
378
379    pub(crate) fn path_with_delta(&self, delta: &EnvDelta) -> Result<String, CoreError> {
380        let mut entries = delta.path_entries.clone();
381        // Use the original, clean PATH saved by the activation script so that
382        // previously-injected vs entries are not duplicated on each hook call.
383        let base_path = std::env::var_os("__VS_ORIG_PATH").or_else(|| std::env::var_os("PATH"));
384        let existing_entries = base_path
385            .map(|paths| split_paths(&paths).collect::<Vec<_>>())
386            .unwrap_or_default();
387        entries.extend(existing_entries);
388        let joined = join_paths(entries).map_err(|error| {
389            CoreError::Unsupported(format!("failed to join PATH entries: {error}"))
390        })?;
391        Ok(joined.to_string_lossy().into_owned())
392    }
393
394    pub(crate) fn render_hook_env(&self, shell: ShellKind) -> Result<String, CoreError> {
395        // On the very first call __VS_ORIG_PATH is not yet set.  Capture the
396        // current (clean) PATH so we can freeze it as __VS_ORIG_PATH.
397        let orig_path_needs_export = std::env::var_os("__VS_ORIG_PATH").is_none();
398        let orig_path_value = std::env::var("__VS_ORIG_PATH")
399            .or_else(|_| std::env::var("PATH"))
400            .unwrap_or_default();
401
402        let delta = self.build_env()?;
403        let path_value = self.path_with_delta(&delta)?;
404        let state_hash = compute_env_state_hash(&delta, &path_value);
405        let prev_hash = std::env::var("__VS_STATE_HASH").unwrap_or_default();
406        if !prev_hash.is_empty() && state_hash == prev_hash {
407            return Ok(String::new());
408        }
409
410        // Determine which env-var keys the previous hook-env call exported so
411        // that we can unset any that are no longer relevant (e.g. after leaving
412        // a project directory).
413        let prev_keys: Vec<String> = std::env::var("__VS_VARS")
414            .unwrap_or_default()
415            .split(':')
416            .filter(|s| !s.is_empty())
417            .map(String::from)
418            .collect();
419        Ok(render_shell_env_lines(
420            shell,
421            orig_path_needs_export,
422            &orig_path_value,
423            &prev_keys,
424            &delta,
425            &path_value,
426            &state_hash,
427        )
428        .join("\n"))
429    }
430
431    pub(crate) fn preferred_project_file(&self) -> PathBuf {
432        preferred_project_file(&self.cwd)
433    }
434
435    pub(crate) fn session_file(&self) -> Result<PathBuf, CoreError> {
436        let session_id = self
437            .session_id
438            .as_deref()
439            .ok_or(CoreError::MissingSessionId)?;
440        Ok(session_tools_file(self.home(), session_id))
441    }
442
443    pub(crate) fn copy_tree(&self, source: &Path, destination: &Path) -> Result<(), CoreError> {
444        if !source.exists() {
445            return Ok(());
446        }
447        for entry in walkdir::WalkDir::new(source) {
448            let entry = entry.map_err(|error| CoreError::Unsupported(error.to_string()))?;
449            let relative = entry
450                .path()
451                .strip_prefix(source)
452                .map_err(|error| CoreError::Unsupported(error.to_string()))?;
453            let target = destination.join(relative);
454            if entry.file_type().is_dir() {
455                fs::create_dir_all(&target)?;
456            } else {
457                if let Some(parent) = target.parent() {
458                    fs::create_dir_all(parent)?;
459                }
460                fs::copy(entry.path(), &target)?;
461            }
462        }
463        Ok(())
464    }
465}
466
467fn apply_env_keys(delta: &mut EnvDelta, env_keys: Vec<EnvKey>) {
468    for env_key in env_keys {
469        if env_key.key == "PATH" {
470            delta.path_entries.push(PathBuf::from(env_key.value));
471        } else {
472            delta.vars.push((env_key.key, env_key.value));
473        }
474    }
475}
476
477fn compute_env_state_hash(delta: &EnvDelta, path_value: &str) -> String {
478    use std::collections::hash_map::DefaultHasher;
479    use std::hash::{Hash, Hasher};
480
481    let mut hasher = DefaultHasher::new();
482    path_value.hash(&mut hasher);
483    delta.vars.hash(&mut hasher);
484    delta.path_entries.hash(&mut hasher);
485    format!("{:x}", hasher.finish())
486}
487
488fn render_shell_env_lines(
489    shell: ShellKind,
490    orig_path_needs_export: bool,
491    orig_path_value: &str,
492    prev_keys: &[String],
493    delta: &EnvDelta,
494    path_value: &str,
495    state_hash: &str,
496) -> Vec<String> {
497    let new_keys: Vec<&str> = delta.vars.iter().map(|(key, _)| key.as_str()).collect();
498    let stale_keys: Vec<&String> = prev_keys
499        .iter()
500        .filter(|key| !new_keys.contains(&key.as_str()))
501        .collect();
502    let new_keys_joined = new_keys.join(":");
503    let mut lines = Vec::new();
504
505    match shell {
506        ShellKind::Bash | ShellKind::Zsh => {
507            if orig_path_needs_export {
508                lines.push(format!(
509                    "export __VS_ORIG_PATH='{}'",
510                    orig_path_value.replace('\'', "'\"'\"'")
511                ));
512            }
513            for key in &stale_keys {
514                lines.push(format!("unset {key}"));
515            }
516            for (key, value) in &delta.vars {
517                lines.push(format!("export {key}='{}'", value.replace('\'', "'\"'\"'")));
518            }
519            lines.push(format!(
520                "export PATH='{}'",
521                path_value.replace('\'', "'\"'\"'")
522            ));
523            lines.push(format!("export __VS_VARS='{new_keys_joined}'"));
524            lines.push(format!("export __VS_STATE_HASH='{state_hash}'"));
525        }
526        ShellKind::Fish => {
527            if orig_path_needs_export {
528                lines.push(format!(
529                    "set -gx __VS_ORIG_PATH '{}'",
530                    orig_path_value.replace('\'', "\\'")
531                ));
532            }
533            for key in &stale_keys {
534                lines.push(format!("set -e {key}"));
535            }
536            for (key, value) in &delta.vars {
537                lines.push(format!("set -gx {key} '{}'", value.replace('\'', "\\'")));
538            }
539            lines.push(format!(
540                "set -gx PATH '{}'",
541                path_value.replace('\'', "\\'")
542            ));
543            lines.push(format!("set -gx __VS_VARS '{new_keys_joined}'"));
544            lines.push(format!("set -gx __VS_STATE_HASH '{state_hash}'"));
545        }
546        ShellKind::Nushell => {
547            if orig_path_needs_export {
548                lines.push(serde_json::json!({ "__VS_ORIG_PATH": orig_path_value }).to_string());
549            }
550            for key in &stale_keys {
551                lines.push(serde_json::json!({ "__VS_UNSET": key }).to_string());
552            }
553            for (key, value) in &delta.vars {
554                lines.push(serde_json::json!({ key: value }).to_string());
555            }
556            lines.push(serde_json::json!({ "PATH": path_value }).to_string());
557            lines.push(serde_json::json!({ "__VS_VARS": new_keys_joined }).to_string());
558            lines.push(serde_json::json!({ "__VS_STATE_HASH": state_hash }).to_string());
559        }
560        ShellKind::Pwsh => {
561            if orig_path_needs_export {
562                lines.push(format!(
563                    "$env:__VS_ORIG_PATH = '{}'",
564                    orig_path_value.replace('\'', "''")
565                ));
566            }
567            for key in &stale_keys {
568                lines.push(format!(
569                    "Remove-Item Env:\\{key} -ErrorAction SilentlyContinue"
570                ));
571            }
572            for (key, value) in &delta.vars {
573                lines.push(format!("$env:{key} = '{}'", value.replace('\'', "''")));
574            }
575            lines.push(format!("$env:PATH = '{}'", path_value.replace('\'', "''")));
576            lines.push(format!("$env:__VS_VARS = '{new_keys_joined}'"));
577            lines.push(format!("$env:__VS_STATE_HASH = '{state_hash}'"));
578        }
579        ShellKind::Clink => {
580            if orig_path_needs_export {
581                lines.push(format!("set __VS_ORIG_PATH={orig_path_value}"));
582            }
583            for key in &stale_keys {
584                lines.push(format!("set {key}="));
585            }
586            for (key, value) in &delta.vars {
587                lines.push(format!("set {key}={value}"));
588            }
589            lines.push(format!("set PATH={path_value}"));
590            lines.push(format!("set __VS_VARS={new_keys_joined}"));
591            lines.push(format!("set __VS_STATE_HASH={state_hash}"));
592        }
593    }
594
595    lines
596}
597
598#[cfg(test)]
599mod tests {
600    use vs_shell::{EnvDelta, ShellKind};
601
602    use super::{compute_env_state_hash, render_shell_env_lines};
603
604    #[test]
605    fn env_state_hash_should_change_when_env_changes() {
606        let first = compute_env_state_hash(
607            &EnvDelta {
608                vars: vec![(String::from("NODEJS_HOME"), String::from("/a"))],
609                path_entries: Vec::new(),
610            },
611            "/a/bin:/usr/bin",
612        );
613        let second = compute_env_state_hash(
614            &EnvDelta {
615                vars: vec![(String::from("NODEJS_HOME"), String::from("/b"))],
616                path_entries: Vec::new(),
617            },
618            "/b/bin:/usr/bin",
619        );
620
621        assert_ne!(first, second);
622    }
623
624    #[test]
625    fn nushell_rendering_should_emit_unset_markers_for_stale_vars() {
626        let lines = render_shell_env_lines(
627            ShellKind::Nushell,
628            false,
629            "",
630            &[String::from("OLD_HOME"), String::from("KEEP_HOME")],
631            &EnvDelta {
632                vars: vec![(String::from("KEEP_HOME"), String::from("/tool"))],
633                path_entries: Vec::new(),
634            },
635            "/tool/bin:/usr/bin",
636            "hash",
637        );
638
639        assert!(
640            lines
641                .iter()
642                .any(|line| line.contains("\"__VS_UNSET\":\"OLD_HOME\""))
643        );
644        assert!(!lines.iter().any(|line| line.contains("\"OLD_HOME\":\"\"")));
645    }
646}