use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; use std::fs; use std::path::PathBuf; use crate::output::OutputFormat; /// CLI configuration stored in user's home directory #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CliConfig { /// Current active profile name #[serde( default = "default_profile_name", rename = "profile", alias = "current_profile" )] pub current_profile: String, /// Named profiles (like SSH hosts) #[serde(default)] pub profiles: HashMap, /// Output format (table, json, yaml) #[serde( default = "default_format", rename = "format", alias = "default_output_format" )] pub format: String, } fn default_profile_name() -> String { "default".to_string() } fn default_format() -> String { "table".to_string() } /// A named profile for connecting to an Attune server #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { /// API endpoint URL pub api_url: String, /// Authentication token #[serde(skip_serializing_if = "Option::is_none")] pub auth_token: Option, /// Refresh token #[serde(skip_serializing_if = "Option::is_none")] pub refresh_token: Option, /// Output format override for this profile (deprecated — ignored, kept for deserialization compat) #[serde(skip_serializing)] #[allow(dead_code)] pub output_format: Option, /// Optional description #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } impl Default for CliConfig { fn default() -> Self { let mut profiles = HashMap::new(); profiles.insert( "default".to_string(), Profile { api_url: "http://localhost:8080".to_string(), auth_token: None, refresh_token: None, output_format: None, description: Some("Default local server".to_string()), }, ); Self { current_profile: "default".to_string(), profiles, format: default_format(), } } } impl CliConfig { /// Get the configuration file path pub fn config_path() -> Result { // Respect XDG_CONFIG_HOME environment variable (for tests and user overrides) let config_dir = if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") { PathBuf::from(xdg_config) } else { dirs::config_dir().context("Failed to determine config directory")? }; let attune_config_dir = config_dir.join("attune"); fs::create_dir_all(&attune_config_dir).context("Failed to create config directory")?; Ok(attune_config_dir.join("config.yaml")) } /// Load configuration from file, or create default if not exists pub fn load() -> Result { let path = Self::config_path()?; if !path.exists() { let config = Self::default(); config.save()?; return Ok(config); } let content = fs::read_to_string(&path).context("Failed to read config file")?; let config: Self = serde_yaml_ng::from_str(&content).context("Failed to parse config file")?; Ok(config) } /// Save configuration to file pub fn save(&self) -> Result<()> { let path = Self::config_path()?; let content = serde_yaml_ng::to_string(self).context("Failed to serialize config")?; fs::write(&path, content).context("Failed to write config file")?; Ok(()) } /// Get the current active profile pub fn current_profile(&self) -> Result<&Profile> { self.profiles .get(&self.current_profile) .context(format!("Profile '{}' not found", self.current_profile)) } /// Get a mutable reference to the current profile pub fn current_profile_mut(&mut self) -> Result<&mut Profile> { let profile_name = self.current_profile.clone(); self.profiles .get_mut(&profile_name) .context(format!("Profile '{}' not found", profile_name)) } /// Get a profile by name pub fn get_profile(&self, name: &str) -> Option<&Profile> { self.profiles.get(name) } /// Switch to a different profile pub fn switch_profile(&mut self, name: String) -> Result<()> { if !self.profiles.contains_key(&name) { anyhow::bail!("Profile '{}' does not exist", name); } self.current_profile = name; self.save() } /// Add or update a profile pub fn set_profile(&mut self, name: String, profile: Profile) -> Result<()> { self.profiles.insert(name, profile); self.save() } /// Remove a profile pub fn remove_profile(&mut self, name: &str) -> Result<()> { if self.current_profile == name { anyhow::bail!("Cannot remove active profile"); } if name == "default" { anyhow::bail!("Cannot remove the default profile"); } self.profiles.remove(name); self.save() } /// List all profile names pub fn list_profiles(&self) -> Vec { let mut names: Vec = self.profiles.keys().cloned().collect(); names.sort(); names } /// Set the API URL for the current profile /// /// Part of configuration management API - used by `attune config set api-url` command #[allow(dead_code)] pub fn set_api_url(&mut self, url: String) -> Result<()> { let profile = self.current_profile_mut()?; profile.api_url = url; self.save() } /// Set authentication tokens for the current profile pub fn set_auth(&mut self, access_token: String, refresh_token: String) -> Result<()> { let profile = self.current_profile_mut()?; profile.auth_token = Some(access_token); profile.refresh_token = Some(refresh_token); self.save() } /// Clear authentication tokens for the current profile pub fn clear_auth(&mut self) -> Result<()> { let profile = self.current_profile_mut()?; profile.auth_token = None; profile.refresh_token = None; self.save() } /// Resolve the effective output format. /// /// Priority (highest to lowest): /// 1. Explicit CLI flag (`--json`, `--yaml`, `--output`) /// 2. Config `format` field /// /// The `cli_flag` parameter should be `None` when the user did not pass an /// explicit flag (i.e. clap returned the default value `table` *without* /// the user typing it). Callers should pass `Some(format)` only when the /// user actually supplied the flag. pub fn effective_format(&self, cli_override: Option) -> OutputFormat { if let Some(fmt) = cli_override { return fmt; } // Fall back to config value match self.format.to_lowercase().as_str() { "json" => OutputFormat::Json, "yaml" => OutputFormat::Yaml, _ => OutputFormat::Table, } } /// Set a configuration value by key pub fn set_value(&mut self, key: &str, value: String) -> Result<()> { match key { "api_url" => { let profile = self.current_profile_mut()?; profile.api_url = value; } "format" | "output_format" | "default_output_format" => { // Validate the value match value.to_lowercase().as_str() { "table" | "json" | "yaml" => {} _ => anyhow::bail!( "Invalid format '{}'. Must be one of: table, json, yaml", value ), } self.format = value.to_lowercase(); } "profile" | "current_profile" => { self.switch_profile(value)?; return Ok(()); } _ => anyhow::bail!("Unknown config key: {}", key), } self.save() } /// Get a configuration value by key pub fn get_value(&self, key: &str) -> Result { match key { "api_url" => { let profile = self.current_profile()?; Ok(profile.api_url.clone()) } "format" | "output_format" | "default_output_format" => Ok(self.format.clone()), "profile" | "current_profile" => Ok(self.current_profile.clone()), "auth_token" => { let profile = self.current_profile()?; Ok(profile .auth_token .as_ref() .map(|_| "***") .unwrap_or("(not set)") .to_string()) } "refresh_token" => { let profile = self.current_profile()?; Ok(profile .refresh_token .as_ref() .map(|_| "***") .unwrap_or("(not set)") .to_string()) } _ => anyhow::bail!("Unknown config key: {}", key), } } /// List all configuration keys and values for current profile pub fn list_all(&self) -> Vec<(String, String)> { let profile = match self.current_profile() { Ok(p) => p, Err(_) => return vec![], }; vec![ ("profile".to_string(), self.current_profile.clone()), ("format".to_string(), self.format.clone()), ("api_url".to_string(), profile.api_url.clone()), ( "auth_token".to_string(), profile .auth_token .as_ref() .map(|_| "***") .unwrap_or("(not set)") .to_string(), ), ( "refresh_token".to_string(), profile .refresh_token .as_ref() .map(|_| "***") .unwrap_or("(not set)") .to_string(), ), ] } /// Load configuration with optional profile override (without saving) /// /// Used by `--profile` flag to temporarily use a different profile pub fn load_with_profile(profile_name: Option<&str>) -> Result { let mut config = Self::load()?; if let Some(name) = profile_name { // Temporarily switch profile without saving if !config.profiles.contains_key(name) { anyhow::bail!("Profile '{}' does not exist", name); } config.current_profile = name.to_string(); } Ok(config) } /// Get the effective API URL (from override, current profile, or default) pub fn effective_api_url(&self, override_url: &Option) -> String { if let Some(url) = override_url { return url.clone(); } if let Ok(profile) = self.current_profile() { profile.api_url.clone() } else { "http://localhost:8080".to_string() } } /// Get API URL for current profile (without override) #[allow(unused)] pub fn api_url(&self) -> Result { let profile = self.current_profile()?; Ok(profile.api_url.clone()) } /// Get auth token for current profile pub fn auth_token(&self) -> Result> { let profile = self.current_profile()?; Ok(profile.auth_token.clone()) } /// Get refresh token for current profile pub fn refresh_token(&self) -> Result> { let profile = self.current_profile()?; Ok(profile.refresh_token.clone()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_config() { let config = CliConfig::default(); assert_eq!(config.current_profile, "default"); assert_eq!(config.format, "table"); assert!(config.profiles.contains_key("default")); let profile = config.current_profile().unwrap(); assert_eq!(profile.api_url, "http://localhost:8080"); assert!(profile.auth_token.is_none()); assert!(profile.refresh_token.is_none()); } #[test] fn test_effective_api_url() { let config = CliConfig::default(); // No override assert_eq!(config.effective_api_url(&None), "http://localhost:8080"); // With override let override_url = Some("http://example.com".to_string()); assert_eq!( config.effective_api_url(&override_url), "http://example.com" ); } #[test] fn test_effective_format_defaults_to_config() { let config = CliConfig { format: "json".to_string(), ..Default::default() }; // No CLI override → uses config assert_eq!(config.effective_format(None), OutputFormat::Json); } #[test] fn test_effective_format_cli_overrides_config() { let config = CliConfig { format: "json".to_string(), ..Default::default() }; // CLI override wins assert_eq!( config.effective_format(Some(OutputFormat::Yaml)), OutputFormat::Yaml ); } #[test] fn test_effective_format_default_table() { let config = CliConfig::default(); assert_eq!(config.effective_format(None), OutputFormat::Table); } #[test] fn test_profile_management() { let mut config = CliConfig::default(); // Add a new profile let staging_profile = Profile { api_url: "https://staging.example.com".to_string(), auth_token: None, refresh_token: None, output_format: None, description: Some("Staging environment".to_string()), }; config .set_profile("staging".to_string(), staging_profile) .unwrap(); // List profiles let profiles = config.list_profiles(); assert!(profiles.contains(&"default".to_string())); assert!(profiles.contains(&"staging".to_string())); // Switch to staging config.switch_profile("staging".to_string()).unwrap(); assert_eq!(config.current_profile, "staging"); let profile = config.current_profile().unwrap(); assert_eq!(profile.api_url, "https://staging.example.com"); } #[test] fn test_cannot_remove_default_profile() { let mut config = CliConfig::default(); let result = config.remove_profile("default"); assert!(result.is_err()); } #[test] fn test_cannot_remove_active_profile() { let mut config = CliConfig::default(); let test_profile = Profile { api_url: "http://test.com".to_string(), auth_token: None, refresh_token: None, output_format: None, description: None, }; config .set_profile("test".to_string(), test_profile) .unwrap(); config.switch_profile("test".to_string()).unwrap(); let result = config.remove_profile("test"); assert!(result.is_err()); } #[test] fn test_get_set_value() { let mut config = CliConfig::default(); assert_eq!( config.get_value("api_url").unwrap(), "http://localhost:8080" ); assert_eq!(config.get_value("format").unwrap(), "table"); // Set API URL for current profile config .set_value("api_url", "http://test.com".to_string()) .unwrap(); assert_eq!(config.get_value("api_url").unwrap(), "http://test.com"); // Set format config.set_value("format", "json".to_string()).unwrap(); assert_eq!(config.get_value("format").unwrap(), "json"); } #[test] fn test_set_value_validates_format() { let mut config = CliConfig::default(); // Valid values assert!(config.set_value("format", "table".to_string()).is_ok()); assert!(config.set_value("format", "json".to_string()).is_ok()); assert!(config.set_value("format", "yaml".to_string()).is_ok()); assert!(config.set_value("format", "JSON".to_string()).is_ok()); // case-insensitive // Invalid value assert!(config.set_value("format", "xml".to_string()).is_err()); } #[test] fn test_backward_compat_aliases() { let mut config = CliConfig::default(); // Old key names should still work for get/set assert!(config .set_value("output_format", "json".to_string()) .is_ok()); assert_eq!(config.get_value("output_format").unwrap(), "json"); assert_eq!(config.get_value("format").unwrap(), "json"); assert!(config .set_value("default_output_format", "yaml".to_string()) .is_ok()); assert_eq!(config.get_value("default_output_format").unwrap(), "yaml"); assert_eq!(config.get_value("format").unwrap(), "yaml"); } #[test] fn test_deserialize_legacy_default_output_format() { let yaml = r#" profile: default default_output_format: json profiles: default: api_url: http://localhost:8080 "#; let config: CliConfig = serde_yaml_ng::from_str(yaml).unwrap(); assert_eq!(config.format, "json"); } }