1use super::*;
2
3pub(crate) const FALKORDB_DEFAULT_PORT: u16 = 16379;
4pub(crate) const EMBEDDING_DEFAULT_MODEL: &str = "nomic-embed-text";
5pub(crate) const EMBEDDING_DEFAULT_TIMEOUT_SECONDS: u64 = 10;
6const AI_DEFAULT_MAX_CONCURRENCY: u8 = 1;
7pub const INDEXING_RESPECT_GITIGNORE_KEY: &str = "indexing.respect_gitignore";
8const INDEXING_RESPECT_GITIGNORE_ENV: &str = "GOBBY_INDEXING_RESPECT_GITIGNORE";
9
10pub fn decode_config_value(raw: &str) -> Option<String> {
12 match serde_json::from_str::<serde_json::Value>(raw) {
13 Ok(serde_json::Value::String(value)) => Some(value),
14 Ok(value @ (serde_json::Value::Array(_) | serde_json::Value::Object(_))) => {
15 Some(serde_json::to_string(&value).unwrap_or_else(|_| raw.to_string()))
16 }
17 Ok(serde_json::Value::Null) => None,
18 Ok(value) => Some(value.to_string()),
19 Err(_) => Some(raw.to_string()),
20 }
21}
22
23pub fn resolve_env_pattern(value: &str) -> anyhow::Result<Option<String>> {
25 if !value.contains("${") {
26 return Ok(Some(value.to_string()));
27 }
28
29 let mut output = String::with_capacity(value.len());
30 let mut rest = value;
31 let mut unresolved = false;
32
33 while let Some(start) = rest.find("${") {
34 output.push_str(&rest[..start]);
35 let pattern = &rest[start + 2..];
36 let Some(end) = pattern.find('}') else {
37 anyhow::bail!("unterminated environment pattern in `{value}`");
38 };
39
40 let expression = &pattern[..end];
41 if expression.is_empty() {
42 anyhow::bail!("empty environment pattern in `{value}`");
43 }
44
45 let (name, default) = match expression.split_once(":-") {
46 Some((name, default)) => (name, Some(default)),
47 None => (expression, None),
48 };
49 if name.is_empty() {
50 anyhow::bail!("empty environment variable name in `{value}`");
51 }
52
53 match std::env::var(name) {
54 Ok(current) if !(current.is_empty() && default.is_some()) => {
55 output.push_str(¤t);
56 }
57 Ok(_) | Err(std::env::VarError::NotPresent) => match default {
58 Some(default) => output.push_str(default),
59 None => unresolved = true,
60 },
61 Err(std::env::VarError::NotUnicode(_)) => {
62 anyhow::bail!("environment variable `{name}` is not valid unicode");
63 }
64 }
65
66 rest = &pattern[end + 1..];
67 }
68
69 output.push_str(rest);
70 if unresolved {
71 Ok(None)
72 } else {
73 Ok(Some(output))
74 }
75}
76
77pub trait ConfigSource {
79 fn config_value(&mut self, key: &str) -> Option<String>;
81
82 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String>;
84}
85
86pub struct LayeredConfigSource<P, F> {
88 primary: Option<P>,
89 fallback: Option<F>,
90}
91
92impl<P, F> LayeredConfigSource<P, F> {
93 pub fn new(primary: Option<P>, fallback: Option<F>) -> Self {
94 Self { primary, fallback }
95 }
96}
97
98impl<P, F> ConfigSource for LayeredConfigSource<P, F>
99where
100 P: ConfigSource,
101 F: ConfigSource,
102{
103 fn config_value(&mut self, key: &str) -> Option<String> {
104 self.primary
105 .as_mut()
106 .and_then(|source| source.config_value(key))
107 .or_else(|| {
108 self.fallback
109 .as_mut()
110 .and_then(|source| source.config_value(key))
111 })
112 }
113
114 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
115 match self.primary.as_mut() {
116 Some(source) => source.resolve_value(value),
117 None => self
118 .fallback
119 .as_mut()
120 .map(|source| source.resolve_value(value))
121 .unwrap_or_else(|| {
122 resolve_env_pattern(value)?
123 .ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
124 }),
125 }
126 }
127}
128
129pub struct EnvOnlySource;
131
132impl ConfigSource for EnvOnlySource {
133 fn config_value(&mut self, _key: &str) -> Option<String> {
134 None
135 }
136
137 fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
138 if value.contains("$secret:") {
139 anyhow::bail!("secret resolution requires a datastore-backed config source");
140 }
141 resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
142 }
143}
144
145pub fn resolve_falkordb_config(source: &mut impl ConfigSource) -> Option<FalkorConfig> {
147 let host = resolve_setting(source, "GOBBY_FALKORDB_HOST", "databases.falkordb.host")?;
148 let port = resolve_port(
149 source,
150 "GOBBY_FALKORDB_PORT",
151 "databases.falkordb.port",
152 FALKORDB_DEFAULT_PORT,
153 );
154 let password = resolve_setting(
155 source,
156 "GOBBY_FALKORDB_PASSWORD",
157 "databases.falkordb.password",
158 );
159
160 Some(FalkorConfig {
161 host,
162 port,
163 password,
164 })
165}
166
167pub fn resolve_qdrant_config(source: &mut impl ConfigSource) -> Option<QdrantConfig> {
169 let url = resolve_setting(source, "GOBBY_QDRANT_URL", "databases.qdrant.url");
170 url.as_ref()?;
171 let api_key = resolve_setting(source, "GOBBY_QDRANT_API_KEY", "databases.qdrant.api_key");
172
173 Some(QdrantConfig { url, api_key })
174}
175
176pub fn resolve_embedding_config(source: &mut impl ConfigSource) -> Option<EmbeddingConfig> {
178 resolve_embedding_config_resolution(source).map(|resolution| resolution.config)
179}
180
181pub fn resolve_indexing_config(source: &mut impl ConfigSource) -> anyhow::Result<IndexingConfig> {
183 let respect_gitignore = match env_value(INDEXING_RESPECT_GITIGNORE_ENV) {
184 Some(value) => parse_config_bool_or_default(INDEXING_RESPECT_GITIGNORE_ENV, &value, true),
185 None => resolve_config_bool(source, INDEXING_RESPECT_GITIGNORE_KEY, true),
186 };
187
188 Ok(IndexingConfig { respect_gitignore })
189}
190
191pub fn resolve_embedding_config_resolution(
193 source: &mut impl ConfigSource,
194) -> Option<EmbeddingConfigResolution> {
195 let binding = resolve_capability_binding(source, AiCapability::Embed);
196 let config = resolve_embedding_config_from_binding(source, &binding)?;
197
198 Some(EmbeddingConfigResolution {
199 config,
200 namespace: embedding_keys::AI_NAMESPACE,
201 })
202}
203
204pub fn resolve_embedding_config_from_binding(
206 source: &mut impl ConfigSource,
207 binding: &CapabilityBinding,
208) -> Option<EmbeddingConfig> {
209 let api_base = binding
210 .api_base
211 .as_deref()
212 .map(str::trim)
213 .filter(|value| !value.is_empty())?
214 .to_string();
215 let model = binding
216 .model
217 .as_deref()
218 .map(str::trim)
219 .filter(|value| !value.is_empty())
220 .map(ToString::to_string)
221 .unwrap_or_else(|| EMBEDDING_DEFAULT_MODEL.to_string());
222 let api_key = binding
223 .api_key
224 .as_deref()
225 .map(str::trim)
226 .filter(|value| !value.is_empty())
227 .map(ToString::to_string);
228 let query_prefix = resolve_embedding_setting(source, embedding_keys::AI_QUERY_PREFIX);
229 let timeout_seconds = resolve_embedding_setting(source, embedding_keys::AI_TIMEOUT_SECONDS)
230 .and_then(|value| value.parse::<u64>().ok())
231 .unwrap_or(EMBEDDING_DEFAULT_TIMEOUT_SECONDS);
232
233 Some(EmbeddingConfig {
234 api_base,
235 model,
236 api_key,
237 query_prefix,
238 timeout_seconds,
239 })
240}
241
242fn resolve_embedding_setting(source: &mut impl ConfigSource, config_key: &str) -> Option<String> {
243 resolve_ai_config_value(source, config_key)
244}
245
246pub fn resolve_capability_routing(
248 source: &mut impl ConfigSource,
249 capability: AiCapability,
250) -> AiRouting {
251 resolve_ai_routing_value(source, capability.routing_key())
252 .or_else(|| resolve_ai_routing_value(source, ai_keys::ROUTING))
253 .unwrap_or_default()
254}
255
256pub fn resolve_capability_binding(
258 source: &mut impl ConfigSource,
259 capability: AiCapability,
260) -> CapabilityBinding {
261 match capability {
262 AiCapability::AudioTranslate => resolve_audio_translate_binding(source),
263 capability => resolve_base_capability_binding(source, capability),
264 }
265}
266
267pub fn resolve_ai_tuning(source: &mut impl ConfigSource) -> AiTuning {
269 let max_concurrency = resolve_ai_config_value(source, ai_keys::MAX_CONCURRENCY)
270 .and_then(|value| value.trim().parse::<u8>().ok())
271 .filter(|value| *value > 0)
272 .unwrap_or(AI_DEFAULT_MAX_CONCURRENCY);
273 let keep_alive = resolve_ai_config_value(source, ai_keys::KEEP_ALIVE);
274
275 AiTuning {
276 max_concurrency,
277 keep_alive,
278 }
279}
280
281fn resolve_base_capability_binding(
282 source: &mut impl ConfigSource,
283 capability: AiCapability,
284) -> CapabilityBinding {
285 CapabilityBinding {
286 routing: resolve_capability_routing(source, capability),
287 transport: resolve_ai_config_value(source, capability.transport_key()),
288 api_base: resolve_ai_config_value(source, capability.api_base_key()),
289 api_key: resolve_ai_config_value(source, capability.api_key_key()),
290 model: resolve_ai_config_value(source, capability.model_key()),
291 provider: resolve_ai_config_value(source, capability.provider_key()),
292 task: match capability {
293 AiCapability::AudioTranscribe => {
294 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSCRIBE_TASK)
295 }
296 _ => None,
297 },
298 language: match capability {
299 AiCapability::AudioTranscribe => {
300 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSCRIBE_LANGUAGE)
301 }
302 _ => None,
303 },
304 target_lang: match capability {
305 AiCapability::AudioTranslate => {
306 resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TARGET_LANG)
307 }
308 _ => None,
309 },
310 profile: match capability {
311 AiCapability::TextGenerate => {
312 resolve_ai_config_value(source, ai_keys::TEXT_GENERATE_PROFILE)
313 }
314 _ => None,
315 },
316 candidates: match capability {
317 AiCapability::TextGenerate => {
318 resolve_feature_candidates(source, ai_keys::TEXT_GENERATE_CANDIDATES)
319 }
320 _ => None,
321 },
322 reasoning_effort: match capability {
323 AiCapability::TextGenerate => {
324 resolve_ai_config_value(source, ai_keys::TEXT_GENERATE_REASONING_EFFORT)
325 }
326 _ => None,
327 },
328 verify_profile: match capability {
329 AiCapability::TextGenerate => {
330 resolve_ai_config_value(source, ai_keys::TEXT_GENERATE_VERIFY_PROFILE)
331 }
332 _ => None,
333 },
334 verify_model: match capability {
335 AiCapability::TextGenerate => {
336 resolve_ai_config_value(source, ai_keys::TEXT_GENERATE_VERIFY_MODEL)
337 }
338 _ => None,
339 },
340 verify_api_key: match capability {
341 AiCapability::TextGenerate => {
342 resolve_ai_config_value(source, ai_keys::TEXT_GENERATE_VERIFY_API_KEY)
343 }
344 _ => None,
345 },
346 }
347}
348
349fn resolve_audio_translate_binding(source: &mut impl ConfigSource) -> CapabilityBinding {
350 let routing = resolve_ai_routing_value(source, ai_keys::AUDIO_TRANSLATE_ROUTING);
351 let transport = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TRANSPORT);
352 let api_base = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_API_BASE);
353 let api_key = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_API_KEY);
354 let model = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_MODEL);
355 let provider = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_PROVIDER);
356 let target_lang = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TARGET_LANG);
357 let inherited = resolve_base_capability_binding(source, AiCapability::AudioTranscribe);
358
359 CapabilityBinding {
360 routing: routing.unwrap_or(inherited.routing),
361 transport: transport.or(inherited.transport),
362 api_base: api_base.or(inherited.api_base),
363 api_key: api_key.or(inherited.api_key),
364 model: model.or(inherited.model),
365 provider: provider.or(inherited.provider),
366 task: None,
367 language: None,
368 target_lang,
369 profile: None,
370 candidates: None,
371 reasoning_effort: None,
372 verify_profile: None,
373 verify_model: None,
374 verify_api_key: None,
375 }
376}
377
378fn resolve_ai_routing_value(source: &mut impl ConfigSource, config_key: &str) -> Option<AiRouting> {
379 resolve_ai_config_value(source, config_key).and_then(|value| value.parse().ok())
380}
381
382fn resolve_ai_config_value(source: &mut impl ConfigSource, config_key: &str) -> Option<String> {
383 let value = source.config_value(config_key)?;
384 resolve_ai_non_empty(source, config_key, &value)
385}
386
387fn resolve_feature_candidates(
388 source: &mut impl ConfigSource,
389 config_key: &str,
390) -> Option<Vec<FeatureCandidate>> {
391 let raw = resolve_ai_config_value(source, config_key)?;
392 match serde_json::from_str::<Vec<FeatureCandidate>>(&raw) {
393 Ok(candidates) if candidates.is_empty() => None,
394 Ok(candidates) => Some(candidates),
395 Err(error) => {
396 log::warn!("invalid feature candidates for config key {config_key:?}: {error}");
397 None
398 }
399 }
400}
401
402fn resolve_config_bool(
403 source: &mut impl ConfigSource,
404 config_key: &'static str,
405 default: bool,
406) -> bool {
407 let Some(value) = source.config_value(config_key) else {
408 return default;
409 };
410 let Some(resolved) = resolve_non_empty(source, config_key, &value) else {
411 return default;
412 };
413 parse_config_bool_or_default(config_key, &resolved, default)
414}
415
416fn parse_config_bool_or_default(source_key: &str, value: &str, default: bool) -> bool {
417 match value.trim().to_ascii_lowercase().as_str() {
418 "true" | "1" | "yes" | "on" => true,
419 "false" | "0" | "no" | "off" => false,
420 _ => {
421 log::warn!("invalid boolean for config key {source_key:?}; using default {default}");
422 default
423 }
424 }
425}
426
427fn resolve_ai_non_empty(
433 source: &mut impl ConfigSource,
434 source_key: &str,
435 value: &str,
436) -> Option<String> {
437 let trimmed = value.trim();
438 if trimmed.is_empty() {
439 return None;
440 }
441 let resolved = match source.resolve_value(trimmed) {
442 Ok(resolved) => resolved,
443 Err(error) => {
444 log::warn!("failed to resolve config key {source_key:?}: {error}");
445 return None;
446 }
447 };
448 let resolved_trimmed = resolved.trim();
449 if resolved_trimmed.is_empty() || contains_unresolved_env_pattern(resolved_trimmed) {
450 None
451 } else {
452 Some(resolved)
453 }
454}
455
456fn contains_unresolved_env_pattern(value: &str) -> bool {
457 value.contains("${")
458}
459
460fn resolve_setting(
461 source: &mut impl ConfigSource,
462 env_key: &str,
463 config_key: &str,
464) -> Option<String> {
465 resolve_setting_from_keys(source, env_key, &[config_key])
466}
467
468fn resolve_setting_from_keys(
469 source: &mut impl ConfigSource,
470 env_key: &str,
471 config_keys: &[&str],
472) -> Option<String> {
473 if let Some(value) = env_value(env_key) {
474 return resolve_non_empty(source, env_key, &value);
475 }
476 for config_key in config_keys {
477 let Some(value) = source.config_value(config_key) else {
478 continue;
479 };
480 if let Some(resolved) = resolve_non_empty(source, config_key, &value) {
481 return Some(resolved);
482 }
483 }
484 None
485}
486
487fn resolve_port(
488 source: &mut impl ConfigSource,
489 env_key: &str,
490 config_key: &str,
491 default: u16,
492) -> u16 {
493 let (source_key, raw_port) = if let Some(raw_port) = env_value(env_key) {
494 (env_key, raw_port)
495 } else {
496 let Some(raw_port) = source.config_value(config_key) else {
497 return default;
498 };
499 (config_key, raw_port)
500 };
501 let Some(resolved) = resolve_non_empty(source, source_key, &raw_port) else {
502 return default;
503 };
504 match resolved.parse::<u16>() {
505 Ok(port) => port,
506 Err(error) => {
507 log::warn!(
508 "invalid port for config key {source_key:?}: {error}; using default {default}"
509 );
510 default
511 }
512 }
513}
514
515fn resolve_non_empty(
516 source: &mut impl ConfigSource,
517 source_key: &str,
518 value: &str,
519) -> Option<String> {
520 if value.trim().is_empty() {
521 return None;
522 }
523 let resolved = match source.resolve_value(value) {
524 Ok(resolved) => resolved,
525 Err(error) => {
526 log::warn!("failed to resolve config key {source_key:?}: {error}");
527 return None;
528 }
529 };
530 if resolved.trim().is_empty() {
531 None
532 } else {
533 Some(resolved)
534 }
535}
536
537fn env_value(key: &str) -> Option<String> {
538 std::env::var(key)
539 .ok()
540 .filter(|value| !value.trim().is_empty())
541}