Skip to main content

gobby_core/
config.rs

1//! Shared configuration-resolution boundary.
2//!
3//! This module is the public home for lightweight configuration contracts that
4//! are shared across Gobby Rust crates. Concrete service resolution is added in
5//! focused follow-up modules so this baseline crate remains small.
6
7/// FalkorDB connection configuration.
8///
9/// Graph name selection is consumer-owned.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct FalkorConfig {
12    pub host: String,
13    pub port: u16,
14    pub password: Option<String>,
15}
16
17/// Qdrant connection configuration.
18///
19/// Collection naming is consumer-owned.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct QdrantConfig {
22    pub url: Option<String>,
23    pub api_key: Option<String>,
24}
25
26/// Embedding API configuration for an OpenAI-compatible endpoint.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct EmbeddingConfig {
29    pub api_base: String,
30    pub model: String,
31    pub api_key: Option<String>,
32}
33
34const FALKORDB_DEFAULT_PORT: u16 = 16379;
35const EMBEDDING_DEFAULT_MODEL: &str = "nomic-embed-text";
36
37#[cfg(test)]
38pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
39
40/// Decode a config_store value from its stored representation.
41pub fn decode_config_value(raw: &str) -> Option<String> {
42    match serde_json::from_str::<serde_json::Value>(raw) {
43        Ok(serde_json::Value::String(value)) => Some(value),
44        Ok(value @ (serde_json::Value::Array(_) | serde_json::Value::Object(_))) => {
45            Some(serde_json::to_string(&value).unwrap_or_else(|_| raw.to_string()))
46        }
47        Ok(serde_json::Value::Null) => None,
48        Ok(value) => Some(value.to_string()),
49        Err(_) => Some(raw.to_string()),
50    }
51}
52
53/// Resolve `${VAR}` and `${VAR:-default}` environment variable patterns.
54pub fn resolve_env_pattern(value: &str) -> anyhow::Result<Option<String>> {
55    if !value.contains("${") {
56        return Ok(Some(value.to_string()));
57    }
58
59    let mut output = String::with_capacity(value.len());
60    let mut rest = value;
61    let mut unresolved = false;
62
63    while let Some(start) = rest.find("${") {
64        output.push_str(&rest[..start]);
65        let pattern = &rest[start + 2..];
66        let Some(end) = pattern.find('}') else {
67            anyhow::bail!("unterminated environment pattern in `{value}`");
68        };
69
70        let expression = &pattern[..end];
71        if expression.is_empty() {
72            anyhow::bail!("empty environment pattern in `{value}`");
73        }
74
75        let (name, default) = match expression.split_once(":-") {
76            Some((name, default)) => (name, Some(default)),
77            None => (expression, None),
78        };
79        if name.is_empty() {
80            anyhow::bail!("empty environment variable name in `{value}`");
81        }
82
83        match std::env::var(name) {
84            Ok(current) if !(current.is_empty() && default.is_some()) => {
85                output.push_str(&current);
86            }
87            Ok(_) | Err(std::env::VarError::NotPresent) => match default {
88                Some(default) => output.push_str(default),
89                None => unresolved = true,
90            },
91            Err(std::env::VarError::NotUnicode(_)) => {
92                anyhow::bail!("environment variable `{name}` is not valid unicode");
93            }
94        }
95
96        rest = &pattern[end + 1..];
97    }
98
99    output.push_str(rest);
100    if unresolved {
101        Ok(None)
102    } else {
103        Ok(Some(output))
104    }
105}
106
107/// Source for config values and interpolation.
108pub trait ConfigSource {
109    /// Read a decoded config value by key.
110    fn config_value(&mut self, key: &str) -> Option<String>;
111
112    /// Resolve interpolation patterns in a config value.
113    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String>;
114}
115
116/// Environment-only source for consumers without database access.
117pub struct EnvOnlySource;
118
119impl ConfigSource for EnvOnlySource {
120    fn config_value(&mut self, _key: &str) -> Option<String> {
121        None
122    }
123
124    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
125        if value.contains("$secret:") {
126            anyhow::bail!("secret resolution requires a datastore-backed config source");
127        }
128        resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
129    }
130}
131
132/// Resolve FalkorDB config from env, config_store, then defaults.
133pub fn resolve_falkordb_config(source: &mut impl ConfigSource) -> Option<FalkorConfig> {
134    let host = resolve_setting(source, "GOBBY_FALKORDB_HOST", "databases.falkordb.host")?;
135    let port = resolve_port(
136        source,
137        "GOBBY_FALKORDB_PORT",
138        "databases.falkordb.port",
139        FALKORDB_DEFAULT_PORT,
140    );
141    let password = resolve_setting(
142        source,
143        "GOBBY_FALKORDB_PASSWORD",
144        "databases.falkordb.requirepass",
145    );
146
147    Some(FalkorConfig {
148        host,
149        port,
150        password,
151    })
152}
153
154/// Resolve Qdrant config from env and config_store.
155pub fn resolve_qdrant_config(source: &mut impl ConfigSource) -> Option<QdrantConfig> {
156    let url = resolve_setting(source, "GOBBY_QDRANT_URL", "databases.qdrant.url");
157    url.as_ref()?;
158    let api_key = resolve_setting(source, "GOBBY_QDRANT_API_KEY", "databases.qdrant.api_key");
159
160    Some(QdrantConfig { url, api_key })
161}
162
163/// Resolve embedding API config from env, config_store, then defaults.
164pub fn resolve_embedding_config(source: &mut impl ConfigSource) -> Option<EmbeddingConfig> {
165    let api_base = resolve_setting(source, "GOBBY_EMBEDDING_URL", "embeddings.api_base")?;
166    let model = resolve_setting(source, "GOBBY_EMBEDDING_MODEL", "embeddings.model")
167        .unwrap_or_else(|| EMBEDDING_DEFAULT_MODEL.to_string());
168    let api_key = resolve_setting(source, "GOBBY_EMBEDDING_API_KEY", "embeddings.api_key");
169
170    Some(EmbeddingConfig {
171        api_base,
172        model,
173        api_key,
174    })
175}
176
177fn resolve_setting(
178    source: &mut impl ConfigSource,
179    env_key: &str,
180    config_key: &str,
181) -> Option<String> {
182    let value = env_value(env_key).or_else(|| source.config_value(config_key))?;
183    resolve_non_empty(source, &value)
184}
185
186fn resolve_port(
187    source: &mut impl ConfigSource,
188    env_key: &str,
189    config_key: &str,
190    default: u16,
191) -> u16 {
192    let Some(raw_port) = env_value(env_key).or_else(|| source.config_value(config_key)) else {
193        return default;
194    };
195    let Some(resolved) = resolve_non_empty(source, &raw_port) else {
196        return default;
197    };
198    resolved.parse::<u16>().unwrap_or(default)
199}
200
201fn resolve_non_empty(source: &mut impl ConfigSource, value: &str) -> Option<String> {
202    if value.trim().is_empty() {
203        return None;
204    }
205    source
206        .resolve_value(value)
207        .ok()
208        .filter(|resolved| !resolved.trim().is_empty())
209}
210
211fn env_value(key: &str) -> Option<String> {
212    std::env::var(key)
213        .ok()
214        .filter(|value| !value.trim().is_empty())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::collections::HashMap;
221    use std::sync::MutexGuard;
222
223    struct EnvGuard {
224        _lock: MutexGuard<'static, ()>,
225    }
226
227    impl EnvGuard {
228        fn new() -> Self {
229            let guard = Self {
230                _lock: TEST_ENV_LOCK
231                    .lock()
232                    .unwrap_or_else(|poisoned| poisoned.into_inner()),
233            };
234            guard.clear();
235            guard
236        }
237
238        fn clear(&self) {
239            for key in [
240                "GOBBY_FALKORDB_HOST",
241                "GOBBY_FALKORDB_PORT",
242                "GOBBY_FALKORDB_PASSWORD",
243                "GOBBY_QDRANT_URL",
244                "GOBBY_QDRANT_API_KEY",
245                "GOBBY_EMBEDDING_URL",
246                "GOBBY_EMBEDDING_MODEL",
247                "GOBBY_EMBEDDING_API_KEY",
248                "GOBBY_TEST_PRESENT",
249                "GOBBY_TEST_MISSING",
250            ] {
251                unsafe { std::env::remove_var(key) };
252            }
253        }
254
255        fn set(&self, key: &str, value: &str) {
256            unsafe { std::env::set_var(key, value) };
257        }
258    }
259
260    impl Drop for EnvGuard {
261        fn drop(&mut self) {
262            self.clear();
263        }
264    }
265
266    #[derive(Default)]
267    struct TestSource {
268        values: HashMap<&'static str, String>,
269        resolved_values: Vec<String>,
270    }
271
272    impl TestSource {
273        fn with_values(values: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
274            Self {
275                values: values
276                    .into_iter()
277                    .map(|(key, value)| (key, value.to_string()))
278                    .collect(),
279                resolved_values: Vec::new(),
280            }
281        }
282
283        fn with_raw_values(values: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
284            Self {
285                values: values
286                    .into_iter()
287                    .filter_map(|(key, value)| decode_config_value(value).map(|v| (key, v)))
288                    .collect(),
289                resolved_values: Vec::new(),
290            }
291        }
292    }
293
294    impl ConfigSource for TestSource {
295        fn config_value(&mut self, key: &str) -> Option<String> {
296            self.values.get(key).cloned()
297        }
298
299        fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
300            self.resolved_values.push(value.to_string());
301            if let Some(secret_name) = value.strip_prefix("$secret:") {
302                return Ok(format!("resolved-{secret_name}"));
303            }
304            Ok(resolve_env_pattern(value)?.unwrap_or_else(|| value.to_string()))
305        }
306    }
307
308    #[test]
309    fn decode_config_value_handles_json_and_plain() {
310        assert_eq!(
311            decode_config_value("\"http://host:7474\""),
312            Some("http://host:7474".to_string())
313        );
314        assert_eq!(
315            decode_config_value(r#"["alpha",1,true]"#),
316            Some(r#"["alpha",1,true]"#.to_string())
317        );
318        assert_eq!(
319            decode_config_value(r#"{"host":"falkor.local","port":16379}"#),
320            Some(r#"{"host":"falkor.local","port":16379}"#.to_string())
321        );
322        assert_eq!(decode_config_value("42"), Some("42".to_string()));
323        assert_eq!(decode_config_value("true"), Some("true".to_string()));
324        assert_eq!(
325            decode_config_value("http://plain:7474"),
326            Some("http://plain:7474".to_string())
327        );
328        assert_eq!(decode_config_value("null"), None);
329    }
330
331    #[test]
332    fn resolve_env_pattern_with_defaults() {
333        let env = EnvGuard::new();
334        env.set("GOBBY_TEST_PRESENT", "present-value");
335
336        assert_eq!(
337            resolve_env_pattern("${GOBBY_TEST_PRESENT}").unwrap(),
338            Some("present-value".to_string())
339        );
340        assert_eq!(
341            resolve_env_pattern("prefix-${GOBBY_TEST_PRESENT}-suffix").unwrap(),
342            Some("prefix-present-value-suffix".to_string())
343        );
344        assert_eq!(
345            resolve_env_pattern("${GOBBY_TEST_MISSING:-fallback}").unwrap(),
346            Some("fallback".to_string())
347        );
348        assert_eq!(resolve_env_pattern("${GOBBY_TEST_MISSING}").unwrap(), None);
349        assert_eq!(
350            resolve_env_pattern("plain-value").unwrap(),
351            Some("plain-value".to_string())
352        );
353    }
354
355    #[test]
356    fn env_overrides_config_store() {
357        let env = EnvGuard::new();
358        env.set("GOBBY_FALKORDB_HOST", "env-falkor.local");
359        env.set("GOBBY_FALKORDB_PORT", "17000");
360        env.set("GOBBY_FALKORDB_PASSWORD", "env-pass");
361        env.set("GOBBY_QDRANT_URL", "http://env-qdrant:6333");
362        env.set("GOBBY_QDRANT_API_KEY", "env-qdrant-key");
363
364        let mut source = TestSource::with_values([
365            ("databases.falkordb.host", "stored-falkor.local"),
366            ("databases.falkordb.port", "16000"),
367            ("databases.falkordb.requirepass", "stored-pass"),
368            ("databases.qdrant.url", "http://stored-qdrant:6333"),
369            ("databases.qdrant.api_key", "stored-qdrant-key"),
370        ]);
371
372        let falkordb = resolve_falkordb_config(&mut source).expect("falkordb config");
373        let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
374
375        assert_eq!(falkordb.host, "env-falkor.local");
376        assert_eq!(falkordb.port, 17000);
377        assert_eq!(falkordb.password.as_deref(), Some("env-pass"));
378        assert_eq!(qdrant.url.as_deref(), Some("http://env-qdrant:6333"));
379        assert_eq!(qdrant.api_key.as_deref(), Some("env-qdrant-key"));
380    }
381
382    #[test]
383    fn config_source_handles_secrets() {
384        let _env = EnvGuard::new();
385        let mut source = TestSource::with_values([
386            ("databases.falkordb.host", "falkor.local"),
387            ("databases.falkordb.requirepass", "$secret:FALKOR_PASS"),
388        ]);
389
390        let config = resolve_falkordb_config(&mut source).expect("falkordb config");
391
392        assert_eq!(config.password.as_deref(), Some("resolved-FALKOR_PASS"));
393        assert!(
394            source
395                .resolved_values
396                .iter()
397                .any(|value| value == "$secret:FALKOR_PASS")
398        );
399    }
400
401    #[test]
402    fn env_only_source_rejects_secret_patterns() {
403        let _env = EnvGuard::new();
404        let mut source = EnvOnlySource;
405
406        let error = source
407            .resolve_value("$secret:FALKOR_PASS")
408            .expect_err("secret resolution should require a datastore-backed source");
409
410        assert!(error.to_string().contains("secret resolution"));
411    }
412
413    #[test]
414    fn embedding_url_env_var_is_canonical() {
415        let env = EnvGuard::new();
416        env.set("GOBBY_EMBEDDING_URL", "http://env-embedding:11434");
417
418        let mut source = TestSource::with_values([
419            ("embeddings.api_base", "http://stored-embedding:11434"),
420            ("embeddings.model", "stored-model"),
421        ]);
422
423        let config = resolve_embedding_config(&mut source).expect("embedding config");
424
425        assert_eq!(config.api_base, "http://env-embedding:11434");
426        assert_eq!(config.model, "stored-model");
427    }
428
429    #[test]
430    fn postgres_config_source_resolves_secrets() {
431        let _env = EnvGuard::new();
432
433        struct ConnectionLike {
434            values: HashMap<&'static str, String>,
435            secret_reads: usize,
436        }
437
438        struct PostgresConfigSource<'a> {
439            conn: &'a mut ConnectionLike,
440        }
441
442        impl ConfigSource for PostgresConfigSource<'_> {
443            fn config_value(&mut self, key: &str) -> Option<String> {
444                self.conn.values.get(key).cloned()
445            }
446
447            fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
448                self.conn.secret_reads += 1;
449                Ok(format!("secret::{value}"))
450            }
451        }
452
453        let mut conn = ConnectionLike {
454            values: HashMap::from([
455                (
456                    "embeddings.api_base",
457                    "http://stored-embedding:11434".to_string(),
458                ),
459                ("embeddings.api_key", "$secret:OPENAI_API_KEY".to_string()),
460            ]),
461            secret_reads: 0,
462        };
463        let config = {
464            let mut source = PostgresConfigSource { conn: &mut conn };
465            resolve_embedding_config(&mut source).expect("embedding config")
466        };
467
468        assert_eq!(
469            config.api_key.as_deref(),
470            Some("secret::$secret:OPENAI_API_KEY")
471        );
472        assert_eq!(conn.secret_reads, 2);
473    }
474
475    #[test]
476    fn resolve_config_handles_json_encoded_store_values() {
477        let _env = EnvGuard::new();
478        let mut source = TestSource::with_raw_values([
479            ("databases.falkordb.host", r#""json-falkor.local""#),
480            ("databases.falkordb.port", r#""17001""#),
481            ("databases.falkordb.requirepass", r#""$secret:FALKOR_PASS""#),
482            ("databases.qdrant.url", r#""http://json-qdrant:6333""#),
483            ("databases.qdrant.api_key", r#"["alpha",1]"#),
484            ("embeddings.api_base", r#""http://json-embedding:11434""#),
485            ("embeddings.model", r#"["model",1]"#),
486        ]);
487
488        let falkordb = resolve_falkordb_config(&mut source).expect("falkordb config");
489        let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
490        let embedding = resolve_embedding_config(&mut source).expect("embedding config");
491
492        assert_eq!(falkordb.host, "json-falkor.local");
493        assert_eq!(falkordb.port, 17001);
494        assert_eq!(falkordb.password.as_deref(), Some("resolved-FALKOR_PASS"));
495        assert_eq!(qdrant.url.as_deref(), Some("http://json-qdrant:6333"));
496        assert_eq!(qdrant.api_key.as_deref(), Some(r#"["alpha",1]"#));
497        assert_eq!(embedding.api_base, "http://json-embedding:11434");
498        assert_eq!(embedding.model, r#"["model",1]"#);
499    }
500
501    #[test]
502    fn qdrant_and_embedding_resolution_order() {
503        {
504            let env = EnvGuard::new();
505            env.set("GOBBY_QDRANT_API_KEY", "env-qdrant-key");
506            env.set("GOBBY_EMBEDDING_MODEL", "env-embedding-model");
507
508            let mut source = TestSource::with_values([
509                ("databases.qdrant.url", "http://stored-qdrant:6333"),
510                ("databases.qdrant.api_key", "stored-qdrant-key"),
511                ("embeddings.api_base", "http://stored-embedding:11434/v1"),
512                ("embeddings.model", "stored-embedding-model"),
513                ("embeddings.api_key", "$secret:EMBEDDING_KEY"),
514            ]);
515
516            let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
517            let embedding = resolve_embedding_config(&mut source).expect("embedding config");
518
519            assert_eq!(qdrant.url.as_deref(), Some("http://stored-qdrant:6333"));
520            assert_eq!(qdrant.api_key.as_deref(), Some("env-qdrant-key"));
521            assert_eq!(embedding.api_base, "http://stored-embedding:11434/v1");
522            assert_eq!(embedding.model, "env-embedding-model");
523            assert_eq!(embedding.api_key.as_deref(), Some("resolved-EMBEDDING_KEY"));
524        }
525
526        let _env = EnvGuard::new();
527        let mut default_source =
528            TestSource::with_values([("embeddings.api_base", "http://stored-embedding:11434/v1")]);
529        let default_embedding =
530            resolve_embedding_config(&mut default_source).expect("embedding config");
531
532        assert_eq!(default_embedding.model, EMBEDDING_DEFAULT_MODEL);
533        assert!(resolve_qdrant_config(&mut TestSource::default()).is_none());
534    }
535
536    #[test]
537    fn falkordb_config_has_no_domain_graph_name() {
538        let config = FalkorConfig {
539            host: "falkor.local".to_string(),
540            port: 16379,
541            password: None,
542        };
543
544        assert!(!format!("{config:?}").contains("graph"));
545        let forbidden = ["gobby", "_", "code"].concat();
546        assert!(!include_str!("config.rs").contains(&forbidden));
547    }
548
549    #[test]
550    fn qdrant_config_has_no_domain_collection_prefix() {
551        let config = QdrantConfig {
552            url: Some("http://qdrant:6333".to_string()),
553            api_key: None,
554        };
555
556        assert!(!format!("{config:?}").contains("collection"));
557    }
558}