1use std::collections::HashMap;
4use std::time::Duration;
5
6#[derive(Debug, Clone)]
7pub struct Config {
8 pub addr: String,
9 pub metrics_addr: String,
10 pub routes_path: String,
11 pub pricing_path: String,
12 pub ledger_backends: Vec<LedgerBackend>,
13 pub default_tenant: String,
14 pub request_timeout: Duration,
15 pub stream_idle_timeout: Duration,
16 pub embed_default_input_per_mtok: f64,
17 pub env: HashMap<String, String>,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum LedgerBackend {
23 Sqlite,
24 Postgres,
25 Pubsub,
26 Sns,
27}
28
29impl LedgerBackend {
30 pub fn label(self) -> &'static str {
31 match self {
32 LedgerBackend::Sqlite => "sqlite",
33 LedgerBackend::Postgres => "postgres",
34 LedgerBackend::Pubsub => "pubsub",
35 LedgerBackend::Sns => "sns",
36 }
37 }
38}
39
40fn parse_ledger_backends(list: &str) -> anyhow::Result<Vec<LedgerBackend>> {
41 let mut out: Vec<LedgerBackend> = Vec::new();
42 for raw in list.split(',') {
43 let name = raw.trim().to_ascii_lowercase();
44 if name.is_empty() {
45 continue;
46 }
47 let b = match name.as_str() {
48 "sqlite" => LedgerBackend::Sqlite,
49 "postgres" => LedgerBackend::Postgres,
50 "pubsub" => LedgerBackend::Pubsub,
51 "sns" => LedgerBackend::Sns,
52 other => anyhow::bail!("unknown ledger backend '{other}' (sqlite|postgres|pubsub|sns)"),
53 };
54 if out.contains(&b) {
55 anyhow::bail!("duplicate ledger backend '{name}' in SYNAPSE_LEDGER_BACKENDS");
56 }
57 out.push(b);
58 }
59 if out.is_empty() {
60 anyhow::bail!("SYNAPSE_LEDGER_BACKENDS resolved to an empty backend list");
61 }
62 Ok(out)
63}
64
65pub fn vertex_project_from_env(env: &HashMap<String, String>) -> Option<String> {
68 ["VERTEX_PROJECT_ID", "VERTEX_PROJECT"]
69 .into_iter()
70 .find_map(|key| {
71 env.get(key)
72 .map(|s| s.trim())
73 .filter(|s| !s.is_empty())
74 .map(str::to_string)
75 })
76}
77
78impl Config {
79 pub fn from_env_map(env: &HashMap<String, String>) -> anyhow::Result<Self> {
80 let get = |k: &str| env.get(k).cloned().filter(|s| !s.trim().is_empty());
81 let get_or = |k: &str, d: &str| get(k).unwrap_or_else(|| d.to_string());
82 let backends_raw = get("SYNAPSE_LEDGER_BACKENDS")
83 .or_else(|| get("SYNAPSE_LEDGER_BACKEND"))
84 .unwrap_or_else(|| "sqlite".to_string());
85 let ledger_backends = parse_ledger_backends(&backends_raw)?;
86 Ok(Self {
87 addr: get_or("SYNAPSE_ADDR", "0.0.0.0:8080"),
88 metrics_addr: get_or("SYNAPSE_METRICS_ADDR", "0.0.0.0:9090"),
89 routes_path: get_or("SYNAPSE_ROUTES_PATH", "config/routes.toml"),
90 pricing_path: get_or("SYNAPSE_PRICING_PATH", "config/pricing.toml"),
91 ledger_backends,
92 default_tenant: get_or("SYNAPSE_DEFAULT_TENANT", "unattributed"),
93 request_timeout: Duration::from_secs(
94 get_or("SYNAPSE_REQUEST_TIMEOUT_SECS", "120")
95 .parse()
96 .map_err(|e| anyhow::anyhow!("SYNAPSE_REQUEST_TIMEOUT_SECS: {e}"))?,
97 ),
98 stream_idle_timeout: Duration::from_secs(
99 get_or("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS", "60")
100 .parse()
101 .map_err(|e| anyhow::anyhow!("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS: {e}"))?,
102 ),
103 embed_default_input_per_mtok: get_or(
104 "SYNAPSE_EMBED_DEFAULT_INPUT_PRICE_PER_MTOK",
105 "0.10",
106 )
107 .parse()
108 .unwrap_or(0.10),
109 env: env.clone(),
110 })
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
119 pairs
120 .iter()
121 .map(|(k, v)| (k.to_string(), v.to_string()))
122 .collect()
123 }
124
125 #[test]
126 fn vertex_project_from_env_prefers_project_id() {
127 let env = env(&[
128 ("VERTEX_PROJECT_ID", "from-id"),
129 ("VERTEX_PROJECT", "from-legacy"),
130 ]);
131 assert_eq!(vertex_project_from_env(&env).as_deref(), Some("from-id"));
132 }
133
134 #[test]
135 fn vertex_project_from_env_falls_back_to_vertex_project() {
136 let env = env(&[("VERTEX_PROJECT", "legacy-only")]);
137 assert_eq!(
138 vertex_project_from_env(&env).as_deref(),
139 Some("legacy-only")
140 );
141 }
142
143 #[test]
144 fn vertex_project_from_env_ignores_blank_values() {
145 let env = env(&[("VERTEX_PROJECT_ID", " "), ("VERTEX_PROJECT", "ok")]);
146 assert_eq!(vertex_project_from_env(&env).as_deref(), Some("ok"));
147 }
148
149 #[test]
150 fn defaults_apply_when_env_empty() {
151 let c = Config::from_env_map(&env(&[])).unwrap();
152 assert_eq!(c.addr, "0.0.0.0:8080");
153 assert_eq!(c.ledger_backends, vec![LedgerBackend::Sqlite]);
154 assert_eq!(c.default_tenant, "unattributed");
155 }
156
157 #[test]
158 fn env_overrides_and_validates_backend() {
159 let c = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "postgres")])).unwrap();
160 assert_eq!(c.ledger_backends, vec![LedgerBackend::Postgres]);
161 let err = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "mysql")])).unwrap_err();
162 assert!(err.to_string().contains("sqlite|postgres"));
163 }
164
165 #[test]
166 fn parses_stream_timeouts() {
167 let c = Config::from_env_map(&env(&[
168 ("SYNAPSE_REQUEST_TIMEOUT_SECS", "30"),
169 ("SYNAPSE_STREAM_IDLE_TIMEOUT_SECS", "45"),
170 ]))
171 .unwrap();
172 assert_eq!(c.request_timeout, std::time::Duration::from_secs(30));
173 assert_eq!(c.stream_idle_timeout, std::time::Duration::from_secs(45));
174 }
175
176 #[test]
177 fn parses_backend_list() {
178 let c =
179 Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "postgres, pubsub")])).unwrap();
180 assert_eq!(
181 c.ledger_backends,
182 vec![LedgerBackend::Postgres, LedgerBackend::Pubsub]
183 );
184 }
185
186 #[test]
187 fn singular_backend_is_back_compat() {
188 let c = Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKEND", "sns")])).unwrap();
189 assert_eq!(c.ledger_backends, vec![LedgerBackend::Sns]);
190 }
191
192 #[test]
193 fn defaults_to_sqlite_list() {
194 let c = Config::from_env_map(&env(&[])).unwrap();
195 assert_eq!(c.ledger_backends, vec![LedgerBackend::Sqlite]);
196 }
197
198 #[test]
199 fn rejects_unknown_and_duplicate_backends() {
200 assert!(Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "mysql")])).is_err());
201 assert!(
202 Config::from_env_map(&env(&[("SYNAPSE_LEDGER_BACKENDS", "pubsub,pubsub")])).is_err()
203 );
204 }
205}