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 validate(&self) -> Result<Vec<String>> {
309 let mut warnings = Vec::new();
310
311 for (tool_name, tool_config) in &self.config.tools {
313 if let Some(version) = &tool_config.version {
314 if version.is_empty() {
315 warnings.push(format!("Tool '{}' has empty version", tool_name));
316 }
317 }
318
319 if let Some(custom_sources) = &tool_config.custom_sources {
320 for (source_name, url) in custom_sources {
321 if !url.contains("{version}") && !url.contains("{tool}") {
322 warnings.push(format!(
323 "Tool '{}' source '{}' URL may be missing version/tool placeholders",
324 tool_name, source_name
325 ));
326 }
327 }
328 }
329 }
330
331 for (registry_name, registry_config) in &self.config.registries {
333 if registry_config.base_url.is_empty() {
334 warnings.push(format!("Registry '{}' has empty base URL", registry_name));
335 }
336 }
337
338 if self.config.defaults.update_interval.is_empty() {
340 warnings.push("Update interval is empty".to_string());
341 }
342
343 Ok(warnings)
344 }
345
346 pub fn init_project_config(
348 &self,
349 tools: Option<HashMap<String, String>>,
350 interactive: bool,
351 ) -> Result<()> {
352 let config_path = std::env::current_dir()
353 .map_err(|e| VxError::Other {
354 message: format!("Failed to get current directory: {}", e),
355 })?
356 .join(".vx.toml");
357
358 if config_path.exists() {
359 return Err(VxError::Other {
360 message: "Configuration file .vx.toml already exists".to_string(),
361 });
362 }
363
364 let mut project_config = crate::venv::ProjectConfig::default();
365
366 if let Some(tools) = tools {
368 project_config.tools = tools;
369 } else if interactive {
370 if let Some(project_info) = &self.project_info {
373 project_config.tools = project_info.tool_versions.clone();
374 }
375 }
376
377 project_config.settings.auto_install = true;
379 project_config.settings.cache_duration = "7d".to_string();
380
381 let toml_content = toml::to_string_pretty(&project_config).map_err(|e| VxError::Other {
383 message: format!("Failed to serialize configuration: {}", e),
384 })?;
385
386 let header = r#"# VX Project Configuration
388# This file defines the tools and versions required for this project.
389# Run 'vx sync' to install all required tools.
390
391"#;
392
393 let full_content = format!("{}{}", header, toml_content);
394
395 std::fs::write(&config_path, full_content).map_err(|e| VxError::Other {
397 message: format!("Failed to write .vx.toml: {}", e),
398 })?;
399
400 Ok(())
401 }
402
403 pub async fn sync_project(&self, force: bool) -> Result<Vec<String>> {
405 let mut installed_tools = Vec::new();
406
407 let venv_manager = crate::VenvManager::new()?;
409 let project_config = venv_manager.load_project_config()?;
410
411 if let Some(config) = project_config {
412 for (tool_name, version) in &config.tools {
413 let env = crate::VxEnvironment::new()?;
415 let is_installed = env.is_version_installed(tool_name, version);
416
417 if !is_installed || force {
418 installed_tools.push(format!("{}@{}", tool_name, version));
421 }
422 }
423 }
424
425 Ok(installed_tools)
426 }
427
428 pub fn get_project_tool_version(&self, tool_name: &str) -> Option<String> {
430 if let Some(project_info) = &self.project_info {
432 if let Some(version) = project_info.tool_versions.get(tool_name) {
433 return Some(version.clone());
434 }
435 }
436
437 if let Some(tool_config) = self.config.tools.get(tool_name) {
439 return tool_config.version.clone();
440 }
441
442 None
443 }
444
445 pub fn get_status(&self) -> ConfigStatus {
447 let mut layers = Vec::new();
448
449 layers.push(LayerInfo {
451 name: "builtin".to_string(),
452 available: true,
453 priority: 10,
454 });
455
456 if let Some(config_dir) = dirs::config_dir() {
457 let global_config = config_dir.join("vx").join("config.toml");
458 layers.push(LayerInfo {
459 name: "user".to_string(),
460 available: global_config.exists(),
461 priority: 50,
462 });
463 }
464
465 let project_config = PathBuf::from(".vx.toml");
466 layers.push(LayerInfo {
467 name: "project".to_string(),
468 available: project_config.exists(),
469 priority: 80,
470 });
471
472 layers.push(LayerInfo {
473 name: "environment".to_string(),
474 available: std::env::vars().any(|(k, _)| k.starts_with("VX_")),
475 priority: 100,
476 });
477
478 ConfigStatus {
479 layers,
480 available_tools: self.get_available_tools(),
481 fallback_enabled: self.config.defaults.fallback_to_builtin,
482 project_info: self.project_info.clone(),
483 }
484 }
485
486 fn detect_project_info() -> Result<Option<ProjectInfo>> {
488 let current_dir = std::env::current_dir().map_err(|e| VxError::Other {
489 message: format!("Failed to get current directory: {}", e),
490 })?;
491 let mut detected_projects = Vec::new();
492 let mut all_tool_versions = HashMap::new();
493
494 let pyproject_path = current_dir.join("pyproject.toml");
496 if pyproject_path.exists() {
497 if let Ok(versions) = Self::parse_pyproject_toml(&pyproject_path) {
498 detected_projects.push(ProjectType::Python);
499 all_tool_versions.extend(versions);
500 }
501 }
502
503 let cargo_path = current_dir.join("Cargo.toml");
505 if cargo_path.exists() {
506 if let Ok(versions) = Self::parse_cargo_toml(&cargo_path) {
507 detected_projects.push(ProjectType::Rust);
508 all_tool_versions.extend(versions);
509 }
510 }
511
512 let package_path = current_dir.join("package.json");
514 if package_path.exists() {
515 if let Ok(versions) = Self::parse_package_json(&package_path) {
516 detected_projects.push(ProjectType::Node);
517 all_tool_versions.extend(versions);
518 }
519 }
520
521 let gomod_path = current_dir.join("go.mod");
523 if gomod_path.exists() {
524 if let Ok(versions) = Self::parse_go_mod(&gomod_path) {
525 detected_projects.push(ProjectType::Go);
526 all_tool_versions.extend(versions);
527 }
528 }
529
530 if detected_projects.is_empty() {
531 return Ok(None);
532 }
533
534 let project_type = if detected_projects.len() == 1 {
535 detected_projects[0].clone()
536 } else {
537 ProjectType::Mixed
538 };
539
540 let config_file = match project_type {
542 ProjectType::Python => pyproject_path,
543 ProjectType::Rust => cargo_path,
544 ProjectType::Node => package_path,
545 ProjectType::Go => gomod_path,
546 ProjectType::Mixed => {
547 if pyproject_path.exists() {
549 pyproject_path
550 } else if cargo_path.exists() {
551 cargo_path
552 } else if package_path.exists() {
553 package_path
554 } else {
555 gomod_path
556 }
557 }
558 ProjectType::Unknown => return Ok(None),
559 };
560
561 Ok(Some(ProjectInfo {
562 project_type,
563 config_file,
564 tool_versions: all_tool_versions,
565 }))
566 }
567
568 fn build_figment(project_info: &Option<ProjectInfo>) -> Result<Figment> {
570 let mut figment = Figment::new();
571
572 figment = figment.merge(Serialized::defaults(VxConfig::default()));
574
575 if let Some(config_dir) = dirs::config_dir() {
577 let global_config = config_dir.join("vx").join("config.toml");
578 if global_config.exists() {
579 figment = figment.merge(Toml::file(global_config));
580 }
581 }
582
583 if let Some(project_info) = project_info {
585 let project_config = Self::create_project_config_from_info(project_info)?;
586 figment = figment.merge(Serialized::defaults(project_config));
587 }
588
589 let vx_project_config = PathBuf::from(".vx.toml");
591 if vx_project_config.exists() {
592 if let Ok(project_config) = Self::parse_vx_project_config(&vx_project_config) {
594 figment = figment.merge(Serialized::defaults(project_config));
595 }
596 }
597
598 figment = figment.merge(Env::prefixed("VX_"));
600
601 Ok(figment)
602 }
603
604 fn create_project_config_from_info(project_info: &ProjectInfo) -> Result<VxConfig> {
606 let mut config = VxConfig::default();
607
608 for (tool_name, version) in &project_info.tool_versions {
609 config.tools.insert(
610 tool_name.clone(),
611 ToolConfig {
612 version: Some(version.clone()),
613 install_method: None,
614 registry: None,
615 custom_sources: None,
616 },
617 );
618 }
619
620 Ok(config)
621 }
622
623 fn parse_pyproject_toml(path: &PathBuf) -> Result<HashMap<String, String>> {
625 let content = fs::read_to_string(path).map_err(|e| VxError::Other {
626 message: format!("Failed to read pyproject.toml: {}", e),
627 })?;
628 let parsed: toml::Value = toml::from_str(&content).map_err(|e| VxError::Other {
629 message: format!("Failed to parse pyproject.toml: {}", e),
630 })?;
631 let mut versions = HashMap::new();
632
633 if let Some(project) = parsed.get("project") {
635 if let Some(requires_python) = project.get("requires-python") {
636 if let Some(version_str) = requires_python.as_str() {
637 let version = Self::parse_version_requirement(version_str);
639 versions.insert("python".to_string(), version);
640 }
641 }
642 }
643
644 if let Some(tool) = parsed.get("tool") {
646 if let Some(uv) = tool.get("uv") {
647 if let Some(version) = uv.get("version") {
648 if let Some(version_str) = version.as_str() {
649 versions.insert("uv".to_string(), version_str.to_string());
650 }
651 }
652 }
653 }
654
655 Ok(versions)
656 }
657
658 fn parse_cargo_toml(path: &PathBuf) -> Result<HashMap<String, String>> {
660 let content = fs::read_to_string(path).map_err(|e| VxError::Other {
661 message: format!("Failed to read Cargo.toml: {}", e),
662 })?;
663 let parsed: toml::Value = toml::from_str(&content).map_err(|e| VxError::Other {
664 message: format!("Failed to parse Cargo.toml: {}", e),
665 })?;
666 let mut versions = HashMap::new();
667
668 if let Some(package) = parsed.get("package") {
670 if let Some(rust_version) = package.get("rust-version") {
671 if let Some(version_str) = rust_version.as_str() {
672 versions.insert("rust".to_string(), version_str.to_string());
673 }
674 }
675 }
676
677 Ok(versions)
678 }
679
680 fn parse_package_json(path: &PathBuf) -> Result<HashMap<String, String>> {
682 let content = fs::read_to_string(path).map_err(|e| VxError::Other {
683 message: format!("Failed to read package.json: {}", e),
684 })?;
685 let parsed: JsonValue = serde_json::from_str(&content).map_err(|e| VxError::Other {
686 message: format!("Failed to parse package.json: {}", e),
687 })?;
688 let mut versions = HashMap::new();
689
690 if let Some(engines) = parsed.get("engines") {
692 if let Some(node_version) = engines.get("node") {
693 if let Some(version_str) = node_version.as_str() {
694 let version = Self::parse_version_requirement(version_str);
695 versions.insert("node".to_string(), version);
696 }
697 }
698 if let Some(npm_version) = engines.get("npm") {
699 if let Some(version_str) = npm_version.as_str() {
700 let version = Self::parse_version_requirement(version_str);
701 versions.insert("npm".to_string(), version);
702 }
703 }
704 }
705
706 Ok(versions)
707 }
708
709 fn parse_go_mod(path: &PathBuf) -> Result<HashMap<String, String>> {
711 let content = fs::read_to_string(path).map_err(|e| VxError::Other {
712 message: format!("Failed to read go.mod: {}", e),
713 })?;
714 let mut versions = HashMap::new();
715
716 for line in content.lines() {
718 let line = line.trim();
719 if line.starts_with("go ") {
720 let parts: Vec<&str> = line.split_whitespace().collect();
721 if parts.len() >= 2 {
722 versions.insert("go".to_string(), parts[1].to_string());
723 }
724 break;
725 }
726 }
727
728 Ok(versions)
729 }
730
731 fn parse_vx_project_config(path: &PathBuf) -> Result<VxConfig> {
733 let content = fs::read_to_string(path).map_err(|e| VxError::Other {
734 message: format!("Failed to read .vx.toml: {}", e),
735 })?;
736
737 let project_config: crate::venv::ProjectConfig =
739 toml::from_str(&content).map_err(|e| VxError::Other {
740 message: format!("Failed to parse .vx.toml: {}", e),
741 })?;
742
743 let mut vx_config = VxConfig::default();
745
746 for (tool_name, version) in project_config.tools {
748 vx_config.tools.insert(
749 tool_name,
750 ToolConfig {
751 version: Some(version),
752 install_method: None,
753 registry: None,
754 custom_sources: None,
755 },
756 );
757 }
758
759 vx_config.defaults.auto_install = project_config.settings.auto_install;
761
762 Ok(vx_config)
763 }
764
765 fn parse_version_requirement(requirement: &str) -> String {
767 let cleaned = requirement
769 .trim_start_matches(">=")
770 .trim_start_matches("^")
771 .trim_start_matches("~")
772 .trim_start_matches("=")
773 .trim_start_matches(">");
774
775 if let Some(space_pos) = cleaned.find(' ') {
777 cleaned[..space_pos].to_string()
778 } else {
779 cleaned.to_string()
780 }
781 }
782}