1use 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#[derive(Debug, Serialize, Deserialize, Default)]
18pub struct VxConfig {
19 pub defaults: DefaultConfig,
21 pub tools: HashMap<String, ToolConfig>,
23 pub registries: HashMap<String, RegistryConfig>,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
29pub struct DefaultConfig {
30 pub auto_install: bool,
32 pub check_updates: bool,
34 pub update_interval: String,
36 pub default_registry: String,
38 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#[derive(Debug, Serialize, Deserialize, Clone)]
56pub struct ToolConfig {
57 pub version: Option<String>,
59 pub install_method: Option<String>,
61 pub registry: Option<String>,
63 pub custom_sources: Option<HashMap<String, String>>,
65}
66
67#[derive(Debug, Serialize, Deserialize, Clone)]
69pub struct RegistryConfig {
70 pub name: String,
72 pub base_url: String,
74 pub api_url: Option<String>,
76 pub auth_token: Option<String>,
78 pub priority: i32,
80 pub enabled: bool,
82}
83
84#[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#[derive(Debug, Clone, PartialEq)]
94pub enum ProjectType {
95 Python, Rust, Node, Go, Mixed, Unknown, }
102
103#[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#[derive(Debug, Clone)]
114pub struct LayerInfo {
115 pub name: String,
116 pub available: bool,
117 pub priority: i32,
118}
119
120impl ConfigStatus {
121 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 pub fn is_healthy(&self) -> bool {
144 self.layers.iter().any(|l| l.available) && !self.available_tools.is_empty()
146 }
147}
148
149pub struct FigmentConfigManager {
151 figment: Figment,
152 config: VxConfig,
153 project_info: Option<ProjectInfo>,
154}
155
156impl FigmentConfigManager {
157 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 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 pub fn get_tool_config(&self, tool_name: &str) -> Option<&ToolConfig> {
188 self.config.tools.get(tool_name)
189 }
190
191 pub fn get_available_tools(&self) -> Vec<String> {
193 let mut tools = std::collections::HashSet::new();
194
195 for tool in self.config.tools.keys() {
197 tools.insert(tool.clone());
198 }
199
200 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 pub fn supports_tool(&self, tool_name: &str) -> bool {
214 if self.config.tools.contains_key(tool_name) {
216 return true;
217 }
218
219 if self.config.defaults.fallback_to_builtin {
221 return ["uv", "node", "go", "rust"].contains(&tool_name);
222 }
223
224 false
225 }
226
227 pub fn config(&self) -> &VxConfig {
229 &self.config
230 }
231
232 pub fn project_info(&self) -> &Option<ProjectInfo> {
234 &self.project_info
235 }
236
237 pub fn figment(&self) -> &Figment {
239 &self.figment
240 }
241
242 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 pub fn get_download_url(&self, tool_name: &str, version: &str) -> Result<String> {
254 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 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 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 pub fn get_status(&self) -> ConfigStatus {
309 let mut layers = Vec::new();
310
311 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 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 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 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 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 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 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 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 fn build_figment(project_info: &Option<ProjectInfo>) -> Result<Figment> {
432 let mut figment = Figment::new();
433
434 figment = figment.merge(Serialized::defaults(VxConfig::default()));
436
437 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 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 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 figment = figment.merge(Env::prefixed("VX_"));
459
460 Ok(figment)
461 }
462
463 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 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 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 let version = Self::parse_version_requirement(version_str);
498 versions.insert("python".to_string(), version);
499 }
500 }
501 }
502
503 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 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 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 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 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 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 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 fn parse_version_requirement(requirement: &str) -> String {
592 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 if let Some(space_pos) = cleaned.find(' ') {
602 cleaned[..space_pos].to_string()
603 } else {
604 cleaned.to_string()
605 }
606 }
607}