1#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct FalkorConfig {
12 pub host: String,
13 pub port: u16,
14 pub password: Option<String>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct QdrantConfig {
22 pub url: Option<String>,
23 pub api_key: Option<String>,
24}
25
26#[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
40pub 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
53pub 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(¤t);
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
107pub trait ConfigSource {
109 fn config_value(&mut self, key: &str) -> Option<String>;
111
112 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String>;
114}
115
116pub 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
132pub 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
154pub 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
163pub 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}