Skip to main content

vs_core/
app.rs

1use std::collections::BTreeSet;
2use std::env::{join_paths, split_paths};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use vs_config::{
7    AppConfig, HomeLayout, Scope, ToolVersions, find_legacy_file, find_project_file,
8    global_tools_file, preferred_project_file, read_app_config, read_legacy_versions,
9    read_tool_versions, resolve_home, resolve_tool_version, session_tools_file,
10    write_tool_versions,
11};
12use vs_installer::Installer;
13use vs_plugin_api::{EnvKey, Plugin, PluginBackendKind};
14#[cfg(feature = "lua")]
15use vs_plugin_lua::LuaBackend;
16#[cfg(feature = "wasi")]
17use vs_plugin_wasi::WasiBackend;
18use vs_registry::{RegistryEntry, RegistryService};
19use vs_shell::{
20    EnvDelta, HomePaths, ShellKind, bin_dir, global_current_dir, home_paths, install_dir,
21    project_sdk_dir,
22};
23
24use crate::error::CoreError;
25use crate::models::CurrentTool;
26#[cfg(feature = "lua")]
27use crate::registry_source::DEFAULT_VFOX_REGISTRY_SOURCE;
28
29/// Top-level application orchestrator.
30#[derive(Debug, Clone)]
31pub struct App {
32    pub(crate) home_layout: HomeLayout,
33    pub(crate) cwd: PathBuf,
34    pub(crate) session_id: Option<String>,
35    pub(crate) registry: RegistryService,
36    pub(crate) installer: Installer,
37    #[cfg(feature = "lua")]
38    pub(crate) lua_backend: LuaBackend,
39    #[cfg(feature = "wasi")]
40    pub(crate) wasi_backend: WasiBackend,
41}
42
43impl App {
44    /// Creates an application from the process environment.
45    pub fn from_env() -> Result<Self, CoreError> {
46        let home_layout = resolve_home()?;
47        let cwd = std::env::current_dir().map_err(vs_config::ConfigError::from)?;
48        let session_id = std::env::var("VS_SESSION_ID").ok();
49        Self::new(home_layout, cwd, session_id)
50    }
51
52    /// Creates an application with explicit paths.
53    pub fn new(
54        home_layout: HomeLayout,
55        cwd: PathBuf,
56        session_id: Option<String>,
57    ) -> Result<Self, CoreError> {
58        let registry = RegistryService::new(home_layout.active_home.clone());
59        let installer = Installer::new(home_layout.active_home.clone());
60        let app = Self {
61            home_layout,
62            cwd,
63            session_id,
64            registry,
65            installer,
66            #[cfg(feature = "lua")]
67            lua_backend: LuaBackend,
68            #[cfg(feature = "wasi")]
69            wasi_backend: WasiBackend,
70        };
71        app.ensure_home_layout()?;
72        Ok(app)
73    }
74
75    pub(crate) fn home(&self) -> &Path {
76        &self.home_layout.active_home
77    }
78
79    pub(crate) fn home_paths(&self) -> HomePaths {
80        home_paths(self.home())
81    }
82
83    pub(crate) fn app_config(&self) -> Result<AppConfig, CoreError> {
84        let mut config = read_app_config(self.home())?;
85        if config.registry.address.is_empty() {
86            if let Some(default_source) = self.default_registry_source() {
87                config.registry.address = default_source.to_string();
88            }
89        }
90        Ok(config)
91    }
92
93    pub(crate) fn ensure_home_layout(&self) -> Result<(), CoreError> {
94        let layout = self.home_paths();
95        fs::create_dir_all(&layout.home)?;
96        fs::create_dir_all(&layout.registry_dir)?;
97        fs::create_dir_all(&layout.plugins_dir)?;
98        fs::create_dir_all(layout.plugins_dir.join("sources"))?;
99        fs::create_dir_all(&layout.cache_dir)?;
100        fs::create_dir_all(layout.home.join("downloads"))?;
101        fs::create_dir_all(&layout.shims_dir)?;
102        fs::create_dir_all(&layout.sessions_dir)?;
103        fs::create_dir_all(&layout.global_dir)?;
104        Ok(())
105    }
106
107    pub(crate) fn normalize_source_path(&self, source: &str) -> PathBuf {
108        let path = PathBuf::from(source);
109        if path.is_absolute() {
110            path
111        } else {
112            self.cwd.join(path)
113        }
114    }
115
116    pub(crate) fn resolve_registry_entry(&self, name: &str) -> Result<RegistryEntry, CoreError> {
117        if let Some(entry) = self
118            .registry
119            .added_plugins()?
120            .into_iter()
121            .find(|entry| entry.matches(name))
122        {
123            return Ok(entry);
124        }
125
126        self.refresh_registry_index_with_fallback()?;
127        self.registry
128            .available_plugins()?
129            .into_iter()
130            .find(|entry| entry.matches(name))
131            .ok_or_else(|| CoreError::UnknownPlugin(name.to_string()))
132    }
133
134    pub(crate) fn refresh_registry_index_with_fallback(&self) -> Result<(), CoreError> {
135        let config = self.app_config()?;
136        if config.registry.address.is_empty() {
137            return Ok(());
138        }
139
140        match self.update_registry() {
141            Ok(_) => Ok(()),
142            Err(error) => {
143                if self.registry.available_plugins()?.is_empty() {
144                    Err(error)
145                } else {
146                    Ok(())
147                }
148            }
149        }
150    }
151
152    pub(crate) fn load_plugin(&self, entry: &RegistryEntry) -> Result<Box<dyn Plugin>, CoreError> {
153        let entry = self.materialize_plugin_entry(entry)?;
154        #[cfg(any(feature = "lua", feature = "wasi"))]
155        let source = self.normalize_source_path(&entry.source);
156        match entry.backend {
157            PluginBackendKind::Lua => {
158                #[cfg(feature = "lua")]
159                {
160                    self.lua_backend.load(&source).map_err(Into::into)
161                }
162                #[cfg(not(feature = "lua"))]
163                {
164                    Err(CoreError::UnsupportedBackend {
165                        backend: "lua",
166                        feature: "lua",
167                    })
168                }
169            }
170            PluginBackendKind::Wasi => {
171                #[cfg(feature = "wasi")]
172                {
173                    self.wasi_backend.load(&source).map_err(Into::into)
174                }
175                #[cfg(not(feature = "wasi"))]
176                {
177                    Err(CoreError::UnsupportedBackend {
178                        backend: "wasi",
179                        feature: "wasi",
180                    })
181                }
182            }
183        }
184    }
185
186    pub(crate) fn ensure_backend_supported(
187        &self,
188        backend: PluginBackendKind,
189    ) -> Result<(), CoreError> {
190        match backend {
191            PluginBackendKind::Lua => {
192                #[cfg(feature = "lua")]
193                {
194                    Ok(())
195                }
196                #[cfg(not(feature = "lua"))]
197                {
198                    Err(CoreError::UnsupportedBackend {
199                        backend: "lua",
200                        feature: "lua",
201                    })
202                }
203            }
204            PluginBackendKind::Wasi => {
205                #[cfg(feature = "wasi")]
206                {
207                    Ok(())
208                }
209                #[cfg(not(feature = "wasi"))]
210                {
211                    Err(CoreError::UnsupportedBackend {
212                        backend: "wasi",
213                        feature: "wasi",
214                    })
215                }
216            }
217        }
218    }
219
220    pub(crate) fn default_backend(&self) -> Result<PluginBackendKind, CoreError> {
221        #[cfg(all(feature = "lua", feature = "wasi"))]
222        {
223            Ok(PluginBackendKind::Lua)
224        }
225        #[cfg(all(feature = "lua", not(feature = "wasi")))]
226        {
227            Ok(PluginBackendKind::Lua)
228        }
229        #[cfg(all(feature = "wasi", not(feature = "lua")))]
230        {
231            Ok(PluginBackendKind::Wasi)
232        }
233        #[cfg(not(any(feature = "lua", feature = "wasi")))]
234        {
235            Err(CoreError::Unsupported(String::from(
236                "no plugin backend is enabled in this build",
237            )))
238        }
239    }
240
241    pub(crate) fn default_registry_source(&self) -> Option<&'static str> {
242        #[cfg(feature = "lua")]
243        {
244            Some(DEFAULT_VFOX_REGISTRY_SOURCE)
245        }
246        #[cfg(not(feature = "lua"))]
247        {
248            None
249        }
250    }
251
252    pub(crate) fn write_tool_assignment(
253        &self,
254        path: &Path,
255        plugin: &str,
256        version: Option<&str>,
257    ) -> Result<(), CoreError> {
258        let mut tools = if path.exists() {
259            read_tool_versions(path)?
260        } else {
261            ToolVersions::default()
262        };
263        match version {
264            Some(version) => {
265                tools.tools.insert(plugin.to_string(), version.to_string());
266            }
267            None => {
268                tools.tools.remove(plugin);
269            }
270        }
271        write_tool_versions(path, &tools)?;
272        Ok(())
273    }
274
275    pub(crate) fn collect_current_tools(&self) -> Result<Vec<CurrentTool>, CoreError> {
276        let mut names = BTreeSet::new();
277        if let Some(path) = find_project_file(&self.cwd) {
278            names.extend(read_tool_versions(&path)?.tools.into_keys());
279        }
280        if let Some(path) = find_legacy_file(&self.cwd) {
281            names.extend(read_legacy_versions(&path)?.tools.into_keys());
282        }
283        let session_path = self
284            .session_id
285            .as_deref()
286            .map(|session_id| session_tools_file(self.home(), session_id));
287        if let Some(path) = session_path.as_deref() {
288            if path.exists() {
289                names.extend(read_tool_versions(path)?.tools.into_keys());
290            }
291        }
292        let global_path = global_tools_file(self.home());
293        if global_path.exists() {
294            names.extend(read_tool_versions(&global_path)?.tools.into_keys());
295        }
296
297        let mut tools = names
298            .into_iter()
299            .filter_map(|plugin| {
300                resolve_tool_version(self.home(), &self.cwd, self.session_id.as_deref(), &plugin)
301                    .transpose()
302                    .map(|resolved| {
303                        resolved.map(|resolved| CurrentTool {
304                            plugin: resolved.plugin,
305                            version: resolved.version,
306                            scope: resolved.scope,
307                            source: resolved.source,
308                        })
309                    })
310            })
311            .collect::<Result<Vec<_>, _>>()?;
312
313        tools.sort_by(|left, right| left.plugin.cmp(&right.plugin));
314        Ok(tools)
315    }
316
317    pub(crate) fn effective_runtime_dir(&self, current: &CurrentTool) -> PathBuf {
318        match current.scope {
319            Scope::Project => {
320                let linked = project_sdk_dir(&self.cwd, &current.plugin);
321                if linked.exists() {
322                    linked
323                } else {
324                    install_dir(self.home(), &current.plugin, &current.version)
325                }
326            }
327            Scope::Global => {
328                let linked = global_current_dir(self.home(), &current.plugin);
329                if linked.exists() {
330                    linked
331                } else {
332                    install_dir(self.home(), &current.plugin, &current.version)
333                }
334            }
335            Scope::Session | Scope::System => {
336                install_dir(self.home(), &current.plugin, &current.version)
337            }
338        }
339    }
340
341    pub(crate) fn load_installed_runtime(
342        &self,
343        plugin: &str,
344        version: &str,
345    ) -> Result<Option<vs_plugin_api::InstalledRuntime>, CoreError> {
346        self.installer
347            .read_receipt(plugin, version)
348            .map_err(Into::into)
349    }
350
351    pub(crate) fn build_env(&self) -> Result<EnvDelta, CoreError> {
352        let current_tools = self.collect_current_tools()?;
353        let mut delta = EnvDelta::default();
354
355        for tool in &current_tools {
356            let runtime_dir = self.effective_runtime_dir(tool);
357            if let Some(runtime) = self.load_installed_runtime(&tool.plugin, &tool.version)? {
358                if let Ok(entry) = self.resolve_registry_entry(&tool.plugin) {
359                    let plugin = self.load_plugin(&entry)?;
360                    let env_keys = plugin.env_keys(&runtime)?;
361                    apply_env_keys(&mut delta, env_keys);
362                } else {
363                    delta.path_entries.push(bin_dir(runtime.main_path()));
364                }
365            } else {
366                delta.path_entries.push(bin_dir(&runtime_dir));
367            }
368        }
369
370        Ok(delta)
371    }
372
373    pub(crate) fn path_with_delta(&self, delta: &EnvDelta) -> Result<String, CoreError> {
374        let mut entries = delta.path_entries.clone();
375        let existing_entries = std::env::var_os("PATH")
376            .map(|paths| split_paths(&paths).collect::<Vec<_>>())
377            .unwrap_or_default();
378        entries.extend(existing_entries);
379        let joined = join_paths(entries).map_err(|error| {
380            CoreError::Unsupported(format!("failed to join PATH entries: {error}"))
381        })?;
382        Ok(joined.to_string_lossy().into_owned())
383    }
384
385    pub(crate) fn render_hook_env(&self, shell: ShellKind) -> Result<String, CoreError> {
386        let delta = self.build_env()?;
387        let path_value = self.path_with_delta(&delta)?;
388        let mut lines = Vec::new();
389
390        match shell {
391            ShellKind::Bash | ShellKind::Zsh => {
392                for (key, value) in &delta.vars {
393                    lines.push(format!("export {key}='{}'", value.replace('\'', "'\"'\"'")));
394                }
395                lines.push(format!(
396                    "export PATH='{}'",
397                    path_value.replace('\'', "'\"'\"'")
398                ));
399            }
400            ShellKind::Fish => {
401                for (key, value) in &delta.vars {
402                    lines.push(format!("set -gx {key} '{}'", value.replace('\'', "\\'")));
403                }
404                lines.push(format!(
405                    "set -gx PATH '{}'",
406                    path_value.replace('\'', "\\'")
407                ));
408            }
409            ShellKind::Nushell => {
410                for (key, value) in &delta.vars {
411                    let payload = serde_json::json!({ key: value });
412                    lines.push(payload.to_string());
413                }
414                lines.push(serde_json::json!({ "PATH": path_value }).to_string());
415            }
416            ShellKind::Pwsh => {
417                for (key, value) in &delta.vars {
418                    lines.push(format!("$env:{key} = '{}'", value.replace('\'', "''")));
419                }
420                lines.push(format!("$env:PATH = '{}'", path_value.replace('\'', "''")));
421            }
422            ShellKind::Clink => {
423                for (key, value) in &delta.vars {
424                    lines.push(format!("set {key}={value}"));
425                }
426                lines.push(format!("set PATH={path_value}"));
427            }
428        }
429
430        Ok(lines.join("\n"))
431    }
432
433    pub(crate) fn preferred_project_file(&self) -> PathBuf {
434        preferred_project_file(&self.cwd)
435    }
436
437    pub(crate) fn session_file(&self) -> Result<PathBuf, CoreError> {
438        let session_id = self
439            .session_id
440            .as_deref()
441            .ok_or(CoreError::MissingSessionId)?;
442        Ok(session_tools_file(self.home(), session_id))
443    }
444
445    pub(crate) fn copy_tree(&self, source: &Path, destination: &Path) -> Result<(), CoreError> {
446        if !source.exists() {
447            return Ok(());
448        }
449        for entry in walkdir::WalkDir::new(source) {
450            let entry = entry.map_err(|error| CoreError::Unsupported(error.to_string()))?;
451            let relative = entry
452                .path()
453                .strip_prefix(source)
454                .map_err(|error| CoreError::Unsupported(error.to_string()))?;
455            let target = destination.join(relative);
456            if entry.file_type().is_dir() {
457                fs::create_dir_all(&target)?;
458            } else {
459                if let Some(parent) = target.parent() {
460                    fs::create_dir_all(parent)?;
461                }
462                fs::copy(entry.path(), &target)?;
463            }
464        }
465        Ok(())
466    }
467}
468
469fn apply_env_keys(delta: &mut EnvDelta, env_keys: Vec<EnvKey>) {
470    for env_key in env_keys {
471        if env_key.key == "PATH" {
472            delta.path_entries.push(PathBuf::from(env_key.value));
473        } else {
474            delta.vars.push((env_key.key, env_key.value));
475        }
476    }
477}