Skip to main content

aa_core/
config.rs

1//! Gateway deployment-mode configuration types (Epic 17, AAASM-1568).
2//!
3//! Configuration is loaded once at startup and threaded through the
4//! application. This module is the **foundation** of Epic 17 — every
5//! other story in the Epic depends on these types to decide whether
6//! the gateway should boot in local-dev or remote-control-plane mode.
7
8use std::net::SocketAddr;
9use std::path::PathBuf;
10
11/// Errors that can occur while loading or parsing a `GatewayConfig`.
12///
13/// All variants carry enough context to be surfaced verbatim to an
14/// operator running `aasm start`; `Display` implementations come
15/// from `thiserror` so they format cleanly into log lines and CLI
16/// stderr.
17#[derive(Debug, thiserror::Error)]
18pub enum ConfigError {
19    /// Failed to read the YAML config file (permission denied, or
20    /// other filesystem error other than "file not found").
21    #[error("failed to read config file: {0}")]
22    Io(#[from] std::io::Error),
23    /// The YAML payload could not be deserialised into a `GatewayConfig`.
24    #[error("failed to parse config YAML: {0}")]
25    Yaml(#[from] serde_yaml::Error),
26    /// `AA_MODE` was set to something other than `local` or `remote`.
27    #[error("invalid AA_MODE value: '{raw}' (expected 'local' or 'remote')")]
28    InvalidMode {
29        /// The unrecognised value as read from the environment.
30        raw: String,
31    },
32    /// `AAASM_GATEWAY_PORT` was not a valid `u16`.
33    #[error("invalid AAASM_GATEWAY_PORT value: '{raw}' (expected u16)")]
34    InvalidPort {
35        /// The unrecognised value as read from the environment.
36        raw: String,
37    },
38    /// `AAASM_STORAGE_BACKEND` was set to something other than `sqlite`
39    /// or `postgres`.
40    #[error("invalid AAASM_STORAGE_BACKEND value: '{raw}' (expected 'sqlite' or 'postgres')")]
41    InvalidStorageBackend {
42        /// The unrecognised value as read from the environment.
43        raw: String,
44    },
45    /// `AAASM_RETENTION_COLD_ACTION` was set to something other than
46    /// `drop` or `archive`.
47    #[error("invalid AAASM_RETENTION_COLD_ACTION value: '{raw}' (expected 'drop' or 'archive')")]
48    InvalidColdAction {
49        /// The unrecognised value as read from the environment.
50        raw: String,
51    },
52    /// A retention env var (`AAASM_RETENTION_HOT_DAYS`,
53    /// `AAASM_RETENTION_WARM_DAYS`, …) was not a non-negative integer.
54    #[error("invalid {var} value: '{raw}' (expected non-negative integer)")]
55    InvalidUnsignedInt {
56        /// The env-var name, surfaced verbatim in the message so an
57        /// operator scanning startup logs can `grep` for the variable.
58        var: &'static str,
59        /// The unrecognised value as read from the environment.
60        raw: String,
61    },
62    /// `storage.retention.cold_action = archive` was selected but no
63    /// `archive_url` was supplied (in YAML or via env var).
64    #[error("archive_url is required when cold_action is archive")]
65    ArchiveUrlRequired,
66    /// `storage.retention.warm_days` was less than or equal to
67    /// `hot_days` — the warm tier must extend past the hot tier.
68    #[error("warm_days ({warm}) must be greater than hot_days ({hot})")]
69    WarmDaysNotGreaterThanHotDays {
70        /// The configured `hot_days` value (for the operator-facing message).
71        hot: u32,
72        /// The configured `warm_days` value.
73        warm: u32,
74    },
75}
76
77/// Which deployment topology the gateway should boot into.
78///
79/// Selected at startup from a combination of YAML config, environment
80/// variables, and CLI flags. See [Epic 17 spec][epic] for the full
81/// precedence rules.
82///
83/// [epic]: https://lightning-dust-mite.atlassian.net/browse/AAASM-1568
84#[derive(Debug, Clone, Default, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
86#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
87pub enum DeploymentMode {
88    /// Lightweight in-process control plane on `localhost:7391`.
89    ///
90    /// Zero-config developer experience: SQLite storage, embedded
91    /// dashboard, no network connectivity required.
92    #[default]
93    Local,
94    /// Independently-deployed control plane reached over the network.
95    ///
96    /// Agents on multiple machines all register against one gateway.
97    /// PostgreSQL storage, TLS required for production.
98    Remote,
99}
100
101/// Configuration for the in-process **local-dev** control plane.
102///
103/// All fields default to the zero-config developer values documented
104/// in the Epic 17 spec. `storage_path` is stored raw; `~` is expanded
105/// later by `GatewayConfig::expand_paths()` (added in AAASM-1691).
106#[derive(Debug, Clone, PartialEq, Eq)]
107#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
108#[cfg_attr(feature = "serde", serde(default))]
109pub struct LocalModeConfig {
110    /// TCP port the local gateway listens on. Default: `7391`.
111    pub port: u16,
112    /// Whether to serve the dashboard SPA at the same address. Default: `true`.
113    pub dashboard: bool,
114    /// SQLite database path. Default: `~/.aasm/local.db` (un-expanded).
115    pub storage_path: PathBuf,
116}
117
118impl Default for LocalModeConfig {
119    fn default() -> Self {
120        Self {
121            port: 7391,
122            dashboard: true,
123            storage_path: PathBuf::from("~/.aasm/local.db"),
124        }
125    }
126}
127
128/// TLS material for the remote control plane listener.
129///
130/// `None` on `RemoteModeConfig::tls` disables TLS (development only).
131/// Production deployments must supply both files; paths are stored raw
132/// and expanded by `GatewayConfig::expand_paths()` (AAASM-1691).
133#[derive(Debug, Clone, PartialEq, Eq)]
134#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
135pub struct TlsConfig {
136    /// PEM-encoded certificate chain.
137    pub cert_file: PathBuf,
138    /// PEM-encoded private key matching `cert_file`.
139    pub key_file: PathBuf,
140}
141
142/// Configuration for the network-reachable **remote** control plane.
143///
144/// Defaults bind to `0.0.0.0:7391` with no TLS and no database —
145/// production callers must explicitly configure `tls` and
146/// `database_url` before serving real traffic.
147#[derive(Debug, Clone, PartialEq, Eq)]
148#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
149#[cfg_attr(feature = "serde", serde(default))]
150pub struct RemoteModeConfig {
151    /// Address the gateway binds to. Default: `0.0.0.0:7391`.
152    pub listen_addr: SocketAddr,
153    /// TLS cert / key paths. `None` disables TLS (development only).
154    pub tls: Option<TlsConfig>,
155    /// PostgreSQL connection URL. `None` falls back to in-memory storage.
156    pub database_url: Option<String>,
157    /// Optional Redis URL used by the rate-limit and pub/sub subsystems.
158    pub redis_url: Option<String>,
159}
160
161impl Default for RemoteModeConfig {
162    fn default() -> Self {
163        Self {
164            listen_addr: SocketAddr::from(([0, 0, 0, 0], 7391)),
165            tls: None,
166            database_url: None,
167            redis_url: None,
168        }
169    }
170}
171
172/// Agent-side connection settings (used by the SDK FFI shims, not the gateway).
173///
174/// `gateway_url` is the address the SDK calls into. `api_key` is the
175/// optional bearer token surface for authenticated SaaS deployments;
176/// in local mode it is typically `None`.
177#[derive(Debug, Clone, PartialEq, Eq)]
178#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
179#[cfg_attr(feature = "serde", serde(default))]
180pub struct AgentConnectConfig {
181    /// Where the SDK connects. Default: `http://localhost:7391`.
182    pub gateway_url: String,
183    /// Optional API key for authenticated control planes.
184    pub api_key: Option<String>,
185}
186
187impl Default for AgentConnectConfig {
188    fn default() -> Self {
189        Self {
190            gateway_url: String::from("http://localhost:7391"),
191            api_key: None,
192        }
193    }
194}
195
196/// What to do with audit-event rows once they age past the `warm_days`
197/// boundary in [`RetentionConfig`].
198///
199/// `Drop` is the default — operators must explicitly opt into `Archive`
200/// **and** supply an `archive_url` (validation enforced at startup;
201/// tracked under E18 S-H / AAASM-1582).
202#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
203#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
204#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
205pub enum ColdAction {
206    /// Permanently delete cold-tier rows once they pass `warm_days`.
207    #[default]
208    Drop,
209    /// Upload cold-tier rows to the operator-configured `archive_url`
210    /// (S3 / GCS / etc.) and remove them from primary storage.
211    Archive,
212}
213
214/// Hot / warm / cold audit-event lifecycle parameters.
215///
216/// Defaults align with the SOC 2 / ISO 27001 reference window from the
217/// Epic 18 spec: 30 days fully indexed (hot), 90 days
218/// compressed-but-queryable (warm), then `cold_action` decides. The
219/// `schedule` is a 6-field UTC cron expression — default `0 0 3 * * *`
220/// runs the retention sweep at 03:00 UTC daily.
221#[derive(Debug, Clone, PartialEq, Eq)]
222#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
223#[cfg_attr(feature = "serde", serde(default))]
224pub struct RetentionConfig {
225    /// Days of hot-tier retention — rows are kept fully indexed.
226    pub hot_days: u32,
227    /// Days of warm-tier retention before `cold_action` kicks in.
228    pub warm_days: u32,
229    /// What to do with rows past the warm tier.
230    pub cold_action: ColdAction,
231    /// Required when `cold_action = Archive`; ignored otherwise.
232    pub archive_url: Option<String>,
233    /// UTC cron expression for the retention sweep job.
234    pub schedule: String,
235    /// When `true`, the retention task logs what it *would* do without
236    /// touching any data — used by operators to validate new policies
237    /// before turning them on.
238    pub dry_run: bool,
239}
240
241impl Default for RetentionConfig {
242    fn default() -> Self {
243        Self {
244            hot_days: 30,
245            warm_days: 90,
246            cold_action: ColdAction::Drop,
247            archive_url: None,
248            schedule: String::from("0 0 3 * * *"),
249            dry_run: false,
250        }
251    }
252}
253
254/// TimescaleDB-specific knobs for the production Postgres backend.
255///
256/// When `enabled = true` the gateway creates `audit_events` and
257/// `metrics` as TimescaleDB hypertables on startup and installs the
258/// configured compression policy. The two interval fields are
259/// passed through to TimescaleDB verbatim — they accept any Postgres
260/// `INTERVAL` literal (e.g. `"7 days"`, `"12 hours"`).
261#[derive(Debug, Clone, PartialEq, Eq)]
262#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
263#[cfg_attr(feature = "serde", serde(default))]
264pub struct TimescaleConfig {
265    /// Whether to enable the TimescaleDB extension on the connected
266    /// Postgres instance. Setting `false` falls back to plain Postgres
267    /// (no hypertables, no compression policy).
268    pub enabled: bool,
269    /// Hypertable time-chunk interval. Default: `"7 days"`.
270    pub chunk_interval: String,
271    /// Age at which chunks are auto-compressed. Default: `"30 days"`.
272    pub compression_policy: String,
273}
274
275impl Default for TimescaleConfig {
276    fn default() -> Self {
277        Self {
278            enabled: true,
279            chunk_interval: String::from("7 days"),
280            compression_policy: String::from("30 days"),
281        }
282    }
283}
284
285/// Connection pool and TimescaleDB knobs for the production Postgres
286/// `StorageBackend`.
287///
288/// `database_url` is `None` by default so YAML configs without an
289/// explicit URL fall back to the `AAASM_DATABASE_URL` env var (wired
290/// in the env-override Subtask, AAASM-1735). Pool sizing defaults
291/// match the spec's reference values.
292#[derive(Debug, Clone, PartialEq, Eq)]
293#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
294#[cfg_attr(feature = "serde", serde(default))]
295pub struct PostgresConfig {
296    /// PostgreSQL connection URL. Falls back to `AAASM_DATABASE_URL`
297    /// (env-override layer); leaving both unset is a startup error
298    /// when `storage.backend = Postgres`.
299    pub database_url: Option<String>,
300    /// Maximum sqlx connection-pool size. Default: `20`.
301    pub max_connections: u32,
302    /// Minimum sqlx connection-pool size kept warm. Default: `2`.
303    pub min_connections: u32,
304    /// Connection-establishment timeout in seconds. Default: `10`.
305    pub connect_timeout_secs: u64,
306    /// TimescaleDB-specific knobs.
307    pub timescaledb: TimescaleConfig,
308}
309
310impl Default for PostgresConfig {
311    fn default() -> Self {
312        Self {
313            database_url: None,
314            max_connections: 20,
315            min_connections: 2,
316            connect_timeout_secs: 10,
317            timescaledb: TimescaleConfig::default(),
318        }
319    }
320}
321
322/// Local-mode SQLite `StorageBackend` settings.
323///
324/// `path` is stored raw — the leading `~` is expanded by
325/// `GatewayConfig::expand_paths()` (extension landing in Subtask
326/// AAASM-1740). `journal_mode = "wal"` gives a better concurrent-read
327/// experience for the local dashboard while a developer's gateway is
328/// writing audit events.
329#[derive(Debug, Clone, PartialEq, Eq)]
330#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
331#[cfg_attr(feature = "serde", serde(default))]
332pub struct SqliteConfig {
333    /// SQLite database path. Default: `~/.aasm/local.db` (un-expanded).
334    pub path: PathBuf,
335    /// SQLite `journal_mode` PRAGMA. Default: `"wal"`.
336    pub journal_mode: String,
337}
338
339impl Default for SqliteConfig {
340    fn default() -> Self {
341        Self {
342            path: PathBuf::from("~/.aasm/local.db"),
343            journal_mode: String::from("wal"),
344        }
345    }
346}
347
348/// Optional Redis policy / session cache.
349///
350/// `enabled = false` by default — Redis is opt-in. When the operator
351/// measures policy-evaluation latency as a bottleneck they flip
352/// `enabled = true` and the gateway's hot-path policy decisions get
353/// a `policy_cache_ttl_secs` TTL cache in front of PostgreSQL.
354#[derive(Debug, Clone, PartialEq, Eq)]
355#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
356#[cfg_attr(feature = "serde", serde(default))]
357pub struct RedisConfig {
358    /// Master switch — when `false`, no Redis dependency is required.
359    pub enabled: bool,
360    /// Redis connection URL. Falls back to `AAASM_REDIS_URL` (env
361    /// override Subtask AAASM-1735); leaving both unset with
362    /// `enabled = true` is a startup error.
363    pub url: Option<String>,
364    /// TTL in seconds for hot-path policy-decision cache entries.
365    pub policy_cache_ttl_secs: u64,
366    /// Maximum Redis connection-pool size. Default: `10`.
367    pub max_connections: u32,
368}
369
370impl Default for RedisConfig {
371    fn default() -> Self {
372        Self {
373            enabled: false,
374            url: None,
375            policy_cache_ttl_secs: 30,
376            max_connections: 10,
377        }
378    }
379}
380
381/// Which `StorageBackend` implementation the gateway should boot.
382///
383/// `Sqlite` is the documented default for local-dev mode; `Postgres`
384/// is required for any deployment that needs durability across gateway
385/// restarts at production scale. The actual mode-aware default is
386/// resolved by `GatewayConfig::resolve_storage_backend()` in Subtask
387/// AAASM-1740 — this enum's `Default = Sqlite` only matters when the
388/// resolver path is bypassed (e.g. direct `StorageConfig::default()`).
389#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
390#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
391#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
392pub enum StorageBackendType {
393    /// Embedded SQLite database — single file, no external service.
394    #[default]
395    Sqlite,
396    /// PostgreSQL — optionally with TimescaleDB for hypertables.
397    Postgres,
398}
399
400/// Durable-persistence configuration for the gateway (Epic 18).
401///
402/// Composes the per-engine knobs (`sqlite`, `postgres`, `redis`) with
403/// retention-lifecycle parameters and a `backend` selector. Empty YAML
404/// hydrates straight to `Self::default()` thanks to `#[serde(default)]`
405/// on the struct itself; missing nested sections use each sub-config's
406/// own `Default`.
407#[derive(Debug, Clone, Default, PartialEq, Eq)]
408#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
409#[cfg_attr(feature = "serde", serde(default))]
410pub struct StorageConfig {
411    /// Which `StorageBackend` to instantiate at startup.
412    pub backend: StorageBackendType,
413    /// SQLite-specific settings (`backend = Sqlite`).
414    pub sqlite: SqliteConfig,
415    /// Postgres-specific settings (`backend = Postgres`).
416    pub postgres: PostgresConfig,
417    /// Optional Redis cache (`enabled = false` by default).
418    pub redis: RedisConfig,
419    /// Hot / warm / cold audit-event lifecycle policy.
420    pub retention: RetentionConfig,
421    /// Internal marker — `true` iff `backend` was explicitly set in
422    /// YAML or via the `AAASM_STORAGE_BACKEND` env var. When `false`,
423    /// `GatewayConfig::resolve_storage_backend` infers the value from
424    /// the deployment mode (Local → Sqlite, Remote → Postgres).
425    ///
426    /// Marked `#[serde(skip)]` so it never appears in YAML and never
427    /// shows up in serialized output. Always `false` on
428    /// `Self::default()` and immediately after deserialization;
429    /// `from_yaml_str` patches it via a single-pass YAML peek.
430    #[cfg_attr(feature = "serde", serde(skip))]
431    pub(crate) backend_explicit: bool,
432}
433
434/// Top-level gateway configuration loaded at startup.
435///
436/// Composes the four sub-configs and a [`DeploymentMode`] flag. All
437/// fields use `#[serde(default)]` so a minimal YAML — even an empty
438/// document — deserialises into the documented defaults.
439#[derive(Debug, Clone, Default, PartialEq, Eq)]
440#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
441#[cfg_attr(feature = "serde", serde(default))]
442pub struct GatewayConfig {
443    /// Which topology to boot — local-dev or remote control-plane.
444    pub mode: DeploymentMode,
445    /// Settings for `mode = Local`.
446    pub local: LocalModeConfig,
447    /// Settings for `mode = Remote`.
448    pub remote: RemoteModeConfig,
449    /// Settings the SDK FFI shim reads to dial the gateway.
450    pub agent: AgentConnectConfig,
451    /// Durable-persistence configuration (Epic 18 — AAASM-1569).
452    pub storage: StorageConfig,
453}
454
455#[cfg(feature = "serde")]
456impl GatewayConfig {
457    /// Parse a `GatewayConfig` from a YAML string.
458    ///
459    /// Missing fields fall back to their documented defaults thanks to
460    /// the type-level `#[serde(default)]` attribute, so an empty
461    /// document (`""` or `"{}"`) deserialises to `Self::default()`.
462    ///
463    /// In addition to the structural parse, this peeks at the raw YAML
464    /// to determine whether `storage.backend` was set explicitly — used
465    /// by `resolve_storage_backend` (AAASM-1740) to know when to infer
466    /// the value from the deployment mode rather than overwrite an
467    /// operator-supplied choice.
468    pub fn from_yaml_str(yaml: &str) -> Result<Self, ConfigError> {
469        let mut cfg: Self = serde_yaml::from_str(yaml)?;
470        cfg.storage.backend_explicit = yaml_has_storage_backend(yaml);
471        Ok(cfg)
472    }
473
474    /// Load a `GatewayConfig` from a YAML file on disk.
475    ///
476    /// A `NotFound` error returns `Self::default()` so missing
477    /// `~/.aasm/config.yaml` does not break startup. Any other I/O
478    /// error (permission denied, malformed YAML, etc.) propagates
479    /// as `ConfigError`.
480    pub fn load_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ConfigError> {
481        match std::fs::read_to_string(path) {
482            Ok(yaml) => Self::from_yaml_str(&yaml),
483            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
484            Err(err) => Err(ConfigError::Io(err)),
485        }
486    }
487
488    /// Load `GatewayConfig` from the user's `~/.aasm/config.yaml`.
489    ///
490    /// Equivalent to `load_from_path(dirs::home_dir() / ".aasm/config.yaml")`.
491    /// Falls back to `Self::default()` when the file is absent or the
492    /// home directory cannot be resolved (e.g. `$HOME` unset in a
493    /// systemd unit without `User=`).
494    pub fn load_default_path() -> Result<Self, ConfigError> {
495        let Some(home) = dirs::home_dir() else {
496            return Ok(Self::default());
497        };
498        Self::load_from_path(home.join(".aasm").join("config.yaml"))
499    }
500
501    /// One-shot loader for `aasm start` and the gateway bootstrap path:
502    /// read `~/.aasm/config.yaml`, expand `~` in path fields, apply the
503    /// `AA_MODE` / `AAASM_*` env-var overrides, and finally
504    /// [`validate`](Self::validate) the result.
505    ///
506    /// Returns the same `ConfigError` variants as the underlying steps,
507    /// plus the validation errors from E18 S-H (AAASM-1739).
508    pub fn load() -> Result<Self, ConfigError> {
509        let mut cfg = Self::load_default_path()?;
510        cfg.expand_paths();
511        cfg.apply_env_overrides()?;
512        cfg.resolve_storage_backend();
513        cfg.validate()?;
514        Ok(cfg)
515    }
516}
517
518impl GatewayConfig {
519    /// Expand a leading `~` in every path field to the user's home directory.
520    ///
521    /// Touches `local.storage_path` and both `remote.tls` paths.
522    /// A no-op when the home directory cannot be resolved or when
523    /// no field starts with `~`. Idempotent — calling twice produces
524    /// the same result as calling once.
525    pub fn expand_paths(&mut self) {
526        if let Some(home) = dirs::home_dir() {
527            self.expand_paths_in(&home);
528        }
529    }
530
531    /// Same as [`expand_paths`](Self::expand_paths) but takes an explicit home
532    /// directory — used by tests so the assertion is independent of `$HOME`.
533    pub(crate) fn expand_paths_in(&mut self, home: &std::path::Path) {
534        self.local.storage_path = expand_tilde(&self.local.storage_path, home);
535        self.storage.sqlite.path = expand_tilde(&self.storage.sqlite.path, home);
536        if let Some(tls) = &mut self.remote.tls {
537            tls.cert_file = expand_tilde(&tls.cert_file, home);
538            tls.key_file = expand_tilde(&tls.key_file, home);
539        }
540    }
541}
542
543fn expand_tilde(path: &std::path::Path, home: &std::path::Path) -> PathBuf {
544    match path.strip_prefix("~") {
545        Ok(stripped) => home.join(stripped),
546        Err(_) => path.to_path_buf(),
547    }
548}
549
550/// Peek at raw YAML to determine whether `storage.backend` was set
551/// explicitly. Returns `false` for invalid YAML — `from_yaml_str` will
552/// surface that as a `ConfigError::Yaml` further up the call chain.
553#[cfg(feature = "serde")]
554fn yaml_has_storage_backend(yaml: &str) -> bool {
555    let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
556        return false;
557    };
558    value
559        .get("storage")
560        .and_then(|storage| storage.get("backend"))
561        .is_some()
562}
563
564impl GatewayConfig {
565    /// Apply the documented `AA_MODE` / `AAASM_*` environment variables
566    /// on top of `self`, overriding any fields they set.
567    ///
568    /// Returns `ConfigError::InvalidMode` / `ConfigError::InvalidPort`
569    /// when an env var has been set to a value that cannot be parsed.
570    pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
571        self.apply_env_overrides_with(|key| std::env::var(key).ok())
572    }
573
574    /// Same as [`apply_env_overrides`](Self::apply_env_overrides) but reads env
575    /// vars through the supplied closure. Used by tests to inject a mock
576    /// environment without touching process-global state.
577    pub(crate) fn apply_env_overrides_with<F>(&mut self, get_env: F) -> Result<(), ConfigError>
578    where
579        F: Fn(&str) -> Option<String>,
580    {
581        if let Some(raw) = get_env("AA_MODE") {
582            self.mode = match raw.as_str() {
583                "local" => DeploymentMode::Local,
584                "remote" => DeploymentMode::Remote,
585                _ => return Err(ConfigError::InvalidMode { raw }),
586            };
587        }
588        if let Some(raw) = get_env("AAASM_GATEWAY_PORT") {
589            let port: u16 = raw.parse().map_err(|_| ConfigError::InvalidPort { raw: raw.clone() })?;
590            self.local.port = port;
591            self.remote.listen_addr.set_port(port);
592        }
593        self.apply_storage_env_overrides(&get_env)?;
594        self.apply_tls_env_overrides(&get_env);
595        Ok(())
596    }
597
598    /// Apply the `AAASM_STORAGE_*` / `AAASM_*_URL` / `AAASM_RETENTION_*`
599    /// env vars onto `self.storage`.
600    fn apply_storage_env_overrides<F>(&mut self, get_env: &F) -> Result<(), ConfigError>
601    where
602        F: Fn(&str) -> Option<String>,
603    {
604        if let Some(raw) = get_env("AAASM_STORAGE_BACKEND") {
605            self.storage.backend = match raw.as_str() {
606                "sqlite" => StorageBackendType::Sqlite,
607                "postgres" => StorageBackendType::Postgres,
608                _ => return Err(ConfigError::InvalidStorageBackend { raw }),
609            };
610            // An explicit env-var choice counts as "set by operator" —
611            // resolve_storage_backend must not overwrite it.
612            self.storage.backend_explicit = true;
613        }
614        if let Some(url) = get_env("AAASM_DATABASE_URL") {
615            self.storage.postgres.database_url = Some(url);
616        }
617        if let Some(url) = get_env("AAASM_REDIS_URL") {
618            self.storage.redis.url = Some(url);
619        }
620        if let Some(path) = get_env("AAASM_SQLITE_PATH") {
621            self.storage.sqlite.path = PathBuf::from(path);
622        }
623        if let Some(raw) = get_env("AAASM_RETENTION_HOT_DAYS") {
624            self.storage.retention.hot_days = raw.parse().map_err(|_| ConfigError::InvalidUnsignedInt {
625                var: "AAASM_RETENTION_HOT_DAYS",
626                raw: raw.clone(),
627            })?;
628        }
629        if let Some(raw) = get_env("AAASM_RETENTION_COLD_ACTION") {
630            self.storage.retention.cold_action = match raw.as_str() {
631                "drop" => ColdAction::Drop,
632                "archive" => ColdAction::Archive,
633                _ => return Err(ConfigError::InvalidColdAction { raw }),
634            };
635        }
636        Ok(())
637    }
638
639    /// Apply the `AAASM_TLS_CERT` / `AAASM_TLS_KEY` env vars onto
640    /// `self.remote.tls`, creating the [`TlsConfig`] slot if either is set.
641    fn apply_tls_env_overrides<F>(&mut self, get_env: &F)
642    where
643        F: Fn(&str) -> Option<String>,
644    {
645        let cert = get_env("AAASM_TLS_CERT");
646        let key = get_env("AAASM_TLS_KEY");
647        if cert.is_some() || key.is_some() {
648            let tls = self.remote.tls.get_or_insert(TlsConfig {
649                cert_file: PathBuf::new(),
650                key_file: PathBuf::new(),
651            });
652            if let Some(path) = cert {
653                tls.cert_file = PathBuf::from(path);
654            }
655            if let Some(path) = key {
656                tls.key_file = PathBuf::from(path);
657            }
658        }
659    }
660}
661
662impl GatewayConfig {
663    /// Validate the fully-loaded config — call this **after**
664    /// [`expand_paths`](Self::expand_paths) and
665    /// [`apply_env_overrides`](Self::apply_env_overrides) so values
666    /// coming in from env vars are included in the checks.
667    ///
668    /// Currently enforces two storage-retention invariants from
669    /// E18 S-H (AAASM-1582):
670    ///
671    /// * `storage.retention.cold_action = archive` requires
672    ///   `storage.retention.archive_url` to be set (in YAML or by
673    ///   env var) — returns [`ConfigError::ArchiveUrlRequired`].
674    /// * `storage.retention.warm_days` must be strictly greater than
675    ///   `storage.retention.hot_days` — returns
676    ///   [`ConfigError::WarmDaysNotGreaterThanHotDays`].
677    pub fn validate(&self) -> Result<(), ConfigError> {
678        let r = &self.storage.retention;
679        if r.cold_action == ColdAction::Archive && r.archive_url.is_none() {
680            return Err(ConfigError::ArchiveUrlRequired);
681        }
682        if r.warm_days <= r.hot_days {
683            return Err(ConfigError::WarmDaysNotGreaterThanHotDays {
684                hot: r.hot_days,
685                warm: r.warm_days,
686            });
687        }
688        Ok(())
689    }
690}
691
692impl GatewayConfig {
693    /// Infer `storage.backend` from `mode` when it was not explicitly
694    /// set in YAML or via the `AAASM_STORAGE_BACKEND` env var.
695    ///
696    /// * `mode: Local` → `storage.backend = Sqlite`
697    /// * `mode: Remote` → `storage.backend = Postgres`
698    ///
699    /// No-op when the operator explicitly set `storage.backend` in
700    /// YAML or via `AAASM_STORAGE_BACKEND` — their choice always wins,
701    /// including the odd-but-valid `mode: remote` + `storage.backend:
702    /// sqlite` combo (an in-memory test runner pointed at the remote
703    /// API surface).
704    pub fn resolve_storage_backend(&mut self) {
705        if self.storage.backend_explicit {
706            return;
707        }
708        self.storage.backend = match self.mode {
709            DeploymentMode::Local => StorageBackendType::Sqlite,
710            DeploymentMode::Remote => StorageBackendType::Postgres,
711        };
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn deployment_mode_default_is_local() {
721        assert_eq!(DeploymentMode::default(), DeploymentMode::Local);
722    }
723
724    #[cfg(feature = "serde")]
725    #[test]
726    fn deployment_mode_yaml_round_trip_local() {
727        let mode: DeploymentMode = serde_yaml::from_str("local").unwrap();
728        assert_eq!(mode, DeploymentMode::Local);
729    }
730
731    #[cfg(feature = "serde")]
732    #[test]
733    fn deployment_mode_yaml_round_trip_remote() {
734        let mode: DeploymentMode = serde_yaml::from_str("remote").unwrap();
735        assert_eq!(mode, DeploymentMode::Remote);
736    }
737
738    #[cfg(feature = "serde")]
739    #[test]
740    fn deployment_mode_yaml_rejects_unknown_variant() {
741        let result: Result<DeploymentMode, _> = serde_yaml::from_str("foobar");
742        assert!(result.is_err(), "unknown variant should fail to deserialize");
743    }
744
745    #[test]
746    fn local_mode_config_default_matches_spec() {
747        let cfg = LocalModeConfig::default();
748        assert_eq!(cfg.port, 7391);
749        assert!(cfg.dashboard);
750        assert_eq!(cfg.storage_path, PathBuf::from("~/.aasm/local.db"));
751    }
752
753    #[cfg(feature = "serde")]
754    #[test]
755    fn local_mode_config_yaml_overrides_port_keeps_other_defaults() {
756        let cfg: LocalModeConfig = serde_yaml::from_str("port: 8080").unwrap();
757        assert_eq!(cfg.port, 8080);
758        assert!(cfg.dashboard, "dashboard should fall back to default");
759        assert_eq!(
760            cfg.storage_path,
761            PathBuf::from("~/.aasm/local.db"),
762            "storage_path should fall back to default"
763        );
764    }
765
766    #[test]
767    fn remote_mode_config_default_binds_all_interfaces() {
768        let cfg = RemoteModeConfig::default();
769        assert_eq!(cfg.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
770        assert!(cfg.tls.is_none(), "tls should be opt-in, never on by default");
771        assert!(cfg.database_url.is_none());
772        assert!(cfg.redis_url.is_none());
773    }
774
775    #[cfg(feature = "serde")]
776    #[test]
777    fn remote_mode_config_yaml_overrides_database_keeps_other_defaults() {
778        let yaml = r#"database_url: "postgres://aasm@db.internal/aasm""#;
779        let cfg: RemoteModeConfig = serde_yaml::from_str(yaml).unwrap();
780        assert_eq!(cfg.database_url.as_deref(), Some("postgres://aasm@db.internal/aasm"));
781        assert_eq!(cfg.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
782        assert!(cfg.tls.is_none());
783        assert!(cfg.redis_url.is_none());
784    }
785
786    #[test]
787    fn agent_connect_config_default_points_at_localhost() {
788        let cfg = AgentConnectConfig::default();
789        assert_eq!(cfg.gateway_url, "http://localhost:7391");
790        assert!(cfg.api_key.is_none());
791    }
792
793    #[cfg(feature = "serde")]
794    #[test]
795    fn agent_connect_config_yaml_round_trip() {
796        let yaml = r#"
797gateway_url: "https://cp.company.internal:7391"
798api_key: "secret"
799"#;
800        let cfg: AgentConnectConfig = serde_yaml::from_str(yaml).unwrap();
801        assert_eq!(cfg.gateway_url, "https://cp.company.internal:7391");
802        assert_eq!(cfg.api_key.as_deref(), Some("secret"));
803    }
804
805    #[test]
806    fn gateway_config_default_uses_local_mode_and_documented_defaults() {
807        let cfg = GatewayConfig::default();
808        assert_eq!(cfg.mode, DeploymentMode::Local);
809        assert_eq!(cfg.local.port, 7391);
810        assert_eq!(cfg.remote.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
811        assert_eq!(cfg.agent.gateway_url, "http://localhost:7391");
812    }
813
814    #[cfg(feature = "serde")]
815    #[test]
816    fn gateway_config_from_yaml_str_parses_full_epic_example() {
817        let yaml = r#"
818mode: remote
819local:
820  port: 8080
821  dashboard: false
822  storage_path: ~/.aasm/dev.db
823remote:
824  listen_addr: "127.0.0.1:7391"
825  tls:
826    cert_file: /etc/aasm/tls.crt
827    key_file: /etc/aasm/tls.key
828  database_url: "postgres://aasm@db.internal/aasm"
829  redis_url: "redis://redis.internal:6379"
830agent:
831  gateway_url: "https://cp.company.internal:7391"
832  api_key: "secret"
833"#;
834        let cfg = GatewayConfig::from_yaml_str(yaml).expect("valid YAML should parse");
835        assert_eq!(cfg.mode, DeploymentMode::Remote);
836        assert_eq!(cfg.local.port, 8080);
837        assert!(!cfg.local.dashboard);
838        assert_eq!(cfg.remote.listen_addr, SocketAddr::from(([127, 0, 0, 1], 7391)));
839        let tls = cfg.remote.tls.expect("tls present");
840        assert_eq!(tls.cert_file, PathBuf::from("/etc/aasm/tls.crt"));
841        assert_eq!(tls.key_file, PathBuf::from("/etc/aasm/tls.key"));
842        assert_eq!(
843            cfg.remote.database_url.as_deref(),
844            Some("postgres://aasm@db.internal/aasm")
845        );
846        assert_eq!(cfg.agent.api_key.as_deref(), Some("secret"));
847    }
848
849    #[cfg(feature = "serde")]
850    #[test]
851    fn gateway_config_from_yaml_str_empty_doc_returns_default() {
852        let cfg = GatewayConfig::from_yaml_str("{}").unwrap();
853        assert_eq!(cfg, GatewayConfig::default());
854    }
855
856    #[cfg(feature = "serde")]
857    #[test]
858    fn gateway_config_load_from_missing_path_returns_default() {
859        let missing = std::env::temp_dir().join("aasm-config-does-not-exist-AAASM-1691.yaml");
860        // Make sure the test pre-condition holds even if a stale file lingers.
861        let _ = std::fs::remove_file(&missing);
862        let cfg = GatewayConfig::load_from_path(&missing).expect("missing file should not error");
863        assert_eq!(cfg, GatewayConfig::default());
864    }
865
866    #[cfg(feature = "serde")]
867    #[test]
868    fn gateway_config_load_from_existing_path_parses_yaml() {
869        let tmp_dir = std::env::temp_dir().join("aasm-config-AAASM-1691");
870        std::fs::create_dir_all(&tmp_dir).unwrap();
871        let path = tmp_dir.join("config.yaml");
872        std::fs::write(&path, "mode: remote\n").unwrap();
873        let cfg = GatewayConfig::load_from_path(&path).expect("existing file should parse");
874        assert_eq!(cfg.mode, DeploymentMode::Remote);
875        std::fs::remove_file(&path).ok();
876    }
877
878    #[test]
879    fn expand_paths_in_resolves_tilde_in_storage_path() {
880        let mut cfg = GatewayConfig::default();
881        let fake_home = PathBuf::from("/srv/dev/bryant");
882        cfg.expand_paths_in(&fake_home);
883        assert_eq!(cfg.local.storage_path, PathBuf::from("/srv/dev/bryant/.aasm/local.db"));
884    }
885
886    #[test]
887    fn expand_paths_in_resolves_tilde_in_tls_paths() {
888        let mut cfg = GatewayConfig::default();
889        cfg.remote.tls = Some(TlsConfig {
890            cert_file: PathBuf::from("~/secrets/tls.crt"),
891            key_file: PathBuf::from("~/secrets/tls.key"),
892        });
893        let fake_home = PathBuf::from("/srv/dev/bryant");
894        cfg.expand_paths_in(&fake_home);
895        let tls = cfg.remote.tls.unwrap();
896        assert_eq!(tls.cert_file, PathBuf::from("/srv/dev/bryant/secrets/tls.crt"));
897        assert_eq!(tls.key_file, PathBuf::from("/srv/dev/bryant/secrets/tls.key"));
898    }
899
900    #[test]
901    fn expand_paths_in_is_idempotent() {
902        let mut cfg = GatewayConfig::default();
903        let fake_home = PathBuf::from("/srv/dev/bryant");
904        cfg.expand_paths_in(&fake_home);
905        let after_first = cfg.local.storage_path.clone();
906        cfg.expand_paths_in(&fake_home);
907        assert_eq!(cfg.local.storage_path, after_first, "second call must be a no-op");
908    }
909
910    #[test]
911    fn expand_paths_in_leaves_absolute_paths_alone() {
912        let mut cfg = GatewayConfig::default();
913        cfg.local.storage_path = PathBuf::from("/var/lib/aasm.db");
914        cfg.expand_paths_in(&PathBuf::from("/srv/dev/bryant"));
915        assert_eq!(cfg.local.storage_path, PathBuf::from("/var/lib/aasm.db"));
916    }
917
918    /// Helper for env-override tests — builds a closure backed by a
919    /// `HashMap`. Keeps test bodies short without bumping into the
920    /// borrow checker when mapping `&[(&str, &str)]` over `&str` keys.
921    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
922        let map: std::collections::HashMap<String, String> = pairs
923            .iter()
924            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
925            .collect();
926        move |key| map.get(key).cloned()
927    }
928
929    #[test]
930    fn apply_env_overrides_aa_mode_remote_promotes_mode() {
931        let mut cfg = GatewayConfig::default();
932        cfg.apply_env_overrides_with(env(&[("AA_MODE", "remote")])).unwrap();
933        assert_eq!(cfg.mode, DeploymentMode::Remote);
934    }
935
936    #[test]
937    fn apply_env_overrides_aa_mode_invalid_returns_named_error() {
938        let mut cfg = GatewayConfig::default();
939        let err = cfg
940            .apply_env_overrides_with(env(&[("AA_MODE", "foobar")]))
941            .expect_err("invalid value must return Err");
942        // The message must include both the env-var name and the bad value
943        // so operators can grep startup logs.
944        let msg = format!("{err}");
945        assert!(matches!(err, ConfigError::InvalidMode { ref raw } if raw == "foobar"));
946        assert!(msg.contains("AA_MODE"), "message should name the var: {msg}");
947        assert!(msg.contains("foobar"), "message should include the value: {msg}");
948    }
949
950    #[test]
951    fn apply_env_overrides_port_updates_local_and_remote() {
952        let mut cfg = GatewayConfig::default();
953        cfg.apply_env_overrides_with(env(&[("AAASM_GATEWAY_PORT", "8080")]))
954            .unwrap();
955        assert_eq!(cfg.local.port, 8080);
956        assert_eq!(cfg.remote.listen_addr.port(), 8080);
957        // The bind address (only the port should change) keeps 0.0.0.0.
958        assert_eq!(cfg.remote.listen_addr.ip().to_string(), "0.0.0.0");
959    }
960
961    #[test]
962    fn apply_env_overrides_port_invalid_returns_named_error() {
963        let mut cfg = GatewayConfig::default();
964        let err = cfg
965            .apply_env_overrides_with(env(&[("AAASM_GATEWAY_PORT", "not-a-number")]))
966            .expect_err("non-numeric port must return Err");
967        let msg = format!("{err}");
968        assert!(matches!(err, ConfigError::InvalidPort { ref raw } if raw == "not-a-number"));
969        assert!(msg.contains("AAASM_GATEWAY_PORT"));
970        assert!(msg.contains("not-a-number"));
971    }
972
973    #[test]
974    fn apply_env_overrides_database_url_targets_storage_postgres() {
975        let mut cfg = GatewayConfig::default();
976        cfg.apply_env_overrides_with(env(&[("AAASM_DATABASE_URL", "postgres://aasm@db/aasm")]))
977            .unwrap();
978        assert_eq!(
979            cfg.storage.postgres.database_url.as_deref(),
980            Some("postgres://aasm@db/aasm"),
981        );
982        // Legacy remote.database_url is untouched (removed in E18 S-I).
983        assert!(cfg.remote.database_url.is_none());
984    }
985
986    #[test]
987    fn apply_env_overrides_redis_url_targets_storage_redis() {
988        let mut cfg = GatewayConfig::default();
989        cfg.apply_env_overrides_with(env(&[("AAASM_REDIS_URL", "redis://redis:6379")]))
990            .unwrap();
991        assert_eq!(cfg.storage.redis.url.as_deref(), Some("redis://redis:6379"));
992        // Legacy remote.redis_url is untouched (removed in E18 S-I).
993        assert!(cfg.remote.redis_url.is_none());
994    }
995
996    #[test]
997    fn apply_env_overrides_tls_creates_config_when_yaml_omitted_it() {
998        let mut cfg = GatewayConfig::default();
999        assert!(cfg.remote.tls.is_none(), "precondition: TLS off by default");
1000        cfg.apply_env_overrides_with(env(&[
1001            ("AAASM_TLS_CERT", "/etc/aasm/tls.crt"),
1002            ("AAASM_TLS_KEY", "/etc/aasm/tls.key"),
1003        ]))
1004        .unwrap();
1005        let tls = cfg.remote.tls.expect("TLS env vars must create TlsConfig");
1006        assert_eq!(tls.cert_file, PathBuf::from("/etc/aasm/tls.crt"));
1007        assert_eq!(tls.key_file, PathBuf::from("/etc/aasm/tls.key"));
1008    }
1009
1010    #[test]
1011    fn apply_env_overrides_tls_patches_existing_config_asymmetrically() {
1012        let mut cfg = GatewayConfig::default();
1013        cfg.remote.tls = Some(TlsConfig {
1014            cert_file: PathBuf::from("/old/tls.crt"),
1015            key_file: PathBuf::from("/old/tls.key"),
1016        });
1017        // Only AAASM_TLS_CERT set — key should keep its old path.
1018        cfg.apply_env_overrides_with(env(&[("AAASM_TLS_CERT", "/new/tls.crt")]))
1019            .unwrap();
1020        let tls = cfg.remote.tls.expect("tls preserved");
1021        assert_eq!(tls.cert_file, PathBuf::from("/new/tls.crt"));
1022        assert_eq!(tls.key_file, PathBuf::from("/old/tls.key"), "key untouched");
1023    }
1024
1025    #[cfg(feature = "serde")]
1026    #[test]
1027    fn empty_yaml_hydrates_storage_defaults() {
1028        let cfg = GatewayConfig::from_yaml_str("{}").expect("empty YAML must parse");
1029        let s = &cfg.storage;
1030        assert_eq!(s.backend, StorageBackendType::Sqlite, "default backend");
1031        assert_eq!(
1032            s.sqlite.path,
1033            PathBuf::from("~/.aasm/local.db"),
1034            "sqlite path un-expanded by default",
1035        );
1036        assert_eq!(s.sqlite.journal_mode, "wal");
1037        assert!(s.postgres.database_url.is_none(), "postgres url unset");
1038        assert_eq!(s.postgres.max_connections, 20);
1039        assert_eq!(s.postgres.min_connections, 2);
1040        assert_eq!(s.postgres.connect_timeout_secs, 10);
1041        assert!(s.postgres.timescaledb.enabled);
1042        assert_eq!(s.postgres.timescaledb.chunk_interval, "7 days");
1043        assert_eq!(s.postgres.timescaledb.compression_policy, "30 days");
1044        assert!(!s.redis.enabled, "redis opt-in");
1045        assert!(s.redis.url.is_none());
1046        assert_eq!(s.redis.policy_cache_ttl_secs, 30);
1047        assert_eq!(s.redis.max_connections, 10);
1048        assert_eq!(s.retention.hot_days, 30);
1049        assert_eq!(s.retention.warm_days, 90);
1050        assert_eq!(s.retention.cold_action, ColdAction::Drop);
1051        assert!(s.retention.archive_url.is_none());
1052        assert_eq!(s.retention.schedule, "0 0 3 * * *");
1053        assert!(!s.retention.dry_run);
1054    }
1055
1056    #[test]
1057    fn default_retention_schedule_is_a_valid_six_field_cron() {
1058        // The gateway validates `schedule` with the `cron` crate, which
1059        // requires the 6-field (seconds-leading) form. A 5-field default
1060        // is rejected at startup and silently disables retention, so the
1061        // default must always parse as 6 whitespace-separated fields.
1062        let schedule = RetentionConfig::default().schedule;
1063        assert_eq!(
1064            schedule.split_whitespace().count(),
1065            6,
1066            "default retention schedule {schedule:?} must be a 6-field cron",
1067        );
1068        assert_eq!(schedule, "0 0 3 * * *");
1069    }
1070
1071    #[test]
1072    fn apply_env_overrides_storage_matrix() {
1073        let mut cfg = GatewayConfig::default();
1074        cfg.apply_env_overrides_with(env(&[
1075            ("AAASM_STORAGE_BACKEND", "postgres"),
1076            ("AAASM_DATABASE_URL", "postgres://aasm@prod/aasm"),
1077            ("AAASM_REDIS_URL", "redis://prod:6379"),
1078            ("AAASM_SQLITE_PATH", "/var/lib/aasm.db"),
1079            ("AAASM_RETENTION_HOT_DAYS", "7"),
1080            ("AAASM_RETENTION_COLD_ACTION", "archive"),
1081        ]))
1082        .unwrap();
1083        assert_eq!(cfg.storage.backend, StorageBackendType::Postgres);
1084        assert_eq!(
1085            cfg.storage.postgres.database_url.as_deref(),
1086            Some("postgres://aasm@prod/aasm"),
1087        );
1088        assert_eq!(cfg.storage.redis.url.as_deref(), Some("redis://prod:6379"));
1089        assert_eq!(cfg.storage.sqlite.path, PathBuf::from("/var/lib/aasm.db"));
1090        assert_eq!(cfg.storage.retention.hot_days, 7);
1091        assert_eq!(cfg.storage.retention.cold_action, ColdAction::Archive);
1092    }
1093
1094    #[test]
1095    fn apply_env_overrides_storage_backend_invalid_returns_named_error() {
1096        let mut cfg = GatewayConfig::default();
1097        let err = cfg
1098            .apply_env_overrides_with(env(&[("AAASM_STORAGE_BACKEND", "mysql")]))
1099            .expect_err("unsupported backend must return Err");
1100        let msg = format!("{err}");
1101        assert!(matches!(err, ConfigError::InvalidStorageBackend { ref raw } if raw == "mysql"));
1102        assert!(msg.contains("AAASM_STORAGE_BACKEND"));
1103        assert!(msg.contains("mysql"));
1104        assert!(msg.contains("sqlite") && msg.contains("postgres"));
1105    }
1106
1107    #[test]
1108    fn apply_env_overrides_cold_action_invalid_returns_named_error() {
1109        let mut cfg = GatewayConfig::default();
1110        let err = cfg
1111            .apply_env_overrides_with(env(&[("AAASM_RETENTION_COLD_ACTION", "tombstone")]))
1112            .expect_err("unsupported cold_action must return Err");
1113        assert!(matches!(err, ConfigError::InvalidColdAction { ref raw } if raw == "tombstone"));
1114    }
1115
1116    #[test]
1117    fn apply_env_overrides_retention_hot_days_invalid_returns_named_error() {
1118        let mut cfg = GatewayConfig::default();
1119        let err = cfg
1120            .apply_env_overrides_with(env(&[("AAASM_RETENTION_HOT_DAYS", "thirty")]))
1121            .expect_err("non-numeric hot_days must return Err");
1122        assert!(matches!(
1123            err,
1124            ConfigError::InvalidUnsignedInt {
1125                var: "AAASM_RETENTION_HOT_DAYS",
1126                ref raw,
1127            } if raw == "thirty"
1128        ));
1129    }
1130
1131    #[test]
1132    fn validate_archive_without_url_fails_with_documented_message() {
1133        let mut cfg = GatewayConfig::default();
1134        cfg.storage.retention.cold_action = ColdAction::Archive;
1135        cfg.storage.retention.archive_url = None;
1136        let err = cfg
1137            .validate()
1138            .expect_err("cold_action = Archive without archive_url must fail");
1139        assert!(matches!(err, ConfigError::ArchiveUrlRequired));
1140        assert_eq!(format!("{err}"), "archive_url is required when cold_action is archive",);
1141    }
1142
1143    #[test]
1144    fn validate_archive_with_url_passes() {
1145        let mut cfg = GatewayConfig::default();
1146        cfg.storage.retention.cold_action = ColdAction::Archive;
1147        cfg.storage.retention.archive_url = Some("s3://aasm-archive/".into());
1148        cfg.validate().expect("archive + url must validate");
1149    }
1150
1151    #[test]
1152    fn validate_warm_days_must_be_greater_than_hot_days() {
1153        let mut cfg = GatewayConfig::default();
1154        cfg.storage.retention.hot_days = 60;
1155        cfg.storage.retention.warm_days = 30; // < hot_days
1156        let err = cfg.validate().expect_err("warm_days <= hot_days must fail");
1157        assert!(matches!(
1158            err,
1159            ConfigError::WarmDaysNotGreaterThanHotDays { hot: 60, warm: 30 }
1160        ));
1161        assert_eq!(format!("{err}"), "warm_days (30) must be greater than hot_days (60)",);
1162    }
1163
1164    #[test]
1165    fn validate_warm_days_equal_to_hot_days_also_fails() {
1166        let mut cfg = GatewayConfig::default();
1167        cfg.storage.retention.hot_days = 30;
1168        cfg.storage.retention.warm_days = 30; // == hot_days
1169        let err = cfg
1170            .validate()
1171            .expect_err("warm_days == hot_days must fail (strict inequality)");
1172        assert!(matches!(
1173            err,
1174            ConfigError::WarmDaysNotGreaterThanHotDays { hot: 30, warm: 30 }
1175        ));
1176    }
1177
1178    #[test]
1179    fn resolve_storage_backend_defaults_to_sqlite_in_local_mode() {
1180        // Default mode is already Local; backend_explicit stays false
1181        // since it's only flipped via YAML peek or AAASM_STORAGE_BACKEND.
1182        let mut cfg = GatewayConfig {
1183            mode: DeploymentMode::Local,
1184            ..GatewayConfig::default()
1185        };
1186        cfg.resolve_storage_backend();
1187        assert_eq!(cfg.storage.backend, StorageBackendType::Sqlite);
1188    }
1189
1190    #[test]
1191    fn resolve_storage_backend_defaults_to_postgres_in_remote_mode() {
1192        let mut cfg = GatewayConfig {
1193            mode: DeploymentMode::Remote,
1194            ..GatewayConfig::default()
1195        };
1196        cfg.resolve_storage_backend();
1197        assert_eq!(cfg.storage.backend, StorageBackendType::Postgres);
1198    }
1199
1200    #[test]
1201    fn resolve_storage_backend_respects_explicit_choice() {
1202        // Operator pinned sqlite in remote mode (e.g. in-process test
1203        // runner pointed at the remote API surface) — resolver must
1204        // leave it alone.
1205        let mut cfg = GatewayConfig {
1206            mode: DeploymentMode::Remote,
1207            ..GatewayConfig::default()
1208        };
1209        cfg.storage.backend = StorageBackendType::Sqlite;
1210        cfg.storage.backend_explicit = true;
1211        cfg.resolve_storage_backend();
1212        assert_eq!(cfg.storage.backend, StorageBackendType::Sqlite);
1213    }
1214
1215    #[test]
1216    fn expand_paths_in_resolves_tilde_in_storage_sqlite_path() {
1217        let mut cfg = GatewayConfig::default();
1218        assert_eq!(cfg.storage.sqlite.path, PathBuf::from("~/.aasm/local.db"));
1219        let fake_home = PathBuf::from("/srv/dev/bryant");
1220        cfg.expand_paths_in(&fake_home);
1221        assert_eq!(cfg.storage.sqlite.path, PathBuf::from("/srv/dev/bryant/.aasm/local.db"),);
1222    }
1223}