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 UTC cron expression — default `0 3 * * *` runs the
220/// 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 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        if let Some(raw) = get_env("AAASM_STORAGE_BACKEND") {
594            self.storage.backend = match raw.as_str() {
595                "sqlite" => StorageBackendType::Sqlite,
596                "postgres" => StorageBackendType::Postgres,
597                _ => return Err(ConfigError::InvalidStorageBackend { raw }),
598            };
599            // An explicit env-var choice counts as "set by operator" —
600            // resolve_storage_backend must not overwrite it.
601            self.storage.backend_explicit = true;
602        }
603        if let Some(url) = get_env("AAASM_DATABASE_URL") {
604            self.storage.postgres.database_url = Some(url);
605        }
606        if let Some(url) = get_env("AAASM_REDIS_URL") {
607            self.storage.redis.url = Some(url);
608        }
609        if let Some(path) = get_env("AAASM_SQLITE_PATH") {
610            self.storage.sqlite.path = PathBuf::from(path);
611        }
612        if let Some(raw) = get_env("AAASM_RETENTION_HOT_DAYS") {
613            self.storage.retention.hot_days = raw.parse().map_err(|_| ConfigError::InvalidUnsignedInt {
614                var: "AAASM_RETENTION_HOT_DAYS",
615                raw: raw.clone(),
616            })?;
617        }
618        if let Some(raw) = get_env("AAASM_RETENTION_COLD_ACTION") {
619            self.storage.retention.cold_action = match raw.as_str() {
620                "drop" => ColdAction::Drop,
621                "archive" => ColdAction::Archive,
622                _ => return Err(ConfigError::InvalidColdAction { raw }),
623            };
624        }
625        let cert = get_env("AAASM_TLS_CERT");
626        let key = get_env("AAASM_TLS_KEY");
627        if cert.is_some() || key.is_some() {
628            let tls = self.remote.tls.get_or_insert(TlsConfig {
629                cert_file: PathBuf::new(),
630                key_file: PathBuf::new(),
631            });
632            if let Some(path) = cert {
633                tls.cert_file = PathBuf::from(path);
634            }
635            if let Some(path) = key {
636                tls.key_file = PathBuf::from(path);
637            }
638        }
639        Ok(())
640    }
641}
642
643impl GatewayConfig {
644    /// Validate the fully-loaded config — call this **after**
645    /// [`expand_paths`](Self::expand_paths) and
646    /// [`apply_env_overrides`](Self::apply_env_overrides) so values
647    /// coming in from env vars are included in the checks.
648    ///
649    /// Currently enforces two storage-retention invariants from
650    /// E18 S-H (AAASM-1582):
651    ///
652    /// * `storage.retention.cold_action = archive` requires
653    ///   `storage.retention.archive_url` to be set (in YAML or by
654    ///   env var) — returns [`ConfigError::ArchiveUrlRequired`].
655    /// * `storage.retention.warm_days` must be strictly greater than
656    ///   `storage.retention.hot_days` — returns
657    ///   [`ConfigError::WarmDaysNotGreaterThanHotDays`].
658    pub fn validate(&self) -> Result<(), ConfigError> {
659        let r = &self.storage.retention;
660        if r.cold_action == ColdAction::Archive && r.archive_url.is_none() {
661            return Err(ConfigError::ArchiveUrlRequired);
662        }
663        if r.warm_days <= r.hot_days {
664            return Err(ConfigError::WarmDaysNotGreaterThanHotDays {
665                hot: r.hot_days,
666                warm: r.warm_days,
667            });
668        }
669        Ok(())
670    }
671}
672
673impl GatewayConfig {
674    /// Infer `storage.backend` from `mode` when it was not explicitly
675    /// set in YAML or via the `AAASM_STORAGE_BACKEND` env var.
676    ///
677    /// * `mode: Local` → `storage.backend = Sqlite`
678    /// * `mode: Remote` → `storage.backend = Postgres`
679    ///
680    /// No-op when the operator explicitly set `storage.backend` in
681    /// YAML or via `AAASM_STORAGE_BACKEND` — their choice always wins,
682    /// including the odd-but-valid `mode: remote` + `storage.backend:
683    /// sqlite` combo (an in-memory test runner pointed at the remote
684    /// API surface).
685    pub fn resolve_storage_backend(&mut self) {
686        if self.storage.backend_explicit {
687            return;
688        }
689        self.storage.backend = match self.mode {
690            DeploymentMode::Local => StorageBackendType::Sqlite,
691            DeploymentMode::Remote => StorageBackendType::Postgres,
692        };
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn deployment_mode_default_is_local() {
702        assert_eq!(DeploymentMode::default(), DeploymentMode::Local);
703    }
704
705    #[cfg(feature = "serde")]
706    #[test]
707    fn deployment_mode_yaml_round_trip_local() {
708        let mode: DeploymentMode = serde_yaml::from_str("local").unwrap();
709        assert_eq!(mode, DeploymentMode::Local);
710    }
711
712    #[cfg(feature = "serde")]
713    #[test]
714    fn deployment_mode_yaml_round_trip_remote() {
715        let mode: DeploymentMode = serde_yaml::from_str("remote").unwrap();
716        assert_eq!(mode, DeploymentMode::Remote);
717    }
718
719    #[cfg(feature = "serde")]
720    #[test]
721    fn deployment_mode_yaml_rejects_unknown_variant() {
722        let result: Result<DeploymentMode, _> = serde_yaml::from_str("foobar");
723        assert!(result.is_err(), "unknown variant should fail to deserialize");
724    }
725
726    #[test]
727    fn local_mode_config_default_matches_spec() {
728        let cfg = LocalModeConfig::default();
729        assert_eq!(cfg.port, 7391);
730        assert!(cfg.dashboard);
731        assert_eq!(cfg.storage_path, PathBuf::from("~/.aasm/local.db"));
732    }
733
734    #[cfg(feature = "serde")]
735    #[test]
736    fn local_mode_config_yaml_overrides_port_keeps_other_defaults() {
737        let cfg: LocalModeConfig = serde_yaml::from_str("port: 8080").unwrap();
738        assert_eq!(cfg.port, 8080);
739        assert!(cfg.dashboard, "dashboard should fall back to default");
740        assert_eq!(
741            cfg.storage_path,
742            PathBuf::from("~/.aasm/local.db"),
743            "storage_path should fall back to default"
744        );
745    }
746
747    #[test]
748    fn remote_mode_config_default_binds_all_interfaces() {
749        let cfg = RemoteModeConfig::default();
750        assert_eq!(cfg.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
751        assert!(cfg.tls.is_none(), "tls should be opt-in, never on by default");
752        assert!(cfg.database_url.is_none());
753        assert!(cfg.redis_url.is_none());
754    }
755
756    #[cfg(feature = "serde")]
757    #[test]
758    fn remote_mode_config_yaml_overrides_database_keeps_other_defaults() {
759        let yaml = r#"database_url: "postgres://aasm@db.internal/aasm""#;
760        let cfg: RemoteModeConfig = serde_yaml::from_str(yaml).unwrap();
761        assert_eq!(cfg.database_url.as_deref(), Some("postgres://aasm@db.internal/aasm"));
762        assert_eq!(cfg.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
763        assert!(cfg.tls.is_none());
764        assert!(cfg.redis_url.is_none());
765    }
766
767    #[test]
768    fn agent_connect_config_default_points_at_localhost() {
769        let cfg = AgentConnectConfig::default();
770        assert_eq!(cfg.gateway_url, "http://localhost:7391");
771        assert!(cfg.api_key.is_none());
772    }
773
774    #[cfg(feature = "serde")]
775    #[test]
776    fn agent_connect_config_yaml_round_trip() {
777        let yaml = r#"
778gateway_url: "https://cp.company.internal:7391"
779api_key: "secret"
780"#;
781        let cfg: AgentConnectConfig = serde_yaml::from_str(yaml).unwrap();
782        assert_eq!(cfg.gateway_url, "https://cp.company.internal:7391");
783        assert_eq!(cfg.api_key.as_deref(), Some("secret"));
784    }
785
786    #[test]
787    fn gateway_config_default_uses_local_mode_and_documented_defaults() {
788        let cfg = GatewayConfig::default();
789        assert_eq!(cfg.mode, DeploymentMode::Local);
790        assert_eq!(cfg.local.port, 7391);
791        assert_eq!(cfg.remote.listen_addr, SocketAddr::from(([0, 0, 0, 0], 7391)));
792        assert_eq!(cfg.agent.gateway_url, "http://localhost:7391");
793    }
794
795    #[cfg(feature = "serde")]
796    #[test]
797    fn gateway_config_from_yaml_str_parses_full_epic_example() {
798        let yaml = r#"
799mode: remote
800local:
801  port: 8080
802  dashboard: false
803  storage_path: ~/.aasm/dev.db
804remote:
805  listen_addr: "127.0.0.1:7391"
806  tls:
807    cert_file: /etc/aasm/tls.crt
808    key_file: /etc/aasm/tls.key
809  database_url: "postgres://aasm@db.internal/aasm"
810  redis_url: "redis://redis.internal:6379"
811agent:
812  gateway_url: "https://cp.company.internal:7391"
813  api_key: "secret"
814"#;
815        let cfg = GatewayConfig::from_yaml_str(yaml).expect("valid YAML should parse");
816        assert_eq!(cfg.mode, DeploymentMode::Remote);
817        assert_eq!(cfg.local.port, 8080);
818        assert!(!cfg.local.dashboard);
819        assert_eq!(cfg.remote.listen_addr, SocketAddr::from(([127, 0, 0, 1], 7391)));
820        let tls = cfg.remote.tls.expect("tls present");
821        assert_eq!(tls.cert_file, PathBuf::from("/etc/aasm/tls.crt"));
822        assert_eq!(tls.key_file, PathBuf::from("/etc/aasm/tls.key"));
823        assert_eq!(
824            cfg.remote.database_url.as_deref(),
825            Some("postgres://aasm@db.internal/aasm")
826        );
827        assert_eq!(cfg.agent.api_key.as_deref(), Some("secret"));
828    }
829
830    #[cfg(feature = "serde")]
831    #[test]
832    fn gateway_config_from_yaml_str_empty_doc_returns_default() {
833        let cfg = GatewayConfig::from_yaml_str("{}").unwrap();
834        assert_eq!(cfg, GatewayConfig::default());
835    }
836
837    #[cfg(feature = "serde")]
838    #[test]
839    fn gateway_config_load_from_missing_path_returns_default() {
840        let missing = std::env::temp_dir().join("aasm-config-does-not-exist-AAASM-1691.yaml");
841        // Make sure the test pre-condition holds even if a stale file lingers.
842        let _ = std::fs::remove_file(&missing);
843        let cfg = GatewayConfig::load_from_path(&missing).expect("missing file should not error");
844        assert_eq!(cfg, GatewayConfig::default());
845    }
846
847    #[cfg(feature = "serde")]
848    #[test]
849    fn gateway_config_load_from_existing_path_parses_yaml() {
850        let tmp_dir = std::env::temp_dir().join("aasm-config-AAASM-1691");
851        std::fs::create_dir_all(&tmp_dir).unwrap();
852        let path = tmp_dir.join("config.yaml");
853        std::fs::write(&path, "mode: remote\n").unwrap();
854        let cfg = GatewayConfig::load_from_path(&path).expect("existing file should parse");
855        assert_eq!(cfg.mode, DeploymentMode::Remote);
856        std::fs::remove_file(&path).ok();
857    }
858
859    #[test]
860    fn expand_paths_in_resolves_tilde_in_storage_path() {
861        let mut cfg = GatewayConfig::default();
862        let fake_home = PathBuf::from("/srv/dev/bryant");
863        cfg.expand_paths_in(&fake_home);
864        assert_eq!(cfg.local.storage_path, PathBuf::from("/srv/dev/bryant/.aasm/local.db"));
865    }
866
867    #[test]
868    fn expand_paths_in_resolves_tilde_in_tls_paths() {
869        let mut cfg = GatewayConfig::default();
870        cfg.remote.tls = Some(TlsConfig {
871            cert_file: PathBuf::from("~/secrets/tls.crt"),
872            key_file: PathBuf::from("~/secrets/tls.key"),
873        });
874        let fake_home = PathBuf::from("/srv/dev/bryant");
875        cfg.expand_paths_in(&fake_home);
876        let tls = cfg.remote.tls.unwrap();
877        assert_eq!(tls.cert_file, PathBuf::from("/srv/dev/bryant/secrets/tls.crt"));
878        assert_eq!(tls.key_file, PathBuf::from("/srv/dev/bryant/secrets/tls.key"));
879    }
880
881    #[test]
882    fn expand_paths_in_is_idempotent() {
883        let mut cfg = GatewayConfig::default();
884        let fake_home = PathBuf::from("/srv/dev/bryant");
885        cfg.expand_paths_in(&fake_home);
886        let after_first = cfg.local.storage_path.clone();
887        cfg.expand_paths_in(&fake_home);
888        assert_eq!(cfg.local.storage_path, after_first, "second call must be a no-op");
889    }
890
891    #[test]
892    fn expand_paths_in_leaves_absolute_paths_alone() {
893        let mut cfg = GatewayConfig::default();
894        cfg.local.storage_path = PathBuf::from("/var/lib/aasm.db");
895        cfg.expand_paths_in(&PathBuf::from("/srv/dev/bryant"));
896        assert_eq!(cfg.local.storage_path, PathBuf::from("/var/lib/aasm.db"));
897    }
898
899    /// Helper for env-override tests — builds a closure backed by a
900    /// `HashMap`. Keeps test bodies short without bumping into the
901    /// borrow checker when mapping `&[(&str, &str)]` over `&str` keys.
902    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
903        let map: std::collections::HashMap<String, String> = pairs
904            .iter()
905            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
906            .collect();
907        move |key| map.get(key).cloned()
908    }
909
910    #[test]
911    fn apply_env_overrides_aa_mode_remote_promotes_mode() {
912        let mut cfg = GatewayConfig::default();
913        cfg.apply_env_overrides_with(env(&[("AA_MODE", "remote")])).unwrap();
914        assert_eq!(cfg.mode, DeploymentMode::Remote);
915    }
916
917    #[test]
918    fn apply_env_overrides_aa_mode_invalid_returns_named_error() {
919        let mut cfg = GatewayConfig::default();
920        let err = cfg
921            .apply_env_overrides_with(env(&[("AA_MODE", "foobar")]))
922            .expect_err("invalid value must return Err");
923        // The message must include both the env-var name and the bad value
924        // so operators can grep startup logs.
925        let msg = format!("{err}");
926        assert!(matches!(err, ConfigError::InvalidMode { ref raw } if raw == "foobar"));
927        assert!(msg.contains("AA_MODE"), "message should name the var: {msg}");
928        assert!(msg.contains("foobar"), "message should include the value: {msg}");
929    }
930
931    #[test]
932    fn apply_env_overrides_port_updates_local_and_remote() {
933        let mut cfg = GatewayConfig::default();
934        cfg.apply_env_overrides_with(env(&[("AAASM_GATEWAY_PORT", "8080")]))
935            .unwrap();
936        assert_eq!(cfg.local.port, 8080);
937        assert_eq!(cfg.remote.listen_addr.port(), 8080);
938        // The bind address (only the port should change) keeps 0.0.0.0.
939        assert_eq!(cfg.remote.listen_addr.ip().to_string(), "0.0.0.0");
940    }
941
942    #[test]
943    fn apply_env_overrides_port_invalid_returns_named_error() {
944        let mut cfg = GatewayConfig::default();
945        let err = cfg
946            .apply_env_overrides_with(env(&[("AAASM_GATEWAY_PORT", "not-a-number")]))
947            .expect_err("non-numeric port must return Err");
948        let msg = format!("{err}");
949        assert!(matches!(err, ConfigError::InvalidPort { ref raw } if raw == "not-a-number"));
950        assert!(msg.contains("AAASM_GATEWAY_PORT"));
951        assert!(msg.contains("not-a-number"));
952    }
953
954    #[test]
955    fn apply_env_overrides_database_url_targets_storage_postgres() {
956        let mut cfg = GatewayConfig::default();
957        cfg.apply_env_overrides_with(env(&[("AAASM_DATABASE_URL", "postgres://aasm@db/aasm")]))
958            .unwrap();
959        assert_eq!(
960            cfg.storage.postgres.database_url.as_deref(),
961            Some("postgres://aasm@db/aasm"),
962        );
963        // Legacy remote.database_url is untouched (removed in E18 S-I).
964        assert!(cfg.remote.database_url.is_none());
965    }
966
967    #[test]
968    fn apply_env_overrides_redis_url_targets_storage_redis() {
969        let mut cfg = GatewayConfig::default();
970        cfg.apply_env_overrides_with(env(&[("AAASM_REDIS_URL", "redis://redis:6379")]))
971            .unwrap();
972        assert_eq!(cfg.storage.redis.url.as_deref(), Some("redis://redis:6379"));
973        // Legacy remote.redis_url is untouched (removed in E18 S-I).
974        assert!(cfg.remote.redis_url.is_none());
975    }
976
977    #[test]
978    fn apply_env_overrides_tls_creates_config_when_yaml_omitted_it() {
979        let mut cfg = GatewayConfig::default();
980        assert!(cfg.remote.tls.is_none(), "precondition: TLS off by default");
981        cfg.apply_env_overrides_with(env(&[
982            ("AAASM_TLS_CERT", "/etc/aasm/tls.crt"),
983            ("AAASM_TLS_KEY", "/etc/aasm/tls.key"),
984        ]))
985        .unwrap();
986        let tls = cfg.remote.tls.expect("TLS env vars must create TlsConfig");
987        assert_eq!(tls.cert_file, PathBuf::from("/etc/aasm/tls.crt"));
988        assert_eq!(tls.key_file, PathBuf::from("/etc/aasm/tls.key"));
989    }
990
991    #[test]
992    fn apply_env_overrides_tls_patches_existing_config_asymmetrically() {
993        let mut cfg = GatewayConfig::default();
994        cfg.remote.tls = Some(TlsConfig {
995            cert_file: PathBuf::from("/old/tls.crt"),
996            key_file: PathBuf::from("/old/tls.key"),
997        });
998        // Only AAASM_TLS_CERT set — key should keep its old path.
999        cfg.apply_env_overrides_with(env(&[("AAASM_TLS_CERT", "/new/tls.crt")]))
1000            .unwrap();
1001        let tls = cfg.remote.tls.expect("tls preserved");
1002        assert_eq!(tls.cert_file, PathBuf::from("/new/tls.crt"));
1003        assert_eq!(tls.key_file, PathBuf::from("/old/tls.key"), "key untouched");
1004    }
1005
1006    #[cfg(feature = "serde")]
1007    #[test]
1008    fn empty_yaml_hydrates_storage_defaults() {
1009        let cfg = GatewayConfig::from_yaml_str("{}").expect("empty YAML must parse");
1010        let s = &cfg.storage;
1011        assert_eq!(s.backend, StorageBackendType::Sqlite, "default backend");
1012        assert_eq!(
1013            s.sqlite.path,
1014            PathBuf::from("~/.aasm/local.db"),
1015            "sqlite path un-expanded by default",
1016        );
1017        assert_eq!(s.sqlite.journal_mode, "wal");
1018        assert!(s.postgres.database_url.is_none(), "postgres url unset");
1019        assert_eq!(s.postgres.max_connections, 20);
1020        assert_eq!(s.postgres.min_connections, 2);
1021        assert_eq!(s.postgres.connect_timeout_secs, 10);
1022        assert!(s.postgres.timescaledb.enabled);
1023        assert_eq!(s.postgres.timescaledb.chunk_interval, "7 days");
1024        assert_eq!(s.postgres.timescaledb.compression_policy, "30 days");
1025        assert!(!s.redis.enabled, "redis opt-in");
1026        assert!(s.redis.url.is_none());
1027        assert_eq!(s.redis.policy_cache_ttl_secs, 30);
1028        assert_eq!(s.redis.max_connections, 10);
1029        assert_eq!(s.retention.hot_days, 30);
1030        assert_eq!(s.retention.warm_days, 90);
1031        assert_eq!(s.retention.cold_action, ColdAction::Drop);
1032        assert!(s.retention.archive_url.is_none());
1033        assert_eq!(s.retention.schedule, "0 3 * * *");
1034        assert!(!s.retention.dry_run);
1035    }
1036
1037    #[test]
1038    fn apply_env_overrides_storage_matrix() {
1039        let mut cfg = GatewayConfig::default();
1040        cfg.apply_env_overrides_with(env(&[
1041            ("AAASM_STORAGE_BACKEND", "postgres"),
1042            ("AAASM_DATABASE_URL", "postgres://aasm@prod/aasm"),
1043            ("AAASM_REDIS_URL", "redis://prod:6379"),
1044            ("AAASM_SQLITE_PATH", "/var/lib/aasm.db"),
1045            ("AAASM_RETENTION_HOT_DAYS", "7"),
1046            ("AAASM_RETENTION_COLD_ACTION", "archive"),
1047        ]))
1048        .unwrap();
1049        assert_eq!(cfg.storage.backend, StorageBackendType::Postgres);
1050        assert_eq!(
1051            cfg.storage.postgres.database_url.as_deref(),
1052            Some("postgres://aasm@prod/aasm"),
1053        );
1054        assert_eq!(cfg.storage.redis.url.as_deref(), Some("redis://prod:6379"));
1055        assert_eq!(cfg.storage.sqlite.path, PathBuf::from("/var/lib/aasm.db"));
1056        assert_eq!(cfg.storage.retention.hot_days, 7);
1057        assert_eq!(cfg.storage.retention.cold_action, ColdAction::Archive);
1058    }
1059
1060    #[test]
1061    fn apply_env_overrides_storage_backend_invalid_returns_named_error() {
1062        let mut cfg = GatewayConfig::default();
1063        let err = cfg
1064            .apply_env_overrides_with(env(&[("AAASM_STORAGE_BACKEND", "mysql")]))
1065            .expect_err("unsupported backend must return Err");
1066        let msg = format!("{err}");
1067        assert!(matches!(err, ConfigError::InvalidStorageBackend { ref raw } if raw == "mysql"));
1068        assert!(msg.contains("AAASM_STORAGE_BACKEND"));
1069        assert!(msg.contains("mysql"));
1070        assert!(msg.contains("sqlite") && msg.contains("postgres"));
1071    }
1072
1073    #[test]
1074    fn apply_env_overrides_cold_action_invalid_returns_named_error() {
1075        let mut cfg = GatewayConfig::default();
1076        let err = cfg
1077            .apply_env_overrides_with(env(&[("AAASM_RETENTION_COLD_ACTION", "tombstone")]))
1078            .expect_err("unsupported cold_action must return Err");
1079        assert!(matches!(err, ConfigError::InvalidColdAction { ref raw } if raw == "tombstone"));
1080    }
1081
1082    #[test]
1083    fn apply_env_overrides_retention_hot_days_invalid_returns_named_error() {
1084        let mut cfg = GatewayConfig::default();
1085        let err = cfg
1086            .apply_env_overrides_with(env(&[("AAASM_RETENTION_HOT_DAYS", "thirty")]))
1087            .expect_err("non-numeric hot_days must return Err");
1088        assert!(matches!(
1089            err,
1090            ConfigError::InvalidUnsignedInt {
1091                var: "AAASM_RETENTION_HOT_DAYS",
1092                ref raw,
1093            } if raw == "thirty"
1094        ));
1095    }
1096
1097    #[test]
1098    fn validate_archive_without_url_fails_with_documented_message() {
1099        let mut cfg = GatewayConfig::default();
1100        cfg.storage.retention.cold_action = ColdAction::Archive;
1101        cfg.storage.retention.archive_url = None;
1102        let err = cfg
1103            .validate()
1104            .expect_err("cold_action = Archive without archive_url must fail");
1105        assert!(matches!(err, ConfigError::ArchiveUrlRequired));
1106        assert_eq!(format!("{err}"), "archive_url is required when cold_action is archive",);
1107    }
1108
1109    #[test]
1110    fn validate_archive_with_url_passes() {
1111        let mut cfg = GatewayConfig::default();
1112        cfg.storage.retention.cold_action = ColdAction::Archive;
1113        cfg.storage.retention.archive_url = Some("s3://aasm-archive/".into());
1114        cfg.validate().expect("archive + url must validate");
1115    }
1116
1117    #[test]
1118    fn validate_warm_days_must_be_greater_than_hot_days() {
1119        let mut cfg = GatewayConfig::default();
1120        cfg.storage.retention.hot_days = 60;
1121        cfg.storage.retention.warm_days = 30; // < hot_days
1122        let err = cfg.validate().expect_err("warm_days <= hot_days must fail");
1123        assert!(matches!(
1124            err,
1125            ConfigError::WarmDaysNotGreaterThanHotDays { hot: 60, warm: 30 }
1126        ));
1127        assert_eq!(format!("{err}"), "warm_days (30) must be greater than hot_days (60)",);
1128    }
1129
1130    #[test]
1131    fn validate_warm_days_equal_to_hot_days_also_fails() {
1132        let mut cfg = GatewayConfig::default();
1133        cfg.storage.retention.hot_days = 30;
1134        cfg.storage.retention.warm_days = 30; // == hot_days
1135        let err = cfg
1136            .validate()
1137            .expect_err("warm_days == hot_days must fail (strict inequality)");
1138        assert!(matches!(
1139            err,
1140            ConfigError::WarmDaysNotGreaterThanHotDays { hot: 30, warm: 30 }
1141        ));
1142    }
1143
1144    #[test]
1145    fn resolve_storage_backend_defaults_to_sqlite_in_local_mode() {
1146        // Default mode is already Local; backend_explicit stays false
1147        // since it's only flipped via YAML peek or AAASM_STORAGE_BACKEND.
1148        let mut cfg = GatewayConfig {
1149            mode: DeploymentMode::Local,
1150            ..GatewayConfig::default()
1151        };
1152        cfg.resolve_storage_backend();
1153        assert_eq!(cfg.storage.backend, StorageBackendType::Sqlite);
1154    }
1155
1156    #[test]
1157    fn resolve_storage_backend_defaults_to_postgres_in_remote_mode() {
1158        let mut cfg = GatewayConfig {
1159            mode: DeploymentMode::Remote,
1160            ..GatewayConfig::default()
1161        };
1162        cfg.resolve_storage_backend();
1163        assert_eq!(cfg.storage.backend, StorageBackendType::Postgres);
1164    }
1165
1166    #[test]
1167    fn resolve_storage_backend_respects_explicit_choice() {
1168        // Operator pinned sqlite in remote mode (e.g. in-process test
1169        // runner pointed at the remote API surface) — resolver must
1170        // leave it alone.
1171        let mut cfg = GatewayConfig {
1172            mode: DeploymentMode::Remote,
1173            ..GatewayConfig::default()
1174        };
1175        cfg.storage.backend = StorageBackendType::Sqlite;
1176        cfg.storage.backend_explicit = true;
1177        cfg.resolve_storage_backend();
1178        assert_eq!(cfg.storage.backend, StorageBackendType::Sqlite);
1179    }
1180
1181    #[test]
1182    fn expand_paths_in_resolves_tilde_in_storage_sqlite_path() {
1183        let mut cfg = GatewayConfig::default();
1184        assert_eq!(cfg.storage.sqlite.path, PathBuf::from("~/.aasm/local.db"));
1185        let fake_home = PathBuf::from("/srv/dev/bryant");
1186        cfg.expand_paths_in(&fake_home);
1187        assert_eq!(cfg.storage.sqlite.path, PathBuf::from("/srv/dev/bryant/.aasm/local.db"),);
1188    }
1189}