1use std::net::SocketAddr;
9use std::path::PathBuf;
10
11#[derive(Debug, thiserror::Error)]
18pub enum ConfigError {
19 #[error("failed to read config file: {0}")]
22 Io(#[from] std::io::Error),
23 #[error("failed to parse config YAML: {0}")]
25 Yaml(#[from] serde_yaml::Error),
26 #[error("invalid AA_MODE value: '{raw}' (expected 'local' or 'remote')")]
28 InvalidMode {
29 raw: String,
31 },
32 #[error("invalid AAASM_GATEWAY_PORT value: '{raw}' (expected u16)")]
34 InvalidPort {
35 raw: String,
37 },
38 #[error("invalid AAASM_STORAGE_BACKEND value: '{raw}' (expected 'sqlite' or 'postgres')")]
41 InvalidStorageBackend {
42 raw: String,
44 },
45 #[error("invalid AAASM_RETENTION_COLD_ACTION value: '{raw}' (expected 'drop' or 'archive')")]
48 InvalidColdAction {
49 raw: String,
51 },
52 #[error("invalid {var} value: '{raw}' (expected non-negative integer)")]
55 InvalidUnsignedInt {
56 var: &'static str,
59 raw: String,
61 },
62 #[error("archive_url is required when cold_action is archive")]
65 ArchiveUrlRequired,
66 #[error("warm_days ({warm}) must be greater than hot_days ({hot})")]
69 WarmDaysNotGreaterThanHotDays {
70 hot: u32,
72 warm: u32,
74 },
75}
76
77#[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 #[default]
93 Local,
94 Remote,
99}
100
101#[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 pub port: u16,
112 pub dashboard: bool,
114 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#[derive(Debug, Clone, PartialEq, Eq)]
134#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
135pub struct TlsConfig {
136 pub cert_file: PathBuf,
138 pub key_file: PathBuf,
140}
141
142#[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 pub listen_addr: SocketAddr,
153 pub tls: Option<TlsConfig>,
155 pub database_url: Option<String>,
157 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#[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 pub gateway_url: String,
183 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#[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 #[default]
208 Drop,
209 Archive,
212}
213
214#[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 pub hot_days: u32,
227 pub warm_days: u32,
229 pub cold_action: ColdAction,
231 pub archive_url: Option<String>,
233 pub schedule: String,
235 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#[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 pub enabled: bool,
269 pub chunk_interval: String,
271 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#[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 pub database_url: Option<String>,
300 pub max_connections: u32,
302 pub min_connections: u32,
304 pub connect_timeout_secs: u64,
306 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#[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 pub path: PathBuf,
335 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#[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 pub enabled: bool,
360 pub url: Option<String>,
364 pub policy_cache_ttl_secs: u64,
366 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#[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 #[default]
395 Sqlite,
396 Postgres,
398}
399
400#[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 pub backend: StorageBackendType,
413 pub sqlite: SqliteConfig,
415 pub postgres: PostgresConfig,
417 pub redis: RedisConfig,
419 pub retention: RetentionConfig,
421 #[cfg_attr(feature = "serde", serde(skip))]
431 pub(crate) backend_explicit: bool,
432}
433
434#[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 pub mode: DeploymentMode,
445 pub local: LocalModeConfig,
447 pub remote: RemoteModeConfig,
449 pub agent: AgentConnectConfig,
451 pub storage: StorageConfig,
453}
454
455#[cfg(feature = "serde")]
456impl GatewayConfig {
457 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 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 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 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 pub fn expand_paths(&mut self) {
526 if let Some(home) = dirs::home_dir() {
527 self.expand_paths_in(&home);
528 }
529 }
530
531 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#[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 pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
571 self.apply_env_overrides_with(|key| std::env::var(key).ok())
572 }
573
574 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 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 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 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 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 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 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 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 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 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 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; 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; 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 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 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}