use std::net::SocketAddr;
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse config YAML: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("invalid AA_MODE value: '{raw}' (expected 'local' or 'remote')")]
InvalidMode {
raw: String,
},
#[error("invalid AAASM_GATEWAY_PORT value: '{raw}' (expected u16)")]
InvalidPort {
raw: String,
},
#[error("invalid AAASM_STORAGE_BACKEND value: '{raw}' (expected 'sqlite' or 'postgres')")]
InvalidStorageBackend {
raw: String,
},
#[error("invalid AAASM_RETENTION_COLD_ACTION value: '{raw}' (expected 'drop' or 'archive')")]
InvalidColdAction {
raw: String,
},
#[error("invalid {var} value: '{raw}' (expected non-negative integer)")]
InvalidUnsignedInt {
var: &'static str,
raw: String,
},
#[error("archive_url is required when cold_action is archive")]
ArchiveUrlRequired,
#[error("warm_days ({warm}) must be greater than hot_days ({hot})")]
WarmDaysNotGreaterThanHotDays {
hot: u32,
warm: u32,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum DeploymentMode {
#[default]
Local,
Remote,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct LocalModeConfig {
pub port: u16,
pub dashboard: bool,
pub storage_path: PathBuf,
}
impl Default for LocalModeConfig {
fn default() -> Self {
Self {
port: 7391,
dashboard: true,
storage_path: PathBuf::from("~/.aasm/local.db"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct TlsConfig {
pub cert_file: PathBuf,
pub key_file: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct RemoteModeConfig {
pub listen_addr: SocketAddr,
pub tls: Option<TlsConfig>,
pub database_url: Option<String>,
pub redis_url: Option<String>,
}
impl Default for RemoteModeConfig {
fn default() -> Self {
Self {
listen_addr: SocketAddr::from(([0, 0, 0, 0], 7391)),
tls: None,
database_url: None,
redis_url: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct AgentConnectConfig {
pub gateway_url: String,
pub api_key: Option<String>,
}
impl Default for AgentConnectConfig {
fn default() -> Self {
Self {
gateway_url: String::from("http://localhost:7391"),
api_key: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum ColdAction {
#[default]
Drop,
Archive,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct RetentionConfig {
pub hot_days: u32,
pub warm_days: u32,
pub cold_action: ColdAction,
pub archive_url: Option<String>,
pub schedule: String,
pub dry_run: bool,
}
impl Default for RetentionConfig {
fn default() -> Self {
Self {
hot_days: 30,
warm_days: 90,
cold_action: ColdAction::Drop,
archive_url: None,
schedule: String::from("0 3 * * *"),
dry_run: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct TimescaleConfig {
pub enabled: bool,
pub chunk_interval: String,
pub compression_policy: String,
}
impl Default for TimescaleConfig {
fn default() -> Self {
Self {
enabled: true,
chunk_interval: String::from("7 days"),
compression_policy: String::from("30 days"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct PostgresConfig {
pub database_url: Option<String>,
pub max_connections: u32,
pub min_connections: u32,
pub connect_timeout_secs: u64,
pub timescaledb: TimescaleConfig,
}
impl Default for PostgresConfig {
fn default() -> Self {
Self {
database_url: None,
max_connections: 20,
min_connections: 2,
connect_timeout_secs: 10,
timescaledb: TimescaleConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct SqliteConfig {
pub path: PathBuf,
pub journal_mode: String,
}
impl Default for SqliteConfig {
fn default() -> Self {
Self {
path: PathBuf::from("~/.aasm/local.db"),
journal_mode: String::from("wal"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct RedisConfig {
pub enabled: bool,
pub url: Option<String>,
pub policy_cache_ttl_secs: u64,
pub max_connections: u32,
}
impl Default for RedisConfig {
fn default() -> Self {
Self {
enabled: false,
url: None,
policy_cache_ttl_secs: 30,
max_connections: 10,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum StorageBackendType {
#[default]
Sqlite,
Postgres,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct StorageConfig {
pub backend: StorageBackendType,
pub sqlite: SqliteConfig,
pub postgres: PostgresConfig,
pub redis: RedisConfig,
pub retention: RetentionConfig,
#[cfg_attr(feature = "serde", serde(skip))]
pub(crate) backend_explicit: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct GatewayConfig {
pub mode: DeploymentMode,
pub local: LocalModeConfig,
pub remote: RemoteModeConfig,
pub agent: AgentConnectConfig,
pub storage: StorageConfig,
}
#[cfg(feature = "serde")]
impl GatewayConfig {
pub fn from_yaml_str(yaml: &str) -> Result<Self, ConfigError> {
let mut cfg: Self = serde_yaml::from_str(yaml)?;
cfg.storage.backend_explicit = yaml_has_storage_backend(yaml);
Ok(cfg)
}
pub fn load_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ConfigError> {
match std::fs::read_to_string(path) {
Ok(yaml) => Self::from_yaml_str(&yaml),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(err) => Err(ConfigError::Io(err)),
}
}
pub fn load_default_path() -> Result<Self, ConfigError> {
let Some(home) = dirs::home_dir() else {
return Ok(Self::default());
};
Self::load_from_path(home.join(".aasm").join("config.yaml"))
}
pub fn load() -> Result<Self, ConfigError> {
let mut cfg = Self::load_default_path()?;
cfg.expand_paths();
cfg.apply_env_overrides()?;
cfg.resolve_storage_backend();
cfg.validate()?;
Ok(cfg)
}
}
impl GatewayConfig {
pub fn expand_paths(&mut self) {
if let Some(home) = dirs::home_dir() {
self.expand_paths_in(&home);
}
}
pub(crate) fn expand_paths_in(&mut self, home: &std::path::Path) {
self.local.storage_path = expand_tilde(&self.local.storage_path, home);
self.storage.sqlite.path = expand_tilde(&self.storage.sqlite.path, home);
if let Some(tls) = &mut self.remote.tls {
tls.cert_file = expand_tilde(&tls.cert_file, home);
tls.key_file = expand_tilde(&tls.key_file, home);
}
}
}
fn expand_tilde(path: &std::path::Path, home: &std::path::Path) -> PathBuf {
match path.strip_prefix("~") {
Ok(stripped) => home.join(stripped),
Err(_) => path.to_path_buf(),
}
}
#[cfg(feature = "serde")]
fn yaml_has_storage_backend(yaml: &str) -> bool {
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
return false;
};
value
.get("storage")
.and_then(|storage| storage.get("backend"))
.is_some()
}
impl GatewayConfig {
pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
self.apply_env_overrides_with(|key| std::env::var(key).ok())
}
pub(crate) fn apply_env_overrides_with<F>(&mut self, get_env: F) -> Result<(), ConfigError>
where
F: Fn(&str) -> Option<String>,
{
if let Some(raw) = get_env("AA_MODE") {
self.mode = match raw.as_str() {
"local" => DeploymentMode::Local,
"remote" => DeploymentMode::Remote,
_ => return Err(ConfigError::InvalidMode { raw }),
};
}
if let Some(raw) = get_env("AAASM_GATEWAY_PORT") {
let port: u16 = raw.parse().map_err(|_| ConfigError::InvalidPort { raw: raw.clone() })?;
self.local.port = port;
self.remote.listen_addr.set_port(port);
}
if let Some(raw) = get_env("AAASM_STORAGE_BACKEND") {
self.storage.backend = match raw.as_str() {
"sqlite" => StorageBackendType::Sqlite,
"postgres" => StorageBackendType::Postgres,
_ => return Err(ConfigError::InvalidStorageBackend { raw }),
};
self.storage.backend_explicit = true;
}
if let Some(url) = get_env("AAASM_DATABASE_URL") {
self.storage.postgres.database_url = Some(url);
}
if let Some(url) = get_env("AAASM_REDIS_URL") {
self.storage.redis.url = Some(url);
}
if let Some(path) = get_env("AAASM_SQLITE_PATH") {
self.storage.sqlite.path = PathBuf::from(path);
}
if let Some(raw) = get_env("AAASM_RETENTION_HOT_DAYS") {
self.storage.retention.hot_days = raw.parse().map_err(|_| ConfigError::InvalidUnsignedInt {
var: "AAASM_RETENTION_HOT_DAYS",
raw: raw.clone(),
})?;
}
if let Some(raw) = get_env("AAASM_RETENTION_COLD_ACTION") {
self.storage.retention.cold_action = match raw.as_str() {
"drop" => ColdAction::Drop,
"archive" => ColdAction::Archive,
_ => return Err(ConfigError::InvalidColdAction { raw }),
};
}
let cert = get_env("AAASM_TLS_CERT");
let key = get_env("AAASM_TLS_KEY");
if cert.is_some() || key.is_some() {
let tls = self.remote.tls.get_or_insert(TlsConfig {
cert_file: PathBuf::new(),
key_file: PathBuf::new(),
});
if let Some(path) = cert {
tls.cert_file = PathBuf::from(path);
}
if let Some(path) = key {
tls.key_file = PathBuf::from(path);
}
}
Ok(())
}
}
impl GatewayConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
let r = &self.storage.retention;
if r.cold_action == ColdAction::Archive && r.archive_url.is_none() {
return Err(ConfigError::ArchiveUrlRequired);
}
if r.warm_days <= r.hot_days {
return Err(ConfigError::WarmDaysNotGreaterThanHotDays {
hot: r.hot_days,
warm: r.warm_days,
});
}
Ok(())
}
}
impl GatewayConfig {
pub fn resolve_storage_backend(&mut self) {
if self.storage.backend_explicit {
return;
}
self.storage.backend = match self.mode {
DeploymentMode::Local => StorageBackendType::Sqlite,
DeploymentMode::Remote => StorageBackendType::Postgres,
};
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deployment_mode_default_is_local() {
assert_eq!(DeploymentMode::default(), DeploymentMode::Local);
}
#[cfg(feature = "serde")]
#[test]
fn deployment_mode_yaml_round_trip_local() {
let mode: DeploymentMode = serde_yaml::from_str("local").unwrap();
assert_eq!(mode, DeploymentMode::Local);
}
#[cfg(feature = "serde")]
#[test]
fn deployment_mode_yaml_round_trip_remote() {
let mode: DeploymentMode = serde_yaml::from_str("remote").unwrap();
assert_eq!(mode, DeploymentMode::Remote);
}
#[cfg(feature = "serde")]
#[test]
fn deployment_mode_yaml_rejects_unknown_variant() {
let result: Result<DeploymentMode, _> = serde_yaml::from_str("foobar");
assert!(result.is_err(), "unknown variant should fail to deserialize");
}
#[test]
fn local_mode_config_default_matches_spec() {
let cfg = LocalModeConfig::default();
assert_eq!(cfg.port, 7391);
assert!(cfg.dashboard);
assert_eq!(cfg.storage_path, PathBuf::from("~/.aasm/local.db"));
}
#[cfg(feature = "serde")]
#[test]
fn local_mode_config_yaml_overrides_port_keeps_other_defaults() {
let cfg: LocalModeConfig = serde_yaml::from_str("port: 8080").unwrap();
assert_eq!(cfg.port, 8080);
assert!(cfg.dashboard, "dashboard should fall back to default");
assert_eq!(
cfg.storage_path,
PathBuf::from("~/.aasm/local.db"),
"storage_path should fall back to default"
);
}
#[test]
fn remote_mode_config_default_binds_all_interfaces() {
let cfg = RemoteModeConfig::default();
assert_eq!(cfg.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
assert!(cfg.tls.is_none(), "tls should be opt-in, never on by default");
assert!(cfg.database_url.is_none());
assert!(cfg.redis_url.is_none());
}
#[cfg(feature = "serde")]
#[test]
fn remote_mode_config_yaml_overrides_database_keeps_other_defaults() {
let yaml = r#"database_url: "postgres://aasm@db.internal/aasm""#;
let cfg: RemoteModeConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.database_url.as_deref(), Some("postgres://aasm@db.internal/aasm"));
assert_eq!(cfg.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
assert!(cfg.tls.is_none());
assert!(cfg.redis_url.is_none());
}
#[test]
fn agent_connect_config_default_points_at_localhost() {
let cfg = AgentConnectConfig::default();
assert_eq!(cfg.gateway_url, "http://localhost:7391");
assert!(cfg.api_key.is_none());
}
#[cfg(feature = "serde")]
#[test]
fn agent_connect_config_yaml_round_trip() {
let yaml = r#"
gateway_url: "https://cp.company.internal:7391"
api_key: "secret"
"#;
let cfg: AgentConnectConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.gateway_url, "https://cp.company.internal:7391");
assert_eq!(cfg.api_key.as_deref(), Some("secret"));
}
#[test]
fn gateway_config_default_uses_local_mode_and_documented_defaults() {
let cfg = GatewayConfig::default();
assert_eq!(cfg.mode, DeploymentMode::Local);
assert_eq!(cfg.local.port, 7391);
assert_eq!(cfg.remote.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
assert_eq!(cfg.agent.gateway_url, "http://localhost:7391");
}
#[cfg(feature = "serde")]
#[test]
fn gateway_config_from_yaml_str_parses_full_epic_example() {
let yaml = r#"
mode: remote
local:
port: 8080
dashboard: false
storage_path: ~/.aasm/dev.db
remote:
listen_addr: "127.0.0.1:7391"
tls:
cert_file: /etc/aasm/tls.crt
key_file: /etc/aasm/tls.key
database_url: "postgres://aasm@db.internal/aasm"
redis_url: "redis://redis.internal:6379"
agent:
gateway_url: "https://cp.company.internal:7391"
api_key: "secret"
"#;
let cfg = GatewayConfig::from_yaml_str(yaml).expect("valid YAML should parse");
assert_eq!(cfg.mode, DeploymentMode::Remote);
assert_eq!(cfg.local.port, 8080);
assert!(!cfg.local.dashboard);
assert_eq!(cfg.remote.listen_addr, SocketAddr::from(([127, 0, 0, 1], 7391)));
let tls = cfg.remote.tls.expect("tls present");
assert_eq!(tls.cert_file, PathBuf::from("/etc/aasm/tls.crt"));
assert_eq!(tls.key_file, PathBuf::from("/etc/aasm/tls.key"));
assert_eq!(
cfg.remote.database_url.as_deref(),
Some("postgres://aasm@db.internal/aasm")
);
assert_eq!(cfg.agent.api_key.as_deref(), Some("secret"));
}
#[cfg(feature = "serde")]
#[test]
fn gateway_config_from_yaml_str_empty_doc_returns_default() {
let cfg = GatewayConfig::from_yaml_str("{}").unwrap();
assert_eq!(cfg, GatewayConfig::default());
}
#[cfg(feature = "serde")]
#[test]
fn gateway_config_load_from_missing_path_returns_default() {
let missing = std::env::temp_dir().join("aasm-config-does-not-exist-AAASM-1691.yaml");
let _ = std::fs::remove_file(&missing);
let cfg = GatewayConfig::load_from_path(&missing).expect("missing file should not error");
assert_eq!(cfg, GatewayConfig::default());
}
#[cfg(feature = "serde")]
#[test]
fn gateway_config_load_from_existing_path_parses_yaml() {
let tmp_dir = std::env::temp_dir().join("aasm-config-AAASM-1691");
std::fs::create_dir_all(&tmp_dir).unwrap();
let path = tmp_dir.join("config.yaml");
std::fs::write(&path, "mode: remote\n").unwrap();
let cfg = GatewayConfig::load_from_path(&path).expect("existing file should parse");
assert_eq!(cfg.mode, DeploymentMode::Remote);
std::fs::remove_file(&path).ok();
}
#[test]
fn expand_paths_in_resolves_tilde_in_storage_path() {
let mut cfg = GatewayConfig::default();
let fake_home = PathBuf::from("/srv/dev/bryant");
cfg.expand_paths_in(&fake_home);
assert_eq!(cfg.local.storage_path, PathBuf::from("/srv/dev/bryant/.aasm/local.db"));
}
#[test]
fn expand_paths_in_resolves_tilde_in_tls_paths() {
let mut cfg = GatewayConfig::default();
cfg.remote.tls = Some(TlsConfig {
cert_file: PathBuf::from("~/secrets/tls.crt"),
key_file: PathBuf::from("~/secrets/tls.key"),
});
let fake_home = PathBuf::from("/srv/dev/bryant");
cfg.expand_paths_in(&fake_home);
let tls = cfg.remote.tls.unwrap();
assert_eq!(tls.cert_file, PathBuf::from("/srv/dev/bryant/secrets/tls.crt"));
assert_eq!(tls.key_file, PathBuf::from("/srv/dev/bryant/secrets/tls.key"));
}
#[test]
fn expand_paths_in_is_idempotent() {
let mut cfg = GatewayConfig::default();
let fake_home = PathBuf::from("/srv/dev/bryant");
cfg.expand_paths_in(&fake_home);
let after_first = cfg.local.storage_path.clone();
cfg.expand_paths_in(&fake_home);
assert_eq!(cfg.local.storage_path, after_first, "second call must be a no-op");
}
#[test]
fn expand_paths_in_leaves_absolute_paths_alone() {
let mut cfg = GatewayConfig::default();
cfg.local.storage_path = PathBuf::from("/var/lib/aasm.db");
cfg.expand_paths_in(&PathBuf::from("/srv/dev/bryant"));
assert_eq!(cfg.local.storage_path, PathBuf::from("/var/lib/aasm.db"));
}
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: std::collections::HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |key| map.get(key).cloned()
}
#[test]
fn apply_env_overrides_aa_mode_remote_promotes_mode() {
let mut cfg = GatewayConfig::default();
cfg.apply_env_overrides_with(env(&[("AA_MODE", "remote")])).unwrap();
assert_eq!(cfg.mode, DeploymentMode::Remote);
}
#[test]
fn apply_env_overrides_aa_mode_invalid_returns_named_error() {
let mut cfg = GatewayConfig::default();
let err = cfg
.apply_env_overrides_with(env(&[("AA_MODE", "foobar")]))
.expect_err("invalid value must return Err");
let msg = format!("{err}");
assert!(matches!(err, ConfigError::InvalidMode { ref raw } if raw == "foobar"));
assert!(msg.contains("AA_MODE"), "message should name the var: {msg}");
assert!(msg.contains("foobar"), "message should include the value: {msg}");
}
#[test]
fn apply_env_overrides_port_updates_local_and_remote() {
let mut cfg = GatewayConfig::default();
cfg.apply_env_overrides_with(env(&[("AAASM_GATEWAY_PORT", "8080")]))
.unwrap();
assert_eq!(cfg.local.port, 8080);
assert_eq!(cfg.remote.listen_addr.port(), 8080);
assert_eq!(cfg.remote.listen_addr.ip().to_string(), "0.0.0.0");
}
#[test]
fn apply_env_overrides_port_invalid_returns_named_error() {
let mut cfg = GatewayConfig::default();
let err = cfg
.apply_env_overrides_with(env(&[("AAASM_GATEWAY_PORT", "not-a-number")]))
.expect_err("non-numeric port must return Err");
let msg = format!("{err}");
assert!(matches!(err, ConfigError::InvalidPort { ref raw } if raw == "not-a-number"));
assert!(msg.contains("AAASM_GATEWAY_PORT"));
assert!(msg.contains("not-a-number"));
}
#[test]
fn apply_env_overrides_database_url_targets_storage_postgres() {
let mut cfg = GatewayConfig::default();
cfg.apply_env_overrides_with(env(&[("AAASM_DATABASE_URL", "postgres://aasm@db/aasm")]))
.unwrap();
assert_eq!(
cfg.storage.postgres.database_url.as_deref(),
Some("postgres://aasm@db/aasm"),
);
assert!(cfg.remote.database_url.is_none());
}
#[test]
fn apply_env_overrides_redis_url_targets_storage_redis() {
let mut cfg = GatewayConfig::default();
cfg.apply_env_overrides_with(env(&[("AAASM_REDIS_URL", "redis://redis:6379")]))
.unwrap();
assert_eq!(cfg.storage.redis.url.as_deref(), Some("redis://redis:6379"));
assert!(cfg.remote.redis_url.is_none());
}
#[test]
fn apply_env_overrides_tls_creates_config_when_yaml_omitted_it() {
let mut cfg = GatewayConfig::default();
assert!(cfg.remote.tls.is_none(), "precondition: TLS off by default");
cfg.apply_env_overrides_with(env(&[
("AAASM_TLS_CERT", "/etc/aasm/tls.crt"),
("AAASM_TLS_KEY", "/etc/aasm/tls.key"),
]))
.unwrap();
let tls = cfg.remote.tls.expect("TLS env vars must create TlsConfig");
assert_eq!(tls.cert_file, PathBuf::from("/etc/aasm/tls.crt"));
assert_eq!(tls.key_file, PathBuf::from("/etc/aasm/tls.key"));
}
#[test]
fn apply_env_overrides_tls_patches_existing_config_asymmetrically() {
let mut cfg = GatewayConfig::default();
cfg.remote.tls = Some(TlsConfig {
cert_file: PathBuf::from("/old/tls.crt"),
key_file: PathBuf::from("/old/tls.key"),
});
cfg.apply_env_overrides_with(env(&[("AAASM_TLS_CERT", "/new/tls.crt")]))
.unwrap();
let tls = cfg.remote.tls.expect("tls preserved");
assert_eq!(tls.cert_file, PathBuf::from("/new/tls.crt"));
assert_eq!(tls.key_file, PathBuf::from("/old/tls.key"), "key untouched");
}
#[cfg(feature = "serde")]
#[test]
fn empty_yaml_hydrates_storage_defaults() {
let cfg = GatewayConfig::from_yaml_str("{}").expect("empty YAML must parse");
let s = &cfg.storage;
assert_eq!(s.backend, StorageBackendType::Sqlite, "default backend");
assert_eq!(
s.sqlite.path,
PathBuf::from("~/.aasm/local.db"),
"sqlite path un-expanded by default",
);
assert_eq!(s.sqlite.journal_mode, "wal");
assert!(s.postgres.database_url.is_none(), "postgres url unset");
assert_eq!(s.postgres.max_connections, 20);
assert_eq!(s.postgres.min_connections, 2);
assert_eq!(s.postgres.connect_timeout_secs, 10);
assert!(s.postgres.timescaledb.enabled);
assert_eq!(s.postgres.timescaledb.chunk_interval, "7 days");
assert_eq!(s.postgres.timescaledb.compression_policy, "30 days");
assert!(!s.redis.enabled, "redis opt-in");
assert!(s.redis.url.is_none());
assert_eq!(s.redis.policy_cache_ttl_secs, 30);
assert_eq!(s.redis.max_connections, 10);
assert_eq!(s.retention.hot_days, 30);
assert_eq!(s.retention.warm_days, 90);
assert_eq!(s.retention.cold_action, ColdAction::Drop);
assert!(s.retention.archive_url.is_none());
assert_eq!(s.retention.schedule, "0 3 * * *");
assert!(!s.retention.dry_run);
}
#[test]
fn apply_env_overrides_storage_matrix() {
let mut cfg = GatewayConfig::default();
cfg.apply_env_overrides_with(env(&[
("AAASM_STORAGE_BACKEND", "postgres"),
("AAASM_DATABASE_URL", "postgres://aasm@prod/aasm"),
("AAASM_REDIS_URL", "redis://prod:6379"),
("AAASM_SQLITE_PATH", "/var/lib/aasm.db"),
("AAASM_RETENTION_HOT_DAYS", "7"),
("AAASM_RETENTION_COLD_ACTION", "archive"),
]))
.unwrap();
assert_eq!(cfg.storage.backend, StorageBackendType::Postgres);
assert_eq!(
cfg.storage.postgres.database_url.as_deref(),
Some("postgres://aasm@prod/aasm"),
);
assert_eq!(cfg.storage.redis.url.as_deref(), Some("redis://prod:6379"));
assert_eq!(cfg.storage.sqlite.path, PathBuf::from("/var/lib/aasm.db"));
assert_eq!(cfg.storage.retention.hot_days, 7);
assert_eq!(cfg.storage.retention.cold_action, ColdAction::Archive);
}
#[test]
fn apply_env_overrides_storage_backend_invalid_returns_named_error() {
let mut cfg = GatewayConfig::default();
let err = cfg
.apply_env_overrides_with(env(&[("AAASM_STORAGE_BACKEND", "mysql")]))
.expect_err("unsupported backend must return Err");
let msg = format!("{err}");
assert!(matches!(err, ConfigError::InvalidStorageBackend { ref raw } if raw == "mysql"));
assert!(msg.contains("AAASM_STORAGE_BACKEND"));
assert!(msg.contains("mysql"));
assert!(msg.contains("sqlite") && msg.contains("postgres"));
}
#[test]
fn apply_env_overrides_cold_action_invalid_returns_named_error() {
let mut cfg = GatewayConfig::default();
let err = cfg
.apply_env_overrides_with(env(&[("AAASM_RETENTION_COLD_ACTION", "tombstone")]))
.expect_err("unsupported cold_action must return Err");
assert!(matches!(err, ConfigError::InvalidColdAction { ref raw } if raw == "tombstone"));
}
#[test]
fn apply_env_overrides_retention_hot_days_invalid_returns_named_error() {
let mut cfg = GatewayConfig::default();
let err = cfg
.apply_env_overrides_with(env(&[("AAASM_RETENTION_HOT_DAYS", "thirty")]))
.expect_err("non-numeric hot_days must return Err");
assert!(matches!(
err,
ConfigError::InvalidUnsignedInt {
var: "AAASM_RETENTION_HOT_DAYS",
ref raw,
} if raw == "thirty"
));
}
#[test]
fn validate_archive_without_url_fails_with_documented_message() {
let mut cfg = GatewayConfig::default();
cfg.storage.retention.cold_action = ColdAction::Archive;
cfg.storage.retention.archive_url = None;
let err = cfg
.validate()
.expect_err("cold_action = Archive without archive_url must fail");
assert!(matches!(err, ConfigError::ArchiveUrlRequired));
assert_eq!(format!("{err}"), "archive_url is required when cold_action is archive",);
}
#[test]
fn validate_archive_with_url_passes() {
let mut cfg = GatewayConfig::default();
cfg.storage.retention.cold_action = ColdAction::Archive;
cfg.storage.retention.archive_url = Some("s3://aasm-archive/".into());
cfg.validate().expect("archive + url must validate");
}
#[test]
fn validate_warm_days_must_be_greater_than_hot_days() {
let mut cfg = GatewayConfig::default();
cfg.storage.retention.hot_days = 60;
cfg.storage.retention.warm_days = 30; let err = cfg.validate().expect_err("warm_days <= hot_days must fail");
assert!(matches!(
err,
ConfigError::WarmDaysNotGreaterThanHotDays { hot: 60, warm: 30 }
));
assert_eq!(format!("{err}"), "warm_days (30) must be greater than hot_days (60)",);
}
#[test]
fn validate_warm_days_equal_to_hot_days_also_fails() {
let mut cfg = GatewayConfig::default();
cfg.storage.retention.hot_days = 30;
cfg.storage.retention.warm_days = 30; let err = cfg
.validate()
.expect_err("warm_days == hot_days must fail (strict inequality)");
assert!(matches!(
err,
ConfigError::WarmDaysNotGreaterThanHotDays { hot: 30, warm: 30 }
));
}
#[test]
fn resolve_storage_backend_defaults_to_sqlite_in_local_mode() {
let mut cfg = GatewayConfig {
mode: DeploymentMode::Local,
..GatewayConfig::default()
};
cfg.resolve_storage_backend();
assert_eq!(cfg.storage.backend, StorageBackendType::Sqlite);
}
#[test]
fn resolve_storage_backend_defaults_to_postgres_in_remote_mode() {
let mut cfg = GatewayConfig {
mode: DeploymentMode::Remote,
..GatewayConfig::default()
};
cfg.resolve_storage_backend();
assert_eq!(cfg.storage.backend, StorageBackendType::Postgres);
}
#[test]
fn resolve_storage_backend_respects_explicit_choice() {
let mut cfg = GatewayConfig {
mode: DeploymentMode::Remote,
..GatewayConfig::default()
};
cfg.storage.backend = StorageBackendType::Sqlite;
cfg.storage.backend_explicit = true;
cfg.resolve_storage_backend();
assert_eq!(cfg.storage.backend, StorageBackendType::Sqlite);
}
#[test]
fn expand_paths_in_resolves_tilde_in_storage_sqlite_path() {
let mut cfg = GatewayConfig::default();
assert_eq!(cfg.storage.sqlite.path, PathBuf::from("~/.aasm/local.db"));
let fake_home = PathBuf::from("/srv/dev/bryant");
cfg.expand_paths_in(&fake_home);
assert_eq!(cfg.storage.sqlite.path, PathBuf::from("/srv/dev/bryant/.aasm/local.db"),);
}
}