vx_core/
environment.rs

1//! Environment management for VX tool manager
2//!
3//! This module handles:
4//! - Tool installation directories
5//! - Version management
6//! - Environment isolation
7//! - PATH management
8
9use crate::{Result, VxError};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14/// VX environment manager
15#[derive(Debug, Clone)]
16pub struct VxEnvironment {
17    /// Base directory for all VX installations
18    base_dir: PathBuf,
19    /// Configuration directory
20    config_dir: PathBuf,
21    /// Cache directory
22    cache_dir: PathBuf,
23}
24
25/// Tool installation information
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ToolInstallation {
28    /// Tool name
29    pub tool_name: String,
30    /// Installed version
31    pub version: String,
32    /// Installation directory
33    pub install_dir: PathBuf,
34    /// Executable path
35    pub executable_path: PathBuf,
36    /// Installation timestamp
37    pub installed_at: chrono::DateTime<chrono::Utc>,
38    /// Whether this is the active version
39    pub is_active: bool,
40}
41
42/// Environment configuration
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct EnvironmentConfig {
45    /// Active versions for each tool
46    pub active_versions: HashMap<String, String>,
47    /// Global settings
48    pub global_settings: HashMap<String, String>,
49}
50
51impl VxEnvironment {
52    /// Create a new VX environment
53    pub fn new() -> Result<Self> {
54        let base_dir = Self::get_vx_home()?;
55        let config_dir = base_dir.join("config");
56        let cache_dir = base_dir.join("cache");
57
58        // Ensure directories exist
59        std::fs::create_dir_all(&base_dir)?;
60        std::fs::create_dir_all(&config_dir)?;
61        std::fs::create_dir_all(&cache_dir)?;
62
63        Ok(Self {
64            base_dir,
65            config_dir,
66            cache_dir,
67        })
68    }
69
70    /// Create a new VX environment with custom base directory (for testing)
71    pub fn new_with_base_dir<P: AsRef<Path>>(base_dir: P) -> Result<Self> {
72        let base_dir = base_dir.as_ref().to_path_buf();
73        let config_dir = base_dir.join("config");
74        let cache_dir = base_dir.join("cache");
75
76        // Ensure directories exist
77        std::fs::create_dir_all(&base_dir)?;
78        std::fs::create_dir_all(&config_dir)?;
79        std::fs::create_dir_all(&cache_dir)?;
80
81        Ok(Self {
82            base_dir,
83            config_dir,
84            cache_dir,
85        })
86    }
87
88    /// Get VX home directory
89    pub fn get_vx_home() -> Result<PathBuf> {
90        if let Ok(vx_home) = std::env::var("VX_HOME") {
91            Ok(PathBuf::from(vx_home))
92        } else if let Some(home) = dirs::home_dir() {
93            Ok(home.join(".vx"))
94        } else {
95            Err(VxError::ConfigurationError {
96                message: "Cannot determine VX home directory".to_string(),
97            })
98        }
99    }
100
101    /// Get base installation directory
102    pub fn get_base_install_dir(&self) -> PathBuf {
103        self.base_dir.join("tools")
104    }
105
106    /// Get tool installation directory
107    pub fn get_tool_install_dir(&self, tool_name: &str) -> PathBuf {
108        self.get_base_install_dir().join(tool_name)
109    }
110
111    /// Get version installation directory
112    pub fn get_version_install_dir(&self, tool_name: &str, version: &str) -> PathBuf {
113        self.get_tool_install_dir(tool_name).join(version)
114    }
115
116    /// Get cache directory for downloads
117    pub fn get_cache_dir(&self) -> PathBuf {
118        self.cache_dir.clone()
119    }
120
121    /// Get download cache directory for a tool
122    pub fn get_tool_cache_dir(&self, tool_name: &str) -> PathBuf {
123        self.cache_dir.join("downloads").join(tool_name)
124    }
125
126    /// Get shim directory for tool proxies
127    pub fn shim_dir(&self) -> Result<PathBuf> {
128        let shim_dir = self.base_dir.join("shims");
129        std::fs::create_dir_all(&shim_dir)?;
130        Ok(shim_dir)
131    }
132
133    /// Get bin directory for vx executables
134    pub fn bin_dir(&self) -> Result<PathBuf> {
135        let bin_dir = self.base_dir.join("bin");
136        std::fs::create_dir_all(&bin_dir)?;
137        Ok(bin_dir)
138    }
139
140    /// Get configuration file path
141    pub fn get_config_file(&self) -> PathBuf {
142        self.config_dir.join("environment.toml")
143    }
144
145    /// Load environment configuration
146    pub fn load_config(&self) -> Result<EnvironmentConfig> {
147        let config_file = self.get_config_file();
148
149        if !config_file.exists() {
150            return Ok(EnvironmentConfig {
151                active_versions: HashMap::new(),
152                global_settings: HashMap::new(),
153            });
154        }
155
156        let content = std::fs::read_to_string(&config_file)?;
157        let config: EnvironmentConfig =
158            toml::from_str(&content).map_err(|e| VxError::ConfigurationError {
159                message: format!("Failed to parse environment config: {}", e),
160            })?;
161
162        Ok(config)
163    }
164
165    /// Save environment configuration
166    pub fn save_config(&self, config: &EnvironmentConfig) -> Result<()> {
167        let config_file = self.get_config_file();
168        let content = toml::to_string_pretty(config).map_err(|e| VxError::ConfigurationError {
169            message: format!("Failed to serialize environment config: {}", e),
170        })?;
171
172        std::fs::write(&config_file, content)?;
173        Ok(())
174    }
175
176    /// Get active version for a tool
177    pub fn get_active_version(&self, tool_name: &str) -> Result<Option<String>> {
178        let config = self.load_config()?;
179        Ok(config.active_versions.get(tool_name).cloned())
180    }
181
182    /// Set active version for a tool
183    pub fn set_active_version(&self, tool_name: &str, version: &str) -> Result<()> {
184        let mut config = self.load_config()?;
185        config
186            .active_versions
187            .insert(tool_name.to_string(), version.to_string());
188        self.save_config(&config)?;
189        Ok(())
190    }
191
192    /// List all installed versions for a tool
193    pub fn list_installed_versions(&self, tool_name: &str) -> Result<Vec<String>> {
194        let tool_dir = self.get_tool_install_dir(tool_name);
195
196        if !tool_dir.exists() {
197            return Ok(Vec::new());
198        }
199
200        let mut versions = Vec::new();
201        for entry in std::fs::read_dir(&tool_dir)? {
202            let entry = entry?;
203            if entry.file_type()?.is_dir() {
204                if let Some(version) = entry.file_name().to_str() {
205                    versions.push(version.to_string());
206                }
207            }
208        }
209
210        // Sort versions (simple string sort for now, could be improved with semver)
211        versions.sort();
212        Ok(versions)
213    }
214
215    /// Check if a version is installed
216    pub fn is_version_installed(&self, tool_name: &str, version: &str) -> bool {
217        let version_dir = self.get_version_install_dir(tool_name, version);
218        version_dir.exists()
219    }
220
221    /// Get installation info for a tool version
222    pub fn get_installation_info(
223        &self,
224        tool_name: &str,
225        version: &str,
226    ) -> Result<Option<ToolInstallation>> {
227        if !self.is_version_installed(tool_name, version) {
228            return Ok(None);
229        }
230
231        let install_dir = self.get_version_install_dir(tool_name, version);
232        let config = self.load_config()?;
233        let is_active = config.active_versions.get(tool_name) == Some(&version.to_string());
234
235        // Try to find the executable
236        let executable_path = self.find_executable_in_dir(&install_dir, tool_name)?;
237
238        Ok(Some(ToolInstallation {
239            tool_name: tool_name.to_string(),
240            version: version.to_string(),
241            install_dir,
242            executable_path,
243            installed_at: chrono::Utc::now(), // TODO: Get actual installation time
244            is_active,
245        }))
246    }
247
248    /// Find executable in installation directory
249    pub fn find_executable_in_dir(&self, dir: &Path, tool_name: &str) -> Result<PathBuf> {
250        // Common executable patterns (including nested directories)
251        // On Windows, prioritize .cmd and .exe files over files without extensions
252        let mut patterns = vec![];
253
254        // Add Windows-specific patterns first (higher priority)
255        #[cfg(windows)]
256        {
257            patterns.extend(vec![
258                format!("{}.cmd", tool_name),
259                format!("{}.bat", tool_name),
260                format!("{}.exe", tool_name),
261                format!("{}.ps1", tool_name),
262            ]);
263        }
264
265        // Add generic patterns
266        #[cfg(not(windows))]
267        {
268            patterns.extend(vec![format!("{}.exe", tool_name), tool_name.to_string()]);
269        }
270
271        // On Windows, add the no-extension pattern last (lowest priority)
272        #[cfg(windows)]
273        {
274            patterns.push(tool_name.to_string());
275        }
276
277        // Add bin subdirectory patterns
278        #[cfg(windows)]
279        {
280            patterns.extend(vec![
281                format!("bin/{}.cmd", tool_name),
282                format!("bin/{}.bat", tool_name),
283                format!("bin/{}.exe", tool_name),
284                format!("bin/{}.ps1", tool_name),
285                format!("bin/{}", tool_name),
286            ]);
287        }
288
289        #[cfg(not(windows))]
290        {
291            patterns.extend(vec![
292                format!("bin/{}.exe", tool_name),
293                format!("bin/{}", tool_name),
294            ]);
295        }
296
297        // First try direct patterns
298        for pattern in &patterns {
299            let exe_path = dir.join(pattern);
300            if self.is_executable(&exe_path) {
301                return Ok(exe_path);
302            }
303        }
304
305        // If not found, search recursively in subdirectories (common for archives)
306        if let Ok(entries) = std::fs::read_dir(dir) {
307            for entry in entries.flatten() {
308                if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
309                    let subdir = entry.path();
310
311                    // Try patterns in subdirectory
312                    for pattern in &patterns {
313                        let exe_path = subdir.join(pattern);
314                        if self.is_executable(&exe_path) {
315                            return Ok(exe_path);
316                        }
317                    }
318                }
319            }
320        }
321
322        Err(VxError::ExecutableNotFound {
323            tool_name: tool_name.to_string(),
324            install_dir: dir.to_path_buf(),
325        })
326    }
327
328    /// Check if a path is an executable (handles symlinks)
329    fn is_executable(&self, path: &Path) -> bool {
330        if !path.exists() {
331            return false;
332        }
333
334        // Check if it's a regular file or symlink
335        if let Ok(metadata) = std::fs::metadata(path) {
336            if metadata.is_file() {
337                return true;
338            }
339        }
340
341        // Check if it's a symlink that points to an executable
342        if let Ok(metadata) = std::fs::symlink_metadata(path) {
343            if metadata.file_type().is_symlink() {
344                // For symlinks, we consider them executable if they exist
345                // (the target might be executable even if we can't check it directly)
346                return true;
347            }
348        }
349
350        false
351    }
352
353    /// Clean up unused installations
354    pub fn cleanup_unused(&self, keep_latest: usize) -> Result<Vec<String>> {
355        let mut cleaned = Vec::new();
356        let tools_dir = self.get_base_install_dir();
357
358        if !tools_dir.exists() {
359            return Ok(cleaned);
360        }
361
362        for entry in std::fs::read_dir(&tools_dir)? {
363            let entry = entry?;
364            if entry.file_type()?.is_dir() {
365                if let Some(tool_name) = entry.file_name().to_str() {
366                    let removed = self.cleanup_tool_versions(tool_name, keep_latest)?;
367                    cleaned.extend(removed);
368                }
369            }
370        }
371
372        Ok(cleaned)
373    }
374
375    /// Clean up old versions of a specific tool
376    pub fn cleanup_tool_versions(
377        &self,
378        tool_name: &str,
379        keep_latest: usize,
380    ) -> Result<Vec<String>> {
381        let mut versions = self.list_installed_versions(tool_name)?;
382        let config = self.load_config()?;
383        let active_version = config.active_versions.get(tool_name);
384
385        if versions.len() <= keep_latest {
386            return Ok(Vec::new());
387        }
388
389        // Sort versions (newest first)
390        versions.sort();
391        versions.reverse();
392
393        let mut removed = Vec::new();
394        let mut kept_count = 0;
395
396        for version in versions {
397            // Always keep the active version
398            if Some(&version) == active_version {
399                continue;
400            }
401
402            if kept_count < keep_latest {
403                kept_count += 1;
404                continue;
405            }
406
407            // Remove this version
408            let version_dir = self.get_version_install_dir(tool_name, &version);
409            if version_dir.exists() {
410                std::fs::remove_dir_all(&version_dir)?;
411                removed.push(format!("{}@{}", tool_name, version));
412            }
413        }
414
415        Ok(removed)
416    }
417}
418
419impl Default for VxEnvironment {
420    fn default() -> Self {
421        Self::new().expect("Failed to create VX environment")
422    }
423}