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();
ensure_config_dir(&dir)?;
let path = config_path();
let yaml = serde_yaml::to_string(config)?;
write_config_file(&path, &yaml)?;
Ok(())
}
#[cfg(unix)]
fn ensure_config_dir(dir: &std::path::Path) -> Result<(), CliError> {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
if dir.exists() {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).map_err(|e| CliError::Config {
path: dir.to_path_buf(),
source: e,
})?;
return Ok(());
}
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.map_err(|e| CliError::Config {
path: dir.to_path_buf(),
source: e,
})
}
#[cfg(not(unix))]
fn ensure_config_dir(dir: &std::path::Path) -> Result<(), CliError> {
if dir.exists() {
return Ok(());
}
std::fs::create_dir_all(dir).map_err(|e| CliError::Config {
path: dir.to_path_buf(),
source: e,
})
}
#[cfg(unix)]
fn write_config_file(path: &std::path::Path, yaml: &str) -> Result<(), CliError> {
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| CliError::Config {
path: path.to_path_buf(),
source: e,
})?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| CliError::Config {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(yaml.as_bytes()).map_err(|e| CliError::Config {
path: path.to_path_buf(),
source: e,
})
}
#[cfg(not(unix))]
fn write_config_file(path: &std::path::Path, yaml: &str) -> Result<(), CliError> {
std::fs::write(path, yaml).map_err(|e| CliError::Config {
path: path.to_path_buf(),
source: e,
})
}
#[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(),
}
}
#[cfg(unix)]
#[test]
fn save_restricts_config_file_and_dir_permissions() {
use std::os::unix::fs::PermissionsExt;
let _guard = crate::test_support::env_guard();
let tmp = tempfile::TempDir::new().unwrap();
let prev_home = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let result = save(&sample_config());
let dir = config_dir();
let path = config_path();
let dir_mode = std::fs::metadata(&dir).map(|m| m.permissions().mode() & 0o777);
let file_mode = std::fs::metadata(&path).map(|m| m.permissions().mode() & 0o777);
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
result.unwrap();
assert_eq!(dir_mode.unwrap(), 0o700, "config dir must be owner-only (0700)");
assert_eq!(file_mode.unwrap(), 0o600, "config file must be owner-only (0600)");
}
#[cfg(unix)]
#[test]
fn save_tightens_preexisting_loose_permissions() {
use std::os::unix::fs::PermissionsExt;
let _guard = crate::test_support::env_guard();
let tmp = tempfile::TempDir::new().unwrap();
let prev_home = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let dir = config_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = config_path();
std::fs::write(&path, "{}").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let result = save(&sample_config());
let dir_mode = std::fs::metadata(&dir).map(|m| m.permissions().mode() & 0o777);
let file_mode = std::fs::metadata(&path).map(|m| m.permissions().mode() & 0o777);
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
result.unwrap();
assert_eq!(dir_mode.unwrap(), 0o700, "save must tighten an existing dir to 0700");
assert_eq!(file_mode.unwrap(), 0o600, "save must tighten an existing file to 0600");
}
#[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);
}
}