Skip to main content

gobby_core/config/
resolve.rs

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
10/// Decode a config_store value from its stored representation.
11pub 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
23/// Resolve `${VAR}` and `${VAR:-default}` environment variable patterns.
24pub 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(&current);
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
77/// Source for config values and interpolation.
78pub trait ConfigSource {
79    /// Read a decoded config value by key.
80    fn config_value(&mut self, key: &str) -> Option<String>;
81
82    /// Resolve interpolation patterns in a config value.
83    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String>;
84}
85
86/// Generic primary/fallback source for non-env layered configuration.
87pub 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
129/// Environment-only source for consumers without database access.
130pub 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
145/// Resolve FalkorDB config from env, config_store, then defaults.
146pub 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
167/// Resolve Qdrant config from env and config_store.
168pub 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
176/// Resolve embedding API config from config_store/gcore.yaml.
177pub fn resolve_embedding_config(source: &mut impl ConfigSource) -> Option<EmbeddingConfig> {
178    resolve_embedding_config_resolution(source).map(|resolution| resolution.config)
179}
180
181/// Resolve indexing config from env/config_store/gcore.yaml/defaults.
182pub 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
191/// Resolve embedding API config and report which namespace supplied api_base.
192pub 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
204/// Build OpenAI-compatible embedding client config from the shared embed binding.
205pub 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
246/// Resolve a capability's desired routing from config only.
247pub 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
256/// Resolve a capability binding from config only.
257pub 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
267/// Resolve shared AI tuning from config only.
268pub 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
427/// Resolve an AI config value and reject empty or still-unexpanded placeholders.
428///
429/// AI config resolves from `config_store`/gcore.yaml, but stored values may
430/// reference secrets or `${VAR}`. Unresolved placeholders must not masquerade as
431/// usable endpoints, models, or keys.
432fn 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}