use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::CliError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextConfig {
pub api_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardConfig {
#[serde(default = "DashboardConfig::default_port")]
pub port: u16,
#[serde(default)]
pub auto_open: bool,
}
impl DashboardConfig {
fn default_port() -> u16 {
3000
}
}
impl Default for DashboardConfig {
fn default() -> Self {
Self {
port: 3000,
auto_open: false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CliConfig {
#[serde(default)]
pub default_context: Option<String>,
#[serde(default)]
pub contexts: BTreeMap<String, ContextConfig>,
#[serde(default)]
pub dashboard: DashboardConfig,
}
pub fn resolve_dashboard_port(config: &CliConfig, port_flag: Option<u16>) -> u16 {
if let Ok(val) = std::env::var("AASM_DASHBOARD_PORT") {
if let Ok(p) = val.parse::<u16>() {
return p;
}
}
port_flag.unwrap_or(config.dashboard.port)
}
pub fn config_dir() -> PathBuf {
dirs::home_dir().expect("cannot determine home directory").join(".aa")
}
pub fn config_path() -> PathBuf {
config_dir().join("config.yaml")
}
pub fn load() -> Result<CliConfig, CliError> {
let path = config_path();
if !path.exists() {
return Ok(CliConfig::default());
}
let contents = std::fs::read_to_string(&path).map_err(|e| CliError::Config {
path: path.clone(),
source: e,
})?;
let config: CliConfig = serde_yaml::from_str(&contents)?;
Ok(config)
}
pub fn save(config: &CliConfig) -> Result<(), CliError> {
let dir = config_dir();
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| CliError::Config {
path: dir.clone(),
source: e,
})?;
}
let path = config_path();
let yaml = serde_yaml::to_string(config)?;
std::fs::write(&path, yaml).map_err(|e| CliError::Config { path, source: e })?;
Ok(())
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct ResolvedContext {
pub name: Option<String>,
pub api_url: String,
pub api_key: Option<String>,
}
pub fn resolve_context(
config: &CliConfig,
context_flag: Option<&str>,
api_url_flag: Option<&str>,
api_key_flag: Option<&str>,
) -> Result<ResolvedContext, CliError> {
let default_url = "http://localhost:8080";
if let Some(url) = api_url_flag {
return Ok(ResolvedContext {
name: None,
api_url: url.to_string(),
api_key: api_key_flag.map(String::from),
});
}
let context_name = context_flag
.map(String::from)
.or_else(|| config.default_context.clone());
if let Some(ref name) = context_name {
let ctx = config
.contexts
.get(name)
.ok_or_else(|| CliError::ContextNotFound(name.clone()))?;
return Ok(ResolvedContext {
name: Some(name.clone()),
api_url: ctx.api_url.clone(),
api_key: api_key_flag.map(String::from).or_else(|| ctx.api_key.clone()),
});
}
Ok(ResolvedContext {
name: None,
api_url: default_url.to_string(),
api_key: api_key_flag.map(String::from),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> CliConfig {
let mut contexts = BTreeMap::new();
contexts.insert(
"production".to_string(),
ContextConfig {
api_url: "https://api.example.com".to_string(),
api_key: Some("prod-key".to_string()),
},
);
contexts.insert(
"staging".to_string(),
ContextConfig {
api_url: "https://staging.example.com".to_string(),
api_key: None,
},
);
CliConfig {
default_context: Some("production".to_string()),
contexts,
dashboard: DashboardConfig::default(),
}
}
#[test]
fn config_round_trip_yaml() {
let cfg = sample_config();
let yaml = serde_yaml::to_string(&cfg).unwrap();
let parsed: CliConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.default_context, cfg.default_context);
assert_eq!(parsed.contexts.len(), 2);
}
#[test]
fn empty_config_deserializes() {
let yaml = "{}";
let cfg: CliConfig = serde_yaml::from_str(yaml).unwrap();
assert!(cfg.default_context.is_none());
assert!(cfg.contexts.is_empty());
}
#[test]
fn resolve_uses_default_context() {
let cfg = sample_config();
let resolved = resolve_context(&cfg, None, None, None).unwrap();
assert_eq!(resolved.name.as_deref(), Some("production"));
assert_eq!(resolved.api_url, "https://api.example.com");
assert_eq!(resolved.api_key.as_deref(), Some("prod-key"));
}
#[test]
fn resolve_explicit_context_overrides_default() {
let cfg = sample_config();
let resolved = resolve_context(&cfg, Some("staging"), None, None).unwrap();
assert_eq!(resolved.name.as_deref(), Some("staging"));
assert_eq!(resolved.api_url, "https://staging.example.com");
assert!(resolved.api_key.is_none());
}
#[test]
fn resolve_api_url_flag_overrides_everything() {
let cfg = sample_config();
let resolved = resolve_context(&cfg, Some("production"), Some("http://custom:9090"), None).unwrap();
assert!(resolved.name.is_none());
assert_eq!(resolved.api_url, "http://custom:9090");
}
#[test]
fn resolve_api_key_flag_overrides_config_key() {
let cfg = sample_config();
let resolved = resolve_context(&cfg, Some("production"), None, Some("override-key")).unwrap();
assert_eq!(resolved.api_key.as_deref(), Some("override-key"));
}
#[test]
fn resolve_unknown_context_returns_error() {
let cfg = sample_config();
let result = resolve_context(&cfg, Some("nonexistent"), None, None);
assert!(result.is_err());
}
#[test]
fn resolve_no_config_uses_default_url() {
let cfg = CliConfig {
default_context: None,
contexts: BTreeMap::new(),
dashboard: DashboardConfig::default(),
};
let resolved = resolve_context(&cfg, None, None, None).unwrap();
assert_eq!(resolved.api_url, "http://localhost:8080");
assert!(resolved.name.is_none());
}
#[test]
fn dashboard_config_defaults() {
let cfg: DashboardConfig = serde_yaml::from_str("{}").unwrap();
assert_eq!(cfg.port, 3000);
assert!(!cfg.auto_open);
}
#[test]
fn dashboard_config_round_trip_yaml() {
let yaml = "port: 4000\nauto_open: true\n";
let cfg: DashboardConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.port, 4000);
assert!(cfg.auto_open);
let roundtripped = serde_yaml::to_string(&cfg).unwrap();
let cfg2: DashboardConfig = serde_yaml::from_str(&roundtripped).unwrap();
assert_eq!(cfg2.port, 4000);
assert!(cfg2.auto_open);
}
#[test]
fn resolve_dashboard_port_env_overrides_all() {
let _guard = crate::test_support::env_guard();
std::env::set_var("AASM_DASHBOARD_PORT", "9999");
let port = resolve_dashboard_port(&CliConfig::default(), Some(5000));
std::env::remove_var("AASM_DASHBOARD_PORT");
assert_eq!(port, 9999);
}
#[test]
fn resolve_dashboard_port_flag_beats_config() {
let _guard = crate::test_support::env_guard();
std::env::remove_var("AASM_DASHBOARD_PORT");
assert_eq!(resolve_dashboard_port(&CliConfig::default(), Some(4321)), 4321);
}
#[test]
fn resolve_dashboard_port_uses_config_default() {
let _guard = crate::test_support::env_guard();
std::env::remove_var("AASM_DASHBOARD_PORT");
assert_eq!(resolve_dashboard_port(&CliConfig::default(), None), 3000);
}
}