1use 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#[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 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 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, ¤t.plugin);
323 if linked.exists() {
324 linked
325 } else {
326 install_dir(self.home(), ¤t.plugin, ¤t.version)
327 }
328 }
329 Scope::Global => {
330 let linked = global_current_dir(self.home(), ¤t.plugin);
331 if linked.exists() {
332 linked
333 } else {
334 install_dir(self.home(), ¤t.plugin, ¤t.version)
335 }
336 }
337 Scope::Session | Scope::System => {
338 install_dir(self.home(), ¤t.plugin, ¤t.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 ¤t_tools {
358 let runtime_dir = self.effective_runtime_dir(tool);
359 if let Some(runtime) = self.load_installed_runtime(&tool.plugin, &tool.version)? {
360 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 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 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 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}