Skip to main content

synapse/
config.rs

1//! Layered runtime config (env + file paths). Env takes precedence.
2
3use 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    /// Provider credentials/base-urls, read straight from the env map.
18    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
65/// Resolve the GCP project id for Vertex from an env map.
66/// `VERTEX_PROJECT_ID` is preferred; `VERTEX_PROJECT` is accepted for compatibility.
67pub 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}