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#[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 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 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, ¤t.plugin);
321 if linked.exists() {
322 linked
323 } else {
324 install_dir(self.home(), ¤t.plugin, ¤t.version)
325 }
326 }
327 Scope::Global => {
328 let linked = global_current_dir(self.home(), ¤t.plugin);
329 if linked.exists() {
330 linked
331 } else {
332 install_dir(self.home(), ¤t.plugin, ¤t.version)
333 }
334 }
335 Scope::Session | Scope::System => {
336 install_dir(self.home(), ¤t.plugin, ¤t.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 ¤t_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}