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 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 self.apply_storage_env_overrides(&get_env)?;
594 self.apply_tls_env_overrides(&get_env);
595 Ok(())
596 }
597
598 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 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 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 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 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 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 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 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 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 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 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 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 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; 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; 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 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 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}