vx_core/
config_figment.rs

1//! Figment-based configuration system for vx
2//! Leverages the excellent figment crate for layered configuration
3//! Supports reading from existing project configuration files
4
5use crate::{Result, VxError};
6use figment::{
7    providers::{Env, Format, Serialized, Toml},
8    Figment,
9};
10use serde::{Deserialize, Serialize};
11use serde_json::Value as JsonValue;
12use std::collections::HashMap;
13use std::fs;
14use std::path::PathBuf;
15
16/// Main vx configuration structure
17#[derive(Debug, Serialize, Deserialize, Default)]
18pub struct VxConfig {
19    /// Global default settings
20    pub defaults: DefaultConfig,
21    /// Tool-specific configurations
22    pub tools: HashMap<String, ToolConfig>,
23    /// Registry configurations
24    pub registries: HashMap<String, RegistryConfig>,
25}
26
27/// Default configuration settings
28#[derive(Debug, Serialize, Deserialize)]
29pub struct DefaultConfig {
30    /// Automatically install missing tools
31    pub auto_install: bool,
32    /// Check for updates periodically
33    pub check_updates: bool,
34    /// Update check interval
35    pub update_interval: String,
36    /// Default registry to use
37    pub default_registry: String,
38    /// Whether to fall back to builtin configuration
39    pub fallback_to_builtin: bool,
40}
41
42impl Default for DefaultConfig {
43    fn default() -> Self {
44        Self {
45            auto_install: true,
46            check_updates: true,
47            update_interval: "24h".to_string(),
48            default_registry: "official".to_string(),
49            fallback_to_builtin: true,
50        }
51    }
52}
53
54/// Tool-specific configuration
55#[derive(Debug, Serialize, Deserialize, Clone)]
56pub struct ToolConfig {
57    /// Preferred version (latest, lts, specific version)
58    pub version: Option<String>,
59    /// Installation method preference
60    pub install_method: Option<String>,
61    /// Registry to use for this tool
62    pub registry: Option<String>,
63    /// Custom download sources
64    pub custom_sources: Option<HashMap<String, String>>,
65}
66
67/// Registry configuration
68#[derive(Debug, Serialize, Deserialize, Clone)]
69pub struct RegistryConfig {
70    /// Registry name
71    pub name: String,
72    /// Base URL for the registry
73    pub base_url: String,
74    /// API URL (optional)
75    pub api_url: Option<String>,
76    /// Authentication token (optional)
77    pub auth_token: Option<String>,
78    /// Registry priority (higher = more preferred)
79    pub priority: i32,
80    /// Whether this registry is enabled
81    pub enabled: bool,
82}
83
84/// Project configuration detection result
85#[derive(Debug, Clone)]
86pub struct ProjectInfo {
87    pub project_type: ProjectType,
88    pub config_file: PathBuf,
89    pub tool_versions: HashMap<String, String>,
90}
91
92/// Supported project types
93#[derive(Debug, Clone, PartialEq)]
94pub enum ProjectType {
95    Python,  // pyproject.toml
96    Rust,    // Cargo.toml
97    Node,    // package.json
98    Go,      // go.mod
99    Mixed,   // Multiple project types
100    Unknown, // No recognized project files
101}
102
103/// Configuration status for diagnostics
104#[derive(Debug, Clone)]
105pub struct ConfigStatus {
106    pub layers: Vec<LayerInfo>,
107    pub available_tools: Vec<String>,
108    pub fallback_enabled: bool,
109    pub project_info: Option<ProjectInfo>,
110}
111
112/// Information about a configuration layer
113#[derive(Debug, Clone)]
114pub struct LayerInfo {
115    pub name: String,
116    pub available: bool,
117    pub priority: i32,
118}
119
120impl ConfigStatus {
121    /// Get a summary of the configuration status
122    pub fn summary(&self) -> String {
123        let active_layers: Vec<&str> = self
124            .layers
125            .iter()
126            .filter(|l| l.available)
127            .map(|l| l.name.as_str())
128            .collect();
129
130        format!(
131            "Configuration layers: {} | Tools: {} | Fallback: {}",
132            active_layers.join(", "),
133            self.available_tools.len(),
134            if self.fallback_enabled {
135                "enabled"
136            } else {
137                "disabled"
138            }
139        )
140    }
141
142    /// Check if the configuration is healthy
143    pub fn is_healthy(&self) -> bool {
144        // At least one layer should be available
145        self.layers.iter().any(|l| l.available) && !self.available_tools.is_empty()
146    }
147}
148
149/// Configuration manager using Figment
150pub struct FigmentConfigManager {
151    figment: Figment,
152    config: VxConfig,
153    project_info: Option<ProjectInfo>,
154}
155
156impl FigmentConfigManager {
157    /// Create a new configuration manager with full layered configuration
158    pub fn new() -> Result<Self> {
159        let project_info = Self::detect_project_info()?;
160        let figment = Self::build_figment(&project_info)?;
161        let config = figment.extract().map_err(|e| VxError::ConfigError {
162            message: format!("Failed to extract configuration: {}", e),
163        })?;
164
165        Ok(Self {
166            figment,
167            config,
168            project_info,
169        })
170    }
171
172    /// Create a minimal configuration manager (builtin defaults only)
173    pub fn minimal() -> Result<Self> {
174        let figment = Figment::from(Serialized::defaults(VxConfig::default()));
175        let config = figment.extract().map_err(|e| VxError::ConfigError {
176            message: format!("Failed to extract minimal configuration: {}", e),
177        })?;
178
179        Ok(Self {
180            figment,
181            config,
182            project_info: None,
183        })
184    }
185
186    /// Get tool configuration
187    pub fn get_tool_config(&self, tool_name: &str) -> Option<&ToolConfig> {
188        self.config.tools.get(tool_name)
189    }
190
191    /// Get available tools (from configuration + builtin)
192    pub fn get_available_tools(&self) -> Vec<String> {
193        let mut tools = std::collections::HashSet::new();
194
195        // Add configured tools
196        for tool in self.config.tools.keys() {
197            tools.insert(tool.clone());
198        }
199
200        // Add builtin tools if fallback is enabled
201        if self.config.defaults.fallback_to_builtin {
202            for tool in &["uv", "node", "go", "rust"] {
203                tools.insert(tool.to_string());
204            }
205        }
206
207        let mut result: Vec<String> = tools.into_iter().collect();
208        result.sort();
209        result
210    }
211
212    /// Check if a tool is supported
213    pub fn supports_tool(&self, tool_name: &str) -> bool {
214        // Check if configured
215        if self.config.tools.contains_key(tool_name) {
216            return true;
217        }
218
219        // Check builtin if fallback enabled
220        if self.config.defaults.fallback_to_builtin {
221            return ["uv", "node", "go", "rust"].contains(&tool_name);
222        }
223
224        false
225    }
226
227    /// Get the current configuration
228    pub fn config(&self) -> &VxConfig {
229        &self.config
230    }
231
232    /// Get project information
233    pub fn project_info(&self) -> &Option<ProjectInfo> {
234        &self.project_info
235    }
236
237    /// Get the underlying figment for advanced usage
238    pub fn figment(&self) -> &Figment {
239        &self.figment
240    }
241
242    /// Reload configuration
243    pub fn reload(&mut self) -> Result<()> {
244        self.project_info = Self::detect_project_info()?;
245        self.figment = Self::build_figment(&self.project_info)?;
246        self.config = self.figment.extract().map_err(|e| VxError::ConfigError {
247            message: format!("Failed to reload configuration: {}", e),
248        })?;
249        Ok(())
250    }
251
252    /// Get download URL for a tool and version
253    pub fn get_download_url(&self, tool_name: &str, version: &str) -> Result<String> {
254        // First try to get from configuration
255        if let Some(tool_config) = self.config.tools.get(tool_name) {
256            if let Some(custom_sources) = &tool_config.custom_sources {
257                if let Some(url_template) = custom_sources.get("default") {
258                    return Ok(self.expand_url_template(url_template, tool_name, version));
259                }
260            }
261        }
262
263        // Fall back to builtin URL builders
264        if self.config.defaults.fallback_to_builtin {
265            match tool_name {
266                "node" => {
267                    crate::NodeUrlBuilder::download_url(version).ok_or_else(|| VxError::Other {
268                        message: format!("No download URL available for {} {}", tool_name, version),
269                    })
270                }
271                "go" => crate::GoUrlBuilder::download_url(version).ok_or_else(|| VxError::Other {
272                    message: format!("No download URL available for {} {}", tool_name, version),
273                }),
274                _ => Err(VxError::Other {
275                    message: format!("Tool {} not supported", tool_name),
276                }),
277            }
278        } else {
279            Err(VxError::Other {
280                message: format!("Tool {} not configured and fallback disabled", tool_name),
281            })
282        }
283    }
284
285    /// Expand URL template with variables
286    fn expand_url_template(&self, template: &str, tool_name: &str, version: &str) -> String {
287        let platform = crate::Platform::current();
288        let (os, arch) = match tool_name {
289            "node" => platform
290                .node_platform_string()
291                .unwrap_or(("linux".to_string(), "x64".to_string())),
292            "go" => platform
293                .go_platform_string()
294                .unwrap_or(("linux".to_string(), "amd64".to_string())),
295            _ => ("linux".to_string(), "x64".to_string()),
296        };
297        let ext = platform.archive_extension();
298
299        template
300            .replace("{tool}", tool_name)
301            .replace("{version}", version)
302            .replace("{platform}", &os)
303            .replace("{arch}", &arch)
304            .replace("{ext}", ext)
305    }
306
307    /// Get configuration status for diagnostics
308    pub fn get_status(&self) -> ConfigStatus {
309        let mut layers = Vec::new();
310
311        // Check which layers are active
312        layers.push(LayerInfo {
313            name: "builtin".to_string(),
314            available: true,
315            priority: 10,
316        });
317
318        if let Some(config_dir) = dirs::config_dir() {
319            let global_config = config_dir.join("vx").join("config.toml");
320            layers.push(LayerInfo {
321                name: "user".to_string(),
322                available: global_config.exists(),
323                priority: 50,
324            });
325        }
326
327        let project_config = PathBuf::from(".vx.toml");
328        layers.push(LayerInfo {
329            name: "project".to_string(),
330            available: project_config.exists(),
331            priority: 80,
332        });
333
334        layers.push(LayerInfo {
335            name: "environment".to_string(),
336            available: std::env::vars().any(|(k, _)| k.starts_with("VX_")),
337            priority: 100,
338        });
339
340        ConfigStatus {
341            layers,
342            available_tools: self.get_available_tools(),
343            fallback_enabled: self.config.defaults.fallback_to_builtin,
344            project_info: self.project_info.clone(),
345        }
346    }
347
348    /// Detect project information and configuration files
349    fn detect_project_info() -> Result<Option<ProjectInfo>> {
350        let current_dir = std::env::current_dir().map_err(|e| VxError::Other {
351            message: format!("Failed to get current directory: {}", e),
352        })?;
353        let mut detected_projects = Vec::new();
354        let mut all_tool_versions = HashMap::new();
355
356        // Check for Python project (pyproject.toml)
357        let pyproject_path = current_dir.join("pyproject.toml");
358        if pyproject_path.exists() {
359            if let Ok(versions) = Self::parse_pyproject_toml(&pyproject_path) {
360                detected_projects.push(ProjectType::Python);
361                all_tool_versions.extend(versions);
362            }
363        }
364
365        // Check for Rust project (Cargo.toml)
366        let cargo_path = current_dir.join("Cargo.toml");
367        if cargo_path.exists() {
368            if let Ok(versions) = Self::parse_cargo_toml(&cargo_path) {
369                detected_projects.push(ProjectType::Rust);
370                all_tool_versions.extend(versions);
371            }
372        }
373
374        // Check for Node.js project (package.json)
375        let package_path = current_dir.join("package.json");
376        if package_path.exists() {
377            if let Ok(versions) = Self::parse_package_json(&package_path) {
378                detected_projects.push(ProjectType::Node);
379                all_tool_versions.extend(versions);
380            }
381        }
382
383        // Check for Go project (go.mod)
384        let gomod_path = current_dir.join("go.mod");
385        if gomod_path.exists() {
386            if let Ok(versions) = Self::parse_go_mod(&gomod_path) {
387                detected_projects.push(ProjectType::Go);
388                all_tool_versions.extend(versions);
389            }
390        }
391
392        if detected_projects.is_empty() {
393            return Ok(None);
394        }
395
396        let project_type = if detected_projects.len() == 1 {
397            detected_projects[0].clone()
398        } else {
399            ProjectType::Mixed
400        };
401
402        // Use the first detected config file as primary
403        let config_file = match project_type {
404            ProjectType::Python => pyproject_path,
405            ProjectType::Rust => cargo_path,
406            ProjectType::Node => package_path,
407            ProjectType::Go => gomod_path,
408            ProjectType::Mixed => {
409                // Prefer pyproject.toml for mixed projects
410                if pyproject_path.exists() {
411                    pyproject_path
412                } else if cargo_path.exists() {
413                    cargo_path
414                } else if package_path.exists() {
415                    package_path
416                } else {
417                    gomod_path
418                }
419            }
420            ProjectType::Unknown => return Ok(None),
421        };
422
423        Ok(Some(ProjectInfo {
424            project_type,
425            config_file,
426            tool_versions: all_tool_versions,
427        }))
428    }
429
430    /// Build the complete figment with all configuration layers
431    fn build_figment(project_info: &Option<ProjectInfo>) -> Result<Figment> {
432        let mut figment = Figment::new();
433
434        // Layer 1: Built-in defaults (lowest priority)
435        figment = figment.merge(Serialized::defaults(VxConfig::default()));
436
437        // Layer 2: Global user configuration
438        if let Some(config_dir) = dirs::config_dir() {
439            let global_config = config_dir.join("vx").join("config.toml");
440            if global_config.exists() {
441                figment = figment.merge(Toml::file(global_config));
442            }
443        }
444
445        // Layer 3: Project-specific tool versions (from project config files)
446        if let Some(project_info) = project_info {
447            let project_config = Self::create_project_config_from_info(project_info)?;
448            figment = figment.merge(Serialized::defaults(project_config));
449        }
450
451        // Layer 4: vx-specific project configuration (.vx.toml)
452        let vx_project_config = PathBuf::from(".vx.toml");
453        if vx_project_config.exists() {
454            figment = figment.merge(Toml::file(vx_project_config));
455        }
456
457        // Layer 5: Environment variables (highest priority)
458        figment = figment.merge(Env::prefixed("VX_"));
459
460        Ok(figment)
461    }
462
463    /// Create project configuration from detected project info
464    fn create_project_config_from_info(project_info: &ProjectInfo) -> Result<VxConfig> {
465        let mut config = VxConfig::default();
466
467        for (tool_name, version) in &project_info.tool_versions {
468            config.tools.insert(
469                tool_name.clone(),
470                ToolConfig {
471                    version: Some(version.clone()),
472                    install_method: None,
473                    registry: None,
474                    custom_sources: None,
475                },
476            );
477        }
478
479        Ok(config)
480    }
481
482    /// Parse pyproject.toml for tool version requirements
483    fn parse_pyproject_toml(path: &PathBuf) -> Result<HashMap<String, String>> {
484        let content = fs::read_to_string(path).map_err(|e| VxError::Other {
485            message: format!("Failed to read pyproject.toml: {}", e),
486        })?;
487        let parsed: toml::Value = toml::from_str(&content).map_err(|e| VxError::Other {
488            message: format!("Failed to parse pyproject.toml: {}", e),
489        })?;
490        let mut versions = HashMap::new();
491
492        // Check for Python version requirement
493        if let Some(project) = parsed.get("project") {
494            if let Some(requires_python) = project.get("requires-python") {
495                if let Some(version_str) = requires_python.as_str() {
496                    // Parse version requirement like ">=3.8" to "3.8"
497                    let version = Self::parse_version_requirement(version_str);
498                    versions.insert("python".to_string(), version);
499                }
500            }
501        }
502
503        // Check for tool.uv configuration
504        if let Some(tool) = parsed.get("tool") {
505            if let Some(uv) = tool.get("uv") {
506                if let Some(version) = uv.get("version") {
507                    if let Some(version_str) = version.as_str() {
508                        versions.insert("uv".to_string(), version_str.to_string());
509                    }
510                }
511            }
512        }
513
514        Ok(versions)
515    }
516
517    /// Parse Cargo.toml for tool version requirements
518    fn parse_cargo_toml(path: &PathBuf) -> Result<HashMap<String, String>> {
519        let content = fs::read_to_string(path).map_err(|e| VxError::Other {
520            message: format!("Failed to read Cargo.toml: {}", e),
521        })?;
522        let parsed: toml::Value = toml::from_str(&content).map_err(|e| VxError::Other {
523            message: format!("Failed to parse Cargo.toml: {}", e),
524        })?;
525        let mut versions = HashMap::new();
526
527        // Check for Rust version requirement
528        if let Some(package) = parsed.get("package") {
529            if let Some(rust_version) = package.get("rust-version") {
530                if let Some(version_str) = rust_version.as_str() {
531                    versions.insert("rust".to_string(), version_str.to_string());
532                }
533            }
534        }
535
536        Ok(versions)
537    }
538
539    /// Parse package.json for tool version requirements
540    fn parse_package_json(path: &PathBuf) -> Result<HashMap<String, String>> {
541        let content = fs::read_to_string(path).map_err(|e| VxError::Other {
542            message: format!("Failed to read package.json: {}", e),
543        })?;
544        let parsed: JsonValue = serde_json::from_str(&content).map_err(|e| VxError::Other {
545            message: format!("Failed to parse package.json: {}", e),
546        })?;
547        let mut versions = HashMap::new();
548
549        // Check for Node.js version requirement in engines
550        if let Some(engines) = parsed.get("engines") {
551            if let Some(node_version) = engines.get("node") {
552                if let Some(version_str) = node_version.as_str() {
553                    let version = Self::parse_version_requirement(version_str);
554                    versions.insert("node".to_string(), version);
555                }
556            }
557            if let Some(npm_version) = engines.get("npm") {
558                if let Some(version_str) = npm_version.as_str() {
559                    let version = Self::parse_version_requirement(version_str);
560                    versions.insert("npm".to_string(), version);
561                }
562            }
563        }
564
565        Ok(versions)
566    }
567
568    /// Parse go.mod for Go version requirement
569    fn parse_go_mod(path: &PathBuf) -> Result<HashMap<String, String>> {
570        let content = fs::read_to_string(path).map_err(|e| VxError::Other {
571            message: format!("Failed to read go.mod: {}", e),
572        })?;
573        let mut versions = HashMap::new();
574
575        // Parse go.mod format: "go 1.21"
576        for line in content.lines() {
577            let line = line.trim();
578            if line.starts_with("go ") {
579                let parts: Vec<&str> = line.split_whitespace().collect();
580                if parts.len() >= 2 {
581                    versions.insert("go".to_string(), parts[1].to_string());
582                }
583                break;
584            }
585        }
586
587        Ok(versions)
588    }
589
590    /// Parse version requirement string to extract version
591    fn parse_version_requirement(requirement: &str) -> String {
592        // Remove common prefixes like >=, ^, ~, etc.
593        let cleaned = requirement
594            .trim_start_matches(">=")
595            .trim_start_matches("^")
596            .trim_start_matches("~")
597            .trim_start_matches("=")
598            .trim_start_matches(">");
599
600        // Take the first version number found
601        if let Some(space_pos) = cleaned.find(' ') {
602            cleaned[..space_pos].to_string()
603        } else {
604            cleaned.to_string()
605        }
606    }
607}