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    pub query_prefix: Option<String>,
33    pub timeout_seconds: u64,
34}
35
36/// AI routing preference for a capability.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
38pub enum AiRouting {
39    #[default]
40    Auto,
41    Daemon,
42    Direct,
43    Off,
44}
45
46impl std::str::FromStr for AiRouting {
47    type Err = ParseAiRoutingError;
48
49    fn from_str(value: &str) -> Result<Self, Self::Err> {
50        match value.trim() {
51            "auto" => Ok(Self::Auto),
52            "daemon" => Ok(Self::Daemon),
53            "direct" => Ok(Self::Direct),
54            "off" => Ok(Self::Off),
55            value => Err(ParseAiRoutingError {
56                value: value.to_string(),
57            }),
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ParseAiRoutingError {
64    value: String,
65}
66
67impl std::fmt::Display for ParseAiRoutingError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(f, "invalid AI routing `{}`", self.value)
70    }
71}
72
73impl std::error::Error for ParseAiRoutingError {}
74
75/// AI capability names shared with the daemon registry.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub enum AiCapability {
78    Embed,
79    AudioTranscribe,
80    AudioTranslate,
81    VisionExtract,
82    TextGenerate,
83}
84
85impl AiCapability {
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Self::Embed => "embed",
89            Self::AudioTranscribe => "audio_transcribe",
90            Self::AudioTranslate => "audio_translate",
91            Self::VisionExtract => "vision_extract",
92            Self::TextGenerate => "text_generate",
93        }
94    }
95
96    pub fn namespace(self) -> &'static str {
97        match self {
98            Self::Embed => ai_keys::EMBEDDINGS_NAMESPACE,
99            Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_NAMESPACE,
100            Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_NAMESPACE,
101            Self::VisionExtract => ai_keys::VISION_EXTRACT_NAMESPACE,
102            Self::TextGenerate => ai_keys::TEXT_GENERATE_NAMESPACE,
103        }
104    }
105
106    fn routing_key(self) -> &'static str {
107        match self {
108            Self::Embed => ai_keys::EMBEDDINGS_ROUTING,
109            Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_ROUTING,
110            Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_ROUTING,
111            Self::VisionExtract => ai_keys::VISION_EXTRACT_ROUTING,
112            Self::TextGenerate => ai_keys::TEXT_GENERATE_ROUTING,
113        }
114    }
115
116    fn transport_key(self) -> &'static str {
117        match self {
118            Self::Embed => ai_keys::EMBEDDINGS_TRANSPORT,
119            Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_TRANSPORT,
120            Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_TRANSPORT,
121            Self::VisionExtract => ai_keys::VISION_EXTRACT_TRANSPORT,
122            Self::TextGenerate => ai_keys::TEXT_GENERATE_TRANSPORT,
123        }
124    }
125
126    fn api_base_key(self) -> &'static str {
127        match self {
128            Self::Embed => ai_keys::EMBEDDINGS_API_BASE,
129            Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_API_BASE,
130            Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_API_BASE,
131            Self::VisionExtract => ai_keys::VISION_EXTRACT_API_BASE,
132            Self::TextGenerate => ai_keys::TEXT_GENERATE_API_BASE,
133        }
134    }
135
136    fn api_key_key(self) -> &'static str {
137        match self {
138            Self::Embed => ai_keys::EMBEDDINGS_API_KEY,
139            Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_API_KEY,
140            Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_API_KEY,
141            Self::VisionExtract => ai_keys::VISION_EXTRACT_API_KEY,
142            Self::TextGenerate => ai_keys::TEXT_GENERATE_API_KEY,
143        }
144    }
145
146    fn model_key(self) -> &'static str {
147        match self {
148            Self::Embed => ai_keys::EMBEDDINGS_MODEL,
149            Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_MODEL,
150            Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_MODEL,
151            Self::VisionExtract => ai_keys::VISION_EXTRACT_MODEL,
152            Self::TextGenerate => ai_keys::TEXT_GENERATE_MODEL,
153        }
154    }
155
156    fn provider_key(self) -> &'static str {
157        match self {
158            Self::Embed => ai_keys::EMBEDDINGS_PROVIDER,
159            Self::AudioTranscribe => ai_keys::AUDIO_TRANSCRIBE_PROVIDER,
160            Self::AudioTranslate => ai_keys::AUDIO_TRANSLATE_PROVIDER,
161            Self::VisionExtract => ai_keys::VISION_EXTRACT_PROVIDER,
162            Self::TextGenerate => ai_keys::TEXT_GENERATE_PROVIDER,
163        }
164    }
165}
166
167impl std::str::FromStr for AiCapability {
168    type Err = ParseAiCapabilityError;
169
170    fn from_str(value: &str) -> Result<Self, Self::Err> {
171        match value.trim() {
172            "embed" | "embeddings" => Ok(Self::Embed),
173            "audio_transcribe" => Ok(Self::AudioTranscribe),
174            "audio_translate" => Ok(Self::AudioTranslate),
175            "vision_extract" => Ok(Self::VisionExtract),
176            "text_generate" => Ok(Self::TextGenerate),
177            value => Err(ParseAiCapabilityError {
178                value: value.to_string(),
179            }),
180        }
181    }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct ParseAiCapabilityError {
186    value: String,
187}
188
189impl std::fmt::Display for ParseAiCapabilityError {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        write!(f, "invalid AI capability `{}`", self.value)
192    }
193}
194
195impl std::error::Error for ParseAiCapabilityError {}
196
197/// Per-capability AI endpoint binding.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct CapabilityBinding {
200    pub routing: AiRouting,
201    pub transport: Option<String>,
202    pub api_base: Option<String>,
203    pub api_key: Option<String>,
204    pub model: Option<String>,
205    pub provider: Option<String>,
206    pub task: Option<String>,
207    pub language: Option<String>,
208    pub target_lang: Option<String>,
209}
210
211/// Shared AI tuning values.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct AiTuning {
214    pub max_concurrency: u8,
215    pub keep_alive: Option<String>,
216}
217
218/// Canonical home for embedding config keys during namespace migration.
219pub mod embedding_keys {
220    pub const AI_NAMESPACE: &str = "ai.embeddings";
221
222    pub const AI_PROVIDER: &str = "ai.embeddings.provider";
223    pub const AI_API_BASE: &str = "ai.embeddings.api_base";
224    pub const AI_MODEL: &str = "ai.embeddings.model";
225    pub const AI_API_KEY: &str = "ai.embeddings.api_key";
226    pub const AI_QUERY_PREFIX: &str = "ai.embeddings.query_prefix";
227    pub const AI_DIM: &str = "ai.embeddings.dim";
228    pub const AI_TIMEOUT_SECONDS: &str = "ai.embeddings.timeout_seconds";
229
230    const LEGACY_NAMESPACE: &str = "embeddings";
231    const LEGACY_KEY_SUFFIXES: &[&str] = &[
232        "provider",
233        "api_base",
234        "model",
235        "api_key",
236        "api_key_env",
237        "query_prefix",
238        "timeout_seconds",
239        "vector_dim",
240    ];
241
242    pub fn legacy_keys() -> Vec<String> {
243        LEGACY_KEY_SUFFIXES
244            .iter()
245            .map(|suffix| format!("{LEGACY_NAMESPACE}.{suffix}"))
246            .collect()
247    }
248}
249
250/// Canonical home for AI capability config keys.
251pub mod ai_keys {
252    pub const ROUTING: &str = "ai.routing";
253    pub const MAX_CONCURRENCY: &str = "ai.max_concurrency";
254    pub const KEEP_ALIVE: &str = "ai.keep_alive";
255
256    pub const EMBEDDINGS_NAMESPACE: &str = super::embedding_keys::AI_NAMESPACE;
257    pub const EMBEDDINGS_ROUTING: &str = "ai.embeddings.routing";
258    pub const EMBEDDINGS_TRANSPORT: &str = "ai.embeddings.transport";
259    pub const EMBEDDINGS_PROVIDER: &str = super::embedding_keys::AI_PROVIDER;
260    pub const EMBEDDINGS_API_BASE: &str = super::embedding_keys::AI_API_BASE;
261    pub const EMBEDDINGS_MODEL: &str = super::embedding_keys::AI_MODEL;
262    pub const EMBEDDINGS_API_KEY: &str = super::embedding_keys::AI_API_KEY;
263    pub const EMBEDDINGS_QUERY_PREFIX: &str = super::embedding_keys::AI_QUERY_PREFIX;
264    pub const EMBEDDINGS_DIM: &str = super::embedding_keys::AI_DIM;
265    pub const EMBEDDINGS_TIMEOUT_SECONDS: &str = super::embedding_keys::AI_TIMEOUT_SECONDS;
266
267    pub const AUDIO_TRANSCRIBE_NAMESPACE: &str = "ai.audio_transcribe";
268    pub const AUDIO_TRANSCRIBE_ROUTING: &str = "ai.audio_transcribe.routing";
269    pub const AUDIO_TRANSCRIBE_TRANSPORT: &str = "ai.audio_transcribe.transport";
270    pub const AUDIO_TRANSCRIBE_API_BASE: &str = "ai.audio_transcribe.api_base";
271    pub const AUDIO_TRANSCRIBE_API_KEY: &str = "ai.audio_transcribe.api_key";
272    pub const AUDIO_TRANSCRIBE_MODEL: &str = "ai.audio_transcribe.model";
273    pub const AUDIO_TRANSCRIBE_PROVIDER: &str = "ai.audio_transcribe.provider";
274    pub const AUDIO_TRANSCRIBE_TASK: &str = "ai.audio_transcribe.task";
275    pub const AUDIO_TRANSCRIBE_LANGUAGE: &str = "ai.audio_transcribe.language";
276
277    pub const AUDIO_TRANSLATE_NAMESPACE: &str = "ai.audio_translate";
278    pub const AUDIO_TRANSLATE_ROUTING: &str = "ai.audio_translate.routing";
279    pub const AUDIO_TRANSLATE_TRANSPORT: &str = "ai.audio_translate.transport";
280    pub const AUDIO_TRANSLATE_API_BASE: &str = "ai.audio_translate.api_base";
281    pub const AUDIO_TRANSLATE_API_KEY: &str = "ai.audio_translate.api_key";
282    pub const AUDIO_TRANSLATE_MODEL: &str = "ai.audio_translate.model";
283    pub const AUDIO_TRANSLATE_PROVIDER: &str = "ai.audio_translate.provider";
284    pub const AUDIO_TRANSLATE_TARGET_LANG: &str = "ai.audio_translate.target_lang";
285
286    pub const VISION_EXTRACT_NAMESPACE: &str = "ai.vision_extract";
287    pub const VISION_EXTRACT_ROUTING: &str = "ai.vision_extract.routing";
288    pub const VISION_EXTRACT_TRANSPORT: &str = "ai.vision_extract.transport";
289    pub const VISION_EXTRACT_API_BASE: &str = "ai.vision_extract.api_base";
290    pub const VISION_EXTRACT_API_KEY: &str = "ai.vision_extract.api_key";
291    pub const VISION_EXTRACT_MODEL: &str = "ai.vision_extract.model";
292    pub const VISION_EXTRACT_PROVIDER: &str = "ai.vision_extract.provider";
293
294    pub const TEXT_GENERATE_NAMESPACE: &str = "ai.text_generate";
295    pub const TEXT_GENERATE_ROUTING: &str = "ai.text_generate.routing";
296    pub const TEXT_GENERATE_TRANSPORT: &str = "ai.text_generate.transport";
297    pub const TEXT_GENERATE_API_BASE: &str = "ai.text_generate.api_base";
298    pub const TEXT_GENERATE_API_KEY: &str = "ai.text_generate.api_key";
299    pub const TEXT_GENERATE_MODEL: &str = "ai.text_generate.model";
300    pub const TEXT_GENERATE_PROVIDER: &str = "ai.text_generate.provider";
301
302    const ALL_KEYS: &[&str] = &[
303        ROUTING,
304        MAX_CONCURRENCY,
305        KEEP_ALIVE,
306        EMBEDDINGS_ROUTING,
307        EMBEDDINGS_TRANSPORT,
308        EMBEDDINGS_PROVIDER,
309        EMBEDDINGS_API_BASE,
310        EMBEDDINGS_MODEL,
311        EMBEDDINGS_API_KEY,
312        EMBEDDINGS_QUERY_PREFIX,
313        EMBEDDINGS_DIM,
314        EMBEDDINGS_TIMEOUT_SECONDS,
315        AUDIO_TRANSCRIBE_ROUTING,
316        AUDIO_TRANSCRIBE_TRANSPORT,
317        AUDIO_TRANSCRIBE_API_BASE,
318        AUDIO_TRANSCRIBE_API_KEY,
319        AUDIO_TRANSCRIBE_MODEL,
320        AUDIO_TRANSCRIBE_PROVIDER,
321        AUDIO_TRANSCRIBE_TASK,
322        AUDIO_TRANSCRIBE_LANGUAGE,
323        AUDIO_TRANSLATE_ROUTING,
324        AUDIO_TRANSLATE_TRANSPORT,
325        AUDIO_TRANSLATE_API_BASE,
326        AUDIO_TRANSLATE_API_KEY,
327        AUDIO_TRANSLATE_MODEL,
328        AUDIO_TRANSLATE_PROVIDER,
329        AUDIO_TRANSLATE_TARGET_LANG,
330        VISION_EXTRACT_ROUTING,
331        VISION_EXTRACT_TRANSPORT,
332        VISION_EXTRACT_API_BASE,
333        VISION_EXTRACT_API_KEY,
334        VISION_EXTRACT_MODEL,
335        VISION_EXTRACT_PROVIDER,
336        TEXT_GENERATE_ROUTING,
337        TEXT_GENERATE_TRANSPORT,
338        TEXT_GENERATE_API_BASE,
339        TEXT_GENERATE_API_KEY,
340        TEXT_GENERATE_MODEL,
341        TEXT_GENERATE_PROVIDER,
342    ];
343
344    pub fn all() -> &'static [&'static str] {
345        ALL_KEYS
346    }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct EmbeddingConfigResolution {
351    pub config: EmbeddingConfig,
352    pub namespace: &'static str,
353}
354
355const FALKORDB_DEFAULT_PORT: u16 = 16379;
356const EMBEDDING_DEFAULT_MODEL: &str = "nomic-embed-text";
357const EMBEDDING_DEFAULT_TIMEOUT_SECONDS: u64 = 10;
358const AI_DEFAULT_MAX_CONCURRENCY: u8 = 1;
359
360#[cfg(test)]
361pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
362
363/// Decode a config_store value from its stored representation.
364pub fn decode_config_value(raw: &str) -> Option<String> {
365    match serde_json::from_str::<serde_json::Value>(raw) {
366        Ok(serde_json::Value::String(value)) => Some(value),
367        Ok(value @ (serde_json::Value::Array(_) | serde_json::Value::Object(_))) => {
368            Some(serde_json::to_string(&value).unwrap_or_else(|_| raw.to_string()))
369        }
370        Ok(serde_json::Value::Null) => None,
371        Ok(value) => Some(value.to_string()),
372        Err(_) => Some(raw.to_string()),
373    }
374}
375
376/// Resolve `${VAR}` and `${VAR:-default}` environment variable patterns.
377pub fn resolve_env_pattern(value: &str) -> anyhow::Result<Option<String>> {
378    if !value.contains("${") {
379        return Ok(Some(value.to_string()));
380    }
381
382    let mut output = String::with_capacity(value.len());
383    let mut rest = value;
384    let mut unresolved = false;
385
386    while let Some(start) = rest.find("${") {
387        output.push_str(&rest[..start]);
388        let pattern = &rest[start + 2..];
389        let Some(end) = pattern.find('}') else {
390            anyhow::bail!("unterminated environment pattern in `{value}`");
391        };
392
393        let expression = &pattern[..end];
394        if expression.is_empty() {
395            anyhow::bail!("empty environment pattern in `{value}`");
396        }
397
398        let (name, default) = match expression.split_once(":-") {
399            Some((name, default)) => (name, Some(default)),
400            None => (expression, None),
401        };
402        if name.is_empty() {
403            anyhow::bail!("empty environment variable name in `{value}`");
404        }
405
406        match std::env::var(name) {
407            Ok(current) if !(current.is_empty() && default.is_some()) => {
408                output.push_str(&current);
409            }
410            Ok(_) | Err(std::env::VarError::NotPresent) => match default {
411                Some(default) => output.push_str(default),
412                None => unresolved = true,
413            },
414            Err(std::env::VarError::NotUnicode(_)) => {
415                anyhow::bail!("environment variable `{name}` is not valid unicode");
416            }
417        }
418
419        rest = &pattern[end + 1..];
420    }
421
422    output.push_str(rest);
423    if unresolved {
424        Ok(None)
425    } else {
426        Ok(Some(output))
427    }
428}
429
430/// Source for config values and interpolation.
431pub trait ConfigSource {
432    /// Read a decoded config value by key.
433    fn config_value(&mut self, key: &str) -> Option<String>;
434
435    /// Resolve interpolation patterns in a config value.
436    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String>;
437}
438
439/// Environment-only source for consumers without database access.
440pub struct EnvOnlySource;
441
442impl ConfigSource for EnvOnlySource {
443    fn config_value(&mut self, _key: &str) -> Option<String> {
444        None
445    }
446
447    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
448        if value.contains("$secret:") {
449            anyhow::bail!("secret resolution requires a datastore-backed config source");
450        }
451        resolve_env_pattern(value)?.ok_or_else(|| anyhow::anyhow!("unresolved pattern: {value}"))
452    }
453}
454
455/// Resolve FalkorDB config from env, config_store, then defaults.
456pub fn resolve_falkordb_config(source: &mut impl ConfigSource) -> Option<FalkorConfig> {
457    let host = resolve_setting(source, "GOBBY_FALKORDB_HOST", "databases.falkordb.host")?;
458    let port = resolve_port(
459        source,
460        "GOBBY_FALKORDB_PORT",
461        "databases.falkordb.port",
462        FALKORDB_DEFAULT_PORT,
463    );
464    let password = resolve_setting(
465        source,
466        "GOBBY_FALKORDB_PASSWORD",
467        "databases.falkordb.requirepass",
468    );
469
470    Some(FalkorConfig {
471        host,
472        port,
473        password,
474    })
475}
476
477/// Resolve Qdrant config from env and config_store.
478pub fn resolve_qdrant_config(source: &mut impl ConfigSource) -> Option<QdrantConfig> {
479    let url = resolve_setting(source, "GOBBY_QDRANT_URL", "databases.qdrant.url");
480    url.as_ref()?;
481    let api_key = resolve_setting(source, "GOBBY_QDRANT_API_KEY", "databases.qdrant.api_key");
482
483    Some(QdrantConfig { url, api_key })
484}
485
486/// Resolve embedding API config from config_store/gcore.yaml.
487pub fn resolve_embedding_config(source: &mut impl ConfigSource) -> Option<EmbeddingConfig> {
488    resolve_embedding_config_resolution(source).map(|resolution| resolution.config)
489}
490
491/// Resolve embedding API config and report which namespace supplied api_base.
492pub fn resolve_embedding_config_resolution(
493    source: &mut impl ConfigSource,
494) -> Option<EmbeddingConfigResolution> {
495    let binding = resolve_capability_binding(source, AiCapability::Embed);
496    let config = resolve_embedding_config_from_binding(source, &binding)?;
497
498    Some(EmbeddingConfigResolution {
499        config,
500        namespace: embedding_keys::AI_NAMESPACE,
501    })
502}
503
504/// Build OpenAI-compatible embedding client config from the shared embed binding.
505pub fn resolve_embedding_config_from_binding(
506    source: &mut impl ConfigSource,
507    binding: &CapabilityBinding,
508) -> Option<EmbeddingConfig> {
509    let api_base = binding
510        .api_base
511        .as_deref()
512        .map(str::trim)
513        .filter(|value| !value.is_empty())?
514        .to_string();
515    let model = binding
516        .model
517        .as_deref()
518        .map(str::trim)
519        .filter(|value| !value.is_empty())
520        .map(ToString::to_string)
521        .unwrap_or_else(|| EMBEDDING_DEFAULT_MODEL.to_string());
522    let api_key = binding
523        .api_key
524        .as_deref()
525        .map(str::trim)
526        .filter(|value| !value.is_empty())
527        .map(ToString::to_string);
528    let query_prefix = resolve_embedding_setting(source, embedding_keys::AI_QUERY_PREFIX);
529    let timeout_seconds = resolve_embedding_setting(source, embedding_keys::AI_TIMEOUT_SECONDS)
530        .and_then(|value| value.parse::<u64>().ok())
531        .unwrap_or(EMBEDDING_DEFAULT_TIMEOUT_SECONDS);
532
533    Some(EmbeddingConfig {
534        api_base,
535        model,
536        api_key,
537        query_prefix,
538        timeout_seconds,
539    })
540}
541
542fn resolve_embedding_setting(source: &mut impl ConfigSource, config_key: &str) -> Option<String> {
543    resolve_ai_config_value(source, config_key)
544}
545
546/// Resolve a capability's desired routing from config only.
547pub fn resolve_capability_routing(
548    source: &mut impl ConfigSource,
549    capability: AiCapability,
550) -> AiRouting {
551    resolve_ai_routing_value(source, capability.routing_key())
552        .or_else(|| resolve_ai_routing_value(source, ai_keys::ROUTING))
553        .unwrap_or_default()
554}
555
556/// Resolve a capability binding from config only.
557pub fn resolve_capability_binding(
558    source: &mut impl ConfigSource,
559    capability: AiCapability,
560) -> CapabilityBinding {
561    match capability {
562        AiCapability::AudioTranslate => resolve_audio_translate_binding(source),
563        capability => resolve_base_capability_binding(source, capability),
564    }
565}
566
567/// Resolve shared AI tuning from config only.
568pub fn resolve_ai_tuning(source: &mut impl ConfigSource) -> AiTuning {
569    let max_concurrency = resolve_ai_config_value(source, ai_keys::MAX_CONCURRENCY)
570        .and_then(|value| value.trim().parse::<u8>().ok())
571        .filter(|value| *value > 0)
572        .unwrap_or(AI_DEFAULT_MAX_CONCURRENCY);
573    let keep_alive = resolve_ai_config_value(source, ai_keys::KEEP_ALIVE);
574
575    AiTuning {
576        max_concurrency,
577        keep_alive,
578    }
579}
580
581fn resolve_base_capability_binding(
582    source: &mut impl ConfigSource,
583    capability: AiCapability,
584) -> CapabilityBinding {
585    CapabilityBinding {
586        routing: resolve_capability_routing(source, capability),
587        transport: resolve_ai_config_value(source, capability.transport_key()),
588        api_base: resolve_ai_config_value(source, capability.api_base_key()),
589        api_key: resolve_ai_config_value(source, capability.api_key_key()),
590        model: resolve_ai_config_value(source, capability.model_key()),
591        provider: resolve_ai_config_value(source, capability.provider_key()),
592        task: match capability {
593            AiCapability::AudioTranscribe => {
594                resolve_ai_config_value(source, ai_keys::AUDIO_TRANSCRIBE_TASK)
595            }
596            _ => None,
597        },
598        language: match capability {
599            AiCapability::AudioTranscribe => {
600                resolve_ai_config_value(source, ai_keys::AUDIO_TRANSCRIBE_LANGUAGE)
601            }
602            _ => None,
603        },
604        target_lang: match capability {
605            AiCapability::AudioTranslate => {
606                resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TARGET_LANG)
607            }
608            _ => None,
609        },
610    }
611}
612
613fn resolve_audio_translate_binding(source: &mut impl ConfigSource) -> CapabilityBinding {
614    let routing = resolve_ai_routing_value(source, ai_keys::AUDIO_TRANSLATE_ROUTING);
615    let transport = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TRANSPORT);
616    let api_base = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_API_BASE);
617    let api_key = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_API_KEY);
618    let model = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_MODEL);
619    let provider = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_PROVIDER);
620    let target_lang = resolve_ai_config_value(source, ai_keys::AUDIO_TRANSLATE_TARGET_LANG);
621    let inherited = resolve_base_capability_binding(source, AiCapability::AudioTranscribe);
622
623    CapabilityBinding {
624        routing: routing.unwrap_or(inherited.routing),
625        transport: transport.or(inherited.transport),
626        api_base: api_base.or(inherited.api_base),
627        api_key: api_key.or(inherited.api_key),
628        model: model.or(inherited.model),
629        provider: provider.or(inherited.provider),
630        task: None,
631        language: None,
632        target_lang,
633    }
634}
635
636fn resolve_ai_routing_value(source: &mut impl ConfigSource, config_key: &str) -> Option<AiRouting> {
637    resolve_ai_config_value(source, config_key).and_then(|value| value.parse().ok())
638}
639
640fn resolve_ai_config_value(source: &mut impl ConfigSource, config_key: &str) -> Option<String> {
641    let value = source.config_value(config_key)?;
642    resolve_ai_non_empty(source, &value)
643}
644
645/// Resolve an AI config value and reject empty or still-unexpanded placeholders.
646///
647/// AI config resolves from `config_store`/gcore.yaml, but stored values may
648/// reference secrets or `${VAR}`. Unresolved placeholders must not masquerade as
649/// usable endpoints, models, or keys.
650fn resolve_ai_non_empty(source: &mut impl ConfigSource, value: &str) -> Option<String> {
651    let trimmed = value.trim();
652    if trimmed.is_empty() {
653        return None;
654    }
655    source.resolve_value(trimmed).ok().filter(|resolved| {
656        let resolved = resolved.trim();
657        !resolved.is_empty() && !contains_unresolved_env_pattern(resolved)
658    })
659}
660
661fn contains_unresolved_env_pattern(value: &str) -> bool {
662    value.contains("${")
663}
664
665fn resolve_setting(
666    source: &mut impl ConfigSource,
667    env_key: &str,
668    config_key: &str,
669) -> Option<String> {
670    let value = env_value(env_key).or_else(|| source.config_value(config_key))?;
671    resolve_non_empty(source, &value)
672}
673
674fn resolve_port(
675    source: &mut impl ConfigSource,
676    env_key: &str,
677    config_key: &str,
678    default: u16,
679) -> u16 {
680    let Some(raw_port) = env_value(env_key).or_else(|| source.config_value(config_key)) else {
681        return default;
682    };
683    let Some(resolved) = resolve_non_empty(source, &raw_port) else {
684        return default;
685    };
686    resolved.parse::<u16>().unwrap_or(default)
687}
688
689fn resolve_non_empty(source: &mut impl ConfigSource, value: &str) -> Option<String> {
690    if value.trim().is_empty() {
691        return None;
692    }
693    source
694        .resolve_value(value)
695        .ok()
696        .filter(|resolved| !resolved.trim().is_empty())
697}
698
699fn env_value(key: &str) -> Option<String> {
700    std::env::var(key)
701        .ok()
702        .filter(|value| !value.trim().is_empty())
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use std::collections::HashMap;
709    use std::sync::MutexGuard;
710
711    struct EnvGuard {
712        _lock: MutexGuard<'static, ()>,
713    }
714
715    impl EnvGuard {
716        fn new() -> Self {
717            let guard = Self {
718                _lock: TEST_ENV_LOCK
719                    .lock()
720                    .unwrap_or_else(|poisoned| poisoned.into_inner()),
721            };
722            guard.clear();
723            guard
724        }
725
726        fn clear(&self) {
727            for key in [
728                "GOBBY_FALKORDB_HOST",
729                "GOBBY_FALKORDB_PORT",
730                "GOBBY_FALKORDB_PASSWORD",
731                "GOBBY_QDRANT_URL",
732                "GOBBY_QDRANT_API_KEY",
733                "GOBBY_EMBEDDING_URL",
734                "GOBBY_EMBEDDING_MODEL",
735                "GOBBY_EMBEDDING_API_KEY",
736                "GOBBY_EMBEDDING_QUERY_PREFIX",
737                "GOBBY_EMBEDDING_TIMEOUT_SECONDS",
738                "GOBBY_AI_TEXT_GENERATE_API_BASE",
739                "GOBBY_TEST_PRESENT",
740                "GOBBY_TEST_MISSING",
741            ] {
742                // SAFETY: TEST_ENV_LOCK serializes all test environment mutation
743                // here, and the loop only touches the fixed key list above.
744                unsafe { std::env::remove_var(key) };
745            }
746        }
747
748        fn set(&self, key: &str, value: &str) {
749            unsafe { std::env::set_var(key, value) };
750        }
751    }
752
753    impl Drop for EnvGuard {
754        fn drop(&mut self) {
755            self.clear();
756        }
757    }
758
759    #[derive(Default)]
760    struct TestSource {
761        values: HashMap<&'static str, String>,
762        resolved_values: Vec<String>,
763    }
764
765    impl TestSource {
766        fn with_values(values: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
767            Self {
768                values: values
769                    .into_iter()
770                    .map(|(key, value)| (key, value.to_string()))
771                    .collect(),
772                resolved_values: Vec::new(),
773            }
774        }
775
776        fn with_raw_values(values: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
777            Self {
778                values: values
779                    .into_iter()
780                    .filter_map(|(key, value)| decode_config_value(value).map(|v| (key, v)))
781                    .collect(),
782                resolved_values: Vec::new(),
783            }
784        }
785    }
786
787    impl ConfigSource for TestSource {
788        fn config_value(&mut self, key: &str) -> Option<String> {
789            self.values.get(key).cloned()
790        }
791
792        fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
793            self.resolved_values.push(value.to_string());
794            if let Some(secret_name) = value.strip_prefix("$secret:") {
795                return Ok(format!("resolved-{secret_name}"));
796            }
797            Ok(resolve_env_pattern(value)?.unwrap_or_else(|| value.to_string()))
798        }
799    }
800
801    #[derive(Default)]
802    struct LayeredTestSource {
803        store: TestSource,
804        yaml: TestSource,
805    }
806
807    impl LayeredTestSource {
808        fn with_layers(
809            store_values: impl IntoIterator<Item = (&'static str, &'static str)>,
810            yaml_values: impl IntoIterator<Item = (&'static str, &'static str)>,
811        ) -> Self {
812            Self {
813                store: TestSource::with_values(store_values),
814                yaml: TestSource::with_values(yaml_values),
815            }
816        }
817    }
818
819    impl ConfigSource for LayeredTestSource {
820        fn config_value(&mut self, key: &str) -> Option<String> {
821            self.store
822                .config_value(key)
823                .or_else(|| self.yaml.config_value(key))
824        }
825
826        fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
827            self.store.resolve_value(value)
828        }
829    }
830
831    #[test]
832    fn decode_config_value_handles_json_and_plain() {
833        assert_eq!(
834            decode_config_value("\"http://host:7474\""),
835            Some("http://host:7474".to_string())
836        );
837        assert_eq!(
838            decode_config_value(r#"["alpha",1,true]"#),
839            Some(r#"["alpha",1,true]"#.to_string())
840        );
841        assert_eq!(
842            decode_config_value(r#"{"host":"falkor.local","port":16379}"#),
843            Some(r#"{"host":"falkor.local","port":16379}"#.to_string())
844        );
845        assert_eq!(decode_config_value("42"), Some("42".to_string()));
846        assert_eq!(decode_config_value("true"), Some("true".to_string()));
847        assert_eq!(
848            decode_config_value("http://plain:7474"),
849            Some("http://plain:7474".to_string())
850        );
851        assert_eq!(decode_config_value("null"), None);
852    }
853
854    #[test]
855    fn resolve_env_pattern_with_defaults() {
856        let env = EnvGuard::new();
857        env.set("GOBBY_TEST_PRESENT", "present-value");
858
859        assert_eq!(
860            resolve_env_pattern("${GOBBY_TEST_PRESENT}").unwrap(),
861            Some("present-value".to_string())
862        );
863        assert_eq!(
864            resolve_env_pattern("prefix-${GOBBY_TEST_PRESENT}-suffix").unwrap(),
865            Some("prefix-present-value-suffix".to_string())
866        );
867        assert_eq!(
868            resolve_env_pattern("${GOBBY_TEST_MISSING:-fallback}").unwrap(),
869            Some("fallback".to_string())
870        );
871        assert_eq!(resolve_env_pattern("${GOBBY_TEST_MISSING}").unwrap(), None);
872        assert_eq!(
873            resolve_env_pattern("plain-value").unwrap(),
874            Some("plain-value".to_string())
875        );
876    }
877
878    #[test]
879    fn env_overrides_config_store() {
880        let env = EnvGuard::new();
881        env.set("GOBBY_FALKORDB_HOST", "env-falkor.local");
882        env.set("GOBBY_FALKORDB_PORT", "17000");
883        env.set("GOBBY_FALKORDB_PASSWORD", "env-pass");
884        env.set("GOBBY_QDRANT_URL", "http://env-qdrant:6333");
885        env.set("GOBBY_QDRANT_API_KEY", "env-qdrant-key");
886
887        let mut source = TestSource::with_values([
888            ("databases.falkordb.host", "stored-falkor.local"),
889            ("databases.falkordb.port", "16000"),
890            ("databases.falkordb.requirepass", "stored-pass"),
891            ("databases.qdrant.url", "http://stored-qdrant:6333"),
892            ("databases.qdrant.api_key", "stored-qdrant-key"),
893        ]);
894
895        let falkordb = resolve_falkordb_config(&mut source).expect("falkordb config");
896        let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
897
898        assert_eq!(falkordb.host, "env-falkor.local");
899        assert_eq!(falkordb.port, 17000);
900        assert_eq!(falkordb.password.as_deref(), Some("env-pass"));
901        assert_eq!(qdrant.url.as_deref(), Some("http://env-qdrant:6333"));
902        assert_eq!(qdrant.api_key.as_deref(), Some("env-qdrant-key"));
903    }
904
905    #[test]
906    fn config_source_handles_secrets() {
907        let _env = EnvGuard::new();
908        let mut source = TestSource::with_values([
909            ("databases.falkordb.host", "falkor.local"),
910            ("databases.falkordb.requirepass", "$secret:FALKOR_PASS"),
911        ]);
912
913        let config = resolve_falkordb_config(&mut source).expect("falkordb config");
914
915        assert_eq!(config.password.as_deref(), Some("resolved-FALKOR_PASS"));
916        assert!(
917            source
918                .resolved_values
919                .iter()
920                .any(|value| value == "$secret:FALKOR_PASS")
921        );
922    }
923
924    #[test]
925    fn env_only_source_rejects_secret_patterns() {
926        let _env = EnvGuard::new();
927        let mut source = EnvOnlySource;
928
929        let error = source
930            .resolve_value("$secret:FALKOR_PASS")
931            .expect_err("secret resolution should require a datastore-backed source");
932
933        assert!(error.to_string().contains("secret resolution"));
934    }
935
936    #[test]
937    fn ai_routing_per_capability_precedence() {
938        let _env = EnvGuard::new();
939        let mut source = TestSource::with_values([
940            (ai_keys::ROUTING, "daemon"),
941            (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "direct"),
942        ]);
943
944        assert_eq!(
945            resolve_capability_routing(&mut source, AiCapability::AudioTranscribe),
946            AiRouting::Direct
947        );
948
949        let mut source = TestSource::with_values([(ai_keys::ROUTING, "off")]);
950        assert_eq!(
951            resolve_capability_routing(&mut source, AiCapability::VisionExtract),
952            AiRouting::Off
953        );
954
955        let mut source = TestSource::default();
956        assert_eq!(
957            resolve_capability_routing(&mut source, AiCapability::TextGenerate),
958            AiRouting::Auto
959        );
960
961        let mut source = TestSource::with_values([
962            (ai_keys::TEXT_GENERATE_ROUTING, "unknown"),
963            (ai_keys::ROUTING, "direct"),
964        ]);
965        assert_eq!(
966            resolve_capability_routing(&mut source, AiCapability::TextGenerate),
967            AiRouting::Direct
968        );
969
970        assert_eq!("daemon".parse::<AiRouting>().ok(), Some(AiRouting::Daemon));
971        assert!("unknown".parse::<AiRouting>().is_err());
972    }
973
974    #[test]
975    fn ai_config_resolves_store_then_yaml_no_env() {
976        let env = EnvGuard::new();
977        env.set("GOBBY_EMBEDDING_URL", "http://env-embedding:11434/v1");
978        env.set(
979            "GOBBY_AI_TEXT_GENERATE_API_BASE",
980            "http://env-text:11434/v1",
981        );
982        env.set("GOBBY_TEST_PRESENT", "interpolated-text-model");
983
984        let mut source = LayeredTestSource::with_layers(
985            [
986                (
987                    ai_keys::TEXT_GENERATE_API_BASE,
988                    "http://store-text:11434/v1",
989                ),
990                (ai_keys::TEXT_GENERATE_API_KEY, "$secret:TEXT_KEY"),
991                (ai_keys::MAX_CONCURRENCY, "3"),
992            ],
993            [
994                (ai_keys::TEXT_GENERATE_API_BASE, "http://yaml-text:11434/v1"),
995                (ai_keys::TEXT_GENERATE_MODEL, "${GOBBY_TEST_PRESENT}"),
996                (ai_keys::TEXT_GENERATE_API_KEY, "yaml-local-key"),
997                (ai_keys::KEEP_ALIVE, "30s"),
998            ],
999        );
1000
1001        let binding = resolve_capability_binding(&mut source, AiCapability::TextGenerate);
1002        let tuning = resolve_ai_tuning(&mut source);
1003
1004        assert_eq!(
1005            binding.api_base.as_deref(),
1006            Some("http://store-text:11434/v1")
1007        );
1008        assert_eq!(binding.model.as_deref(), Some("interpolated-text-model"));
1009        assert_eq!(binding.api_key.as_deref(), Some("resolved-TEXT_KEY"));
1010        assert_eq!(tuning.max_concurrency, 3);
1011        assert_eq!(tuning.keep_alive.as_deref(), Some("30s"));
1012
1013        let mut standalone_source = LayeredTestSource::with_layers(
1014            [],
1015            [
1016                (
1017                    ai_keys::EMBEDDINGS_API_BASE,
1018                    "http://yaml-embedding:11434/v1",
1019                ),
1020                (ai_keys::EMBEDDINGS_API_KEY, "plaintext-local-key"),
1021            ],
1022        );
1023        let binding = resolve_capability_binding(&mut standalone_source, AiCapability::Embed);
1024        let embedding = resolve_embedding_config(&mut standalone_source).expect("embedding config");
1025
1026        assert_eq!(
1027            binding.api_base.as_deref(),
1028            Some("http://yaml-embedding:11434/v1")
1029        );
1030        assert_eq!(binding.api_key.as_deref(), Some("plaintext-local-key"));
1031        assert_eq!(embedding.api_base, "http://yaml-embedding:11434/v1");
1032
1033        let mut missing_env_source = LayeredTestSource::with_layers(
1034            [],
1035            [(ai_keys::TEXT_GENERATE_MODEL, "${GOBBY_TEST_MISSING}")],
1036        );
1037        let binding =
1038            resolve_capability_binding(&mut missing_env_source, AiCapability::TextGenerate);
1039        assert_eq!(binding.model, None);
1040
1041        let tuning = resolve_ai_tuning(&mut TestSource::default());
1042        assert_eq!(tuning.max_concurrency, 1);
1043        assert!(tuning.keep_alive.is_none());
1044    }
1045
1046    #[test]
1047    fn provider_and_translation_fields_resolve() {
1048        let _env = EnvGuard::new();
1049        let mut source = LayeredTestSource::with_layers(
1050            [
1051                (
1052                    ai_keys::AUDIO_TRANSLATE_PROVIDER,
1053                    "store-translate-provider",
1054                ),
1055                (ai_keys::AUDIO_TRANSLATE_TARGET_LANG, "fr"),
1056            ],
1057            [
1058                (ai_keys::AUDIO_TRANSLATE_PROVIDER, "yaml-translate-provider"),
1059                (ai_keys::AUDIO_TRANSLATE_TARGET_LANG, "de"),
1060                (ai_keys::AUDIO_TRANSCRIBE_LANGUAGE, "en"),
1061            ],
1062        );
1063
1064        let translate = resolve_capability_binding(&mut source, AiCapability::AudioTranslate);
1065        let transcribe = resolve_capability_binding(&mut source, AiCapability::AudioTranscribe);
1066
1067        assert_eq!(
1068            translate.provider.as_deref(),
1069            Some("store-translate-provider")
1070        );
1071        assert_eq!(translate.target_lang.as_deref(), Some("fr"));
1072        assert!(translate.task.is_none());
1073        assert!(translate.language.is_none());
1074
1075        assert_eq!(transcribe.language.as_deref(), Some("en"));
1076        assert!(transcribe.target_lang.is_none());
1077        assert!(
1078            ai_keys::all()
1079                .iter()
1080                .all(|key| !key.starts_with("ai.video."))
1081        );
1082        assert!(ai_keys::all().contains(&ai_keys::EMBEDDINGS_QUERY_PREFIX));
1083        assert!(ai_keys::all().contains(&ai_keys::EMBEDDINGS_DIM));
1084        assert!(ai_keys::all().contains(&ai_keys::EMBEDDINGS_TIMEOUT_SECONDS));
1085    }
1086
1087    #[test]
1088    fn audio_translate_inherits_transcribe_binding() {
1089        let _env = EnvGuard::new();
1090        let mut source = TestSource::with_values([
1091            (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "direct"),
1092            (ai_keys::AUDIO_TRANSCRIBE_TRANSPORT, "daemon_native"),
1093            (ai_keys::AUDIO_TRANSCRIBE_API_BASE, "http://stt:8080/v1"),
1094            (ai_keys::AUDIO_TRANSCRIBE_API_KEY, "$secret:STT_KEY"),
1095            (ai_keys::AUDIO_TRANSCRIBE_MODEL, "whisper-large-v3"),
1096            (ai_keys::AUDIO_TRANSCRIBE_PROVIDER, "faster-whisper"),
1097            (ai_keys::AUDIO_TRANSCRIBE_TASK, "transcribe"),
1098            (ai_keys::AUDIO_TRANSCRIBE_LANGUAGE, "en"),
1099            (ai_keys::AUDIO_TRANSLATE_MODEL, "translate-override"),
1100            (ai_keys::AUDIO_TRANSLATE_PROVIDER, "translate-provider"),
1101            (ai_keys::AUDIO_TRANSLATE_TARGET_LANG, "es"),
1102        ]);
1103
1104        let translate = resolve_capability_binding(&mut source, AiCapability::AudioTranslate);
1105        let transcribe = resolve_capability_binding(&mut source, AiCapability::AudioTranscribe);
1106
1107        assert_eq!(translate.routing, AiRouting::Direct);
1108        assert_eq!(translate.transport.as_deref(), Some("daemon_native"));
1109        assert_eq!(translate.api_base.as_deref(), Some("http://stt:8080/v1"));
1110        assert_eq!(translate.api_key.as_deref(), Some("resolved-STT_KEY"));
1111        assert_eq!(translate.model.as_deref(), Some("translate-override"));
1112        assert_eq!(translate.provider.as_deref(), Some("translate-provider"));
1113        assert_eq!(translate.target_lang.as_deref(), Some("es"));
1114        assert!(translate.task.is_none());
1115        assert!(translate.language.is_none());
1116
1117        assert_eq!(transcribe.task.as_deref(), Some("transcribe"));
1118        assert_eq!(transcribe.language.as_deref(), Some("en"));
1119        assert!(transcribe.target_lang.is_none());
1120
1121        let mut source = TestSource::with_values([
1122            (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "daemon"),
1123            (ai_keys::AUDIO_TRANSLATE_ROUTING, "off"),
1124        ]);
1125        let translate = resolve_capability_binding(&mut source, AiCapability::AudioTranslate);
1126        assert_eq!(translate.routing, AiRouting::Off);
1127    }
1128
1129    #[test]
1130    fn env_does_not_override_ai_embedding_keys() {
1131        let env = EnvGuard::new();
1132        env.set("GOBBY_EMBEDDING_URL", "http://env-embedding:11434");
1133        env.set("GOBBY_EMBEDDING_MODEL", "env-model");
1134        env.set("GOBBY_EMBEDDING_API_KEY", "env-key");
1135        env.set("GOBBY_EMBEDDING_QUERY_PREFIX", "env-prefix:");
1136        env.set("GOBBY_EMBEDDING_TIMEOUT_SECONDS", "7");
1137
1138        let mut source = TestSource::with_values([
1139            (embedding_keys::AI_API_BASE, "http://new-embedding:11434"),
1140            (embedding_keys::AI_MODEL, "new-model"),
1141            (embedding_keys::AI_API_KEY, "$secret:AI_KEY"),
1142            (embedding_keys::AI_QUERY_PREFIX, "new-query:"),
1143            (embedding_keys::AI_TIMEOUT_SECONDS, "12"),
1144        ]);
1145
1146        let resolution =
1147            resolve_embedding_config_resolution(&mut source).expect("embedding config");
1148        let config = resolution.config;
1149
1150        assert_eq!(resolution.namespace, embedding_keys::AI_NAMESPACE);
1151        assert_eq!(config.api_base, "http://new-embedding:11434");
1152        assert_eq!(config.model, "new-model");
1153        assert_eq!(config.api_key.as_deref(), Some("resolved-AI_KEY"));
1154        assert_eq!(config.query_prefix.as_deref(), Some("new-query:"));
1155        assert_eq!(config.timeout_seconds, 12);
1156    }
1157
1158    #[test]
1159    fn legacy_keys_not_honored() {
1160        let _env = EnvGuard::new();
1161        let legacy_keys = embedding_keys::legacy_keys();
1162        let mut source = TestSource::with_values([
1163            (
1164                leak_for_test(legacy_keys[1].clone()),
1165                "http://legacy-embedding:11434",
1166            ),
1167            (leak_for_test(legacy_keys[2].clone()), "legacy-model"),
1168            (leak_for_test(legacy_keys[3].clone()), "$secret:LEGACY_KEY"),
1169            (leak_for_test(legacy_keys[5].clone()), "legacy-query:"),
1170        ]);
1171
1172        assert!(resolve_embedding_config_resolution(&mut source).is_none());
1173    }
1174
1175    fn leak_for_test(value: String) -> &'static str {
1176        // Test-only helper for fixed-size config fixture keys. The source trait
1177        // stores borrowed keys for the duration of the process, so leaking these
1178        // tiny strings avoids global mutable state without affecting production.
1179        Box::leak(value.into_boxed_str())
1180    }
1181
1182    #[test]
1183    fn postgres_config_source_resolves_secrets() {
1184        let _env = EnvGuard::new();
1185
1186        struct ConnectionLike {
1187            values: HashMap<&'static str, String>,
1188            secret_reads: usize,
1189        }
1190
1191        struct PostgresConfigSource<'a> {
1192            conn: &'a mut ConnectionLike,
1193        }
1194
1195        impl ConfigSource for PostgresConfigSource<'_> {
1196            fn config_value(&mut self, key: &str) -> Option<String> {
1197                self.conn.values.get(key).cloned()
1198            }
1199
1200            fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
1201                self.conn.secret_reads += 1;
1202                Ok(format!("secret::{value}"))
1203            }
1204        }
1205
1206        let mut conn = ConnectionLike {
1207            values: HashMap::from([
1208                (
1209                    embedding_keys::AI_API_BASE,
1210                    "http://stored-embedding:11434".to_string(),
1211                ),
1212                (
1213                    embedding_keys::AI_API_KEY,
1214                    "$secret:OPENAI_API_KEY".to_string(),
1215                ),
1216            ]),
1217            secret_reads: 0,
1218        };
1219        let config = {
1220            let mut source = PostgresConfigSource { conn: &mut conn };
1221            resolve_embedding_config(&mut source).expect("embedding config")
1222        };
1223
1224        assert_eq!(
1225            config.api_key.as_deref(),
1226            Some("secret::$secret:OPENAI_API_KEY")
1227        );
1228        assert_eq!(conn.secret_reads, 2);
1229    }
1230
1231    #[test]
1232    fn resolve_config_handles_json_encoded_store_values() {
1233        let _env = EnvGuard::new();
1234        let mut source = TestSource::with_raw_values([
1235            ("databases.falkordb.host", r#""json-falkor.local""#),
1236            ("databases.falkordb.port", r#""17001""#),
1237            ("databases.falkordb.requirepass", r#""$secret:FALKOR_PASS""#),
1238            ("databases.qdrant.url", r#""http://json-qdrant:6333""#),
1239            ("databases.qdrant.api_key", r#"["alpha",1]"#),
1240            (
1241                embedding_keys::AI_API_BASE,
1242                r#""http://json-embedding:11434""#,
1243            ),
1244            (embedding_keys::AI_MODEL, r#"["model",1]"#),
1245        ]);
1246
1247        let falkordb = resolve_falkordb_config(&mut source).expect("falkordb config");
1248        let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
1249        let embedding = resolve_embedding_config(&mut source).expect("embedding config");
1250
1251        assert_eq!(falkordb.host, "json-falkor.local");
1252        assert_eq!(falkordb.port, 17001);
1253        assert_eq!(falkordb.password.as_deref(), Some("resolved-FALKOR_PASS"));
1254        assert_eq!(qdrant.url.as_deref(), Some("http://json-qdrant:6333"));
1255        assert_eq!(qdrant.api_key.as_deref(), Some(r#"["alpha",1]"#));
1256        assert_eq!(embedding.api_base, "http://json-embedding:11434");
1257        assert_eq!(embedding.model, r#"["model",1]"#);
1258    }
1259
1260    #[test]
1261    fn qdrant_and_embedding_resolution_order() {
1262        {
1263            let env = EnvGuard::new();
1264            env.set("GOBBY_QDRANT_API_KEY", "env-qdrant-key");
1265            let mut source = TestSource::with_values([
1266                ("databases.qdrant.url", "http://stored-qdrant:6333"),
1267                ("databases.qdrant.api_key", "stored-qdrant-key"),
1268                (
1269                    embedding_keys::AI_API_BASE,
1270                    "http://stored-embedding:11434/v1",
1271                ),
1272                (embedding_keys::AI_MODEL, "stored-embedding-model"),
1273                (embedding_keys::AI_API_KEY, "$secret:EMBEDDING_KEY"),
1274                (embedding_keys::AI_QUERY_PREFIX, "stored-query-prefix:"),
1275            ]);
1276
1277            let qdrant = resolve_qdrant_config(&mut source).expect("qdrant config");
1278            let embedding = resolve_embedding_config(&mut source).expect("embedding config");
1279
1280            assert_eq!(qdrant.url.as_deref(), Some("http://stored-qdrant:6333"));
1281            assert_eq!(qdrant.api_key.as_deref(), Some("env-qdrant-key"));
1282            assert_eq!(embedding.api_base, "http://stored-embedding:11434/v1");
1283            assert_eq!(embedding.model, "stored-embedding-model");
1284            assert_eq!(embedding.api_key.as_deref(), Some("resolved-EMBEDDING_KEY"));
1285            assert_eq!(
1286                embedding.query_prefix.as_deref(),
1287                Some("stored-query-prefix:")
1288            );
1289            assert_eq!(embedding.timeout_seconds, EMBEDDING_DEFAULT_TIMEOUT_SECONDS);
1290        }
1291
1292        let _env = EnvGuard::new();
1293        let mut default_source = TestSource::with_values([(
1294            embedding_keys::AI_API_BASE,
1295            "http://stored-embedding:11434/v1",
1296        )]);
1297        let default_embedding =
1298            resolve_embedding_config(&mut default_source).expect("embedding config");
1299
1300        assert_eq!(default_embedding.model, EMBEDDING_DEFAULT_MODEL);
1301        assert!(default_embedding.query_prefix.is_none());
1302        assert_eq!(
1303            default_embedding.timeout_seconds,
1304            EMBEDDING_DEFAULT_TIMEOUT_SECONDS
1305        );
1306        assert!(resolve_qdrant_config(&mut TestSource::default()).is_none());
1307    }
1308
1309    #[test]
1310    fn invalid_embedding_timeout_uses_default() {
1311        let _env = EnvGuard::new();
1312        let mut source = TestSource::with_values([
1313            (
1314                embedding_keys::AI_API_BASE,
1315                "http://stored-embedding:11434/v1",
1316            ),
1317            (embedding_keys::AI_TIMEOUT_SECONDS, "not-a-number"),
1318        ]);
1319
1320        let embedding = resolve_embedding_config(&mut source).expect("embedding config");
1321
1322        assert_eq!(embedding.timeout_seconds, EMBEDDING_DEFAULT_TIMEOUT_SECONDS);
1323    }
1324
1325    #[test]
1326    fn falkordb_config_has_no_domain_graph_name() {
1327        let config = FalkorConfig {
1328            host: "falkor.local".to_string(),
1329            port: 16379,
1330            password: None,
1331        };
1332
1333        assert!(!format!("{config:?}").contains("graph"));
1334        let forbidden = ["gobby", "_", "code"].concat();
1335        assert!(!include_str!("config.rs").contains(&forbidden));
1336    }
1337
1338    #[test]
1339    fn qdrant_config_has_no_domain_collection_prefix() {
1340        let config = QdrantConfig {
1341            url: Some("http://qdrant:6333".to_string()),
1342            api_key: None,
1343        };
1344
1345        assert!(!format!("{config:?}").contains("collection"));
1346    }
1347
1348    #[test]
1349    fn embedding_keys_centralized() {
1350        if std::env::var("RUN_SLOW_TESTS").is_err() {
1351            eprintln!("skipping slow workspace embedding key scan; set RUN_SLOW_TESTS=1 to run");
1352            return;
1353        }
1354
1355        let workspace = workspace_root();
1356        let offenders = embedding_key_literal_offenders(&workspace.join("crates"));
1357
1358        assert!(
1359            offenders.is_empty(),
1360            "embedding config key literals must stay in gobby_core::config::embedding_keys: {offenders:?}"
1361        );
1362    }
1363
1364    #[test]
1365    fn ci_guard_rejects_stray_literal() {
1366        let dir = tempfile::tempdir().expect("tempdir");
1367        let src = dir.path().join("src");
1368        std::fs::create_dir_all(&src).expect("create src");
1369        std::fs::write(
1370            src.join("bad.rs"),
1371            format!(r#"const BAD: &str = "{}";"#, embedding_keys::AI_API_BASE),
1372        )
1373        .expect("write bad source");
1374
1375        let offenders = embedding_key_literal_offenders(dir.path());
1376
1377        assert_eq!(offenders.len(), 1);
1378        assert!(offenders[0].ends_with("bad.rs"));
1379    }
1380
1381    #[test]
1382    fn ci_guard_rejects_legacy_namespace() {
1383        let dir = tempfile::tempdir().expect("tempdir");
1384        let src = dir.path().join("src");
1385        std::fs::create_dir_all(&src).expect("create src");
1386        std::fs::write(
1387            src.join("bad.rs"),
1388            format!(
1389                r#"const BAD: &str = "{}";"#,
1390                embedding_keys::legacy_keys()[1]
1391            ),
1392        )
1393        .expect("write bad source");
1394
1395        let offenders = embedding_key_literal_offenders(dir.path());
1396
1397        assert_eq!(offenders.len(), 1);
1398        assert!(offenders[0].ends_with("bad.rs"));
1399    }
1400
1401    fn workspace_root() -> std::path::PathBuf {
1402        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1403            .parent()
1404            .and_then(std::path::Path::parent)
1405            .expect("workspace root")
1406            .to_path_buf()
1407    }
1408
1409    fn embedding_key_literal_offenders(root: &std::path::Path) -> Vec<std::path::PathBuf> {
1410        let mut offenders = Vec::new();
1411        visit_embedding_key_literal_sources(root, &mut offenders);
1412        offenders
1413    }
1414
1415    fn visit_embedding_key_literal_sources(
1416        path: &std::path::Path,
1417        offenders: &mut Vec<std::path::PathBuf>,
1418    ) {
1419        let entries = match std::fs::read_dir(path) {
1420            Ok(entries) => entries,
1421            Err(_) => return,
1422        };
1423        for entry in entries {
1424            let entry = entry.expect("directory entry");
1425            let path = entry.path();
1426            if path.is_dir() {
1427                if should_skip_embedding_key_scan_dir(&path) {
1428                    continue;
1429                }
1430                visit_embedding_key_literal_sources(&path, offenders);
1431                continue;
1432            }
1433            if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
1434                continue;
1435            }
1436            if embedding_key_literal_allowed_path(&path) {
1437                continue;
1438            }
1439            let source = std::fs::read_to_string(&path).expect("read source file");
1440            if guarded_embedding_keys()
1441                .iter()
1442                .any(|key| source.contains(key.as_str()))
1443            {
1444                offenders.push(path);
1445            }
1446        }
1447    }
1448
1449    fn should_skip_embedding_key_scan_dir(path: &std::path::Path) -> bool {
1450        matches!(
1451            path.file_name().and_then(|name| name.to_str()),
1452            Some(
1453                ".git"
1454                    | "target"
1455                    | "node_modules"
1456                    | "dist"
1457                    | "build"
1458                    | ".venv"
1459                    | "venv"
1460                    | "__pycache__"
1461            )
1462        )
1463    }
1464
1465    fn guarded_embedding_keys() -> Vec<String> {
1466        let mut keys = vec![
1467            embedding_keys::AI_PROVIDER,
1468            embedding_keys::AI_API_BASE,
1469            embedding_keys::AI_MODEL,
1470            embedding_keys::AI_API_KEY,
1471            embedding_keys::AI_QUERY_PREFIX,
1472            embedding_keys::AI_DIM,
1473            embedding_keys::AI_TIMEOUT_SECONDS,
1474        ]
1475        .into_iter()
1476        .map(str::to_string)
1477        .collect::<Vec<_>>();
1478        keys.extend(embedding_keys::legacy_keys());
1479        keys
1480    }
1481
1482    fn embedding_key_literal_allowed_path(path: &std::path::Path) -> bool {
1483        let path = path.to_string_lossy();
1484        path.ends_with("crates/gcore/src/config.rs")
1485            || path.ends_with("tests.rs")
1486            || path.contains("/tests/")
1487    }
1488}