Skip to main content

gobby_code/
config.rs

1//! Configuration resolution for gcode.
2//!
3//! Reads bootstrap.yaml → PostgreSQL hub → config_store → service configs.
4//! Resolves $secret:NAME and ${VAR} patterns.
5//!
6//! Source: src/gobby/config/bootstrap.py, src/gobby/config/persistence.py
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11use gobby_core::config::ConfigSource;
12use gobby_core::project::{find_project_root, read_project_id};
13use gobby_core::provisioning::{GCORE_CONFIG_FILENAME, StandaloneConfig};
14use postgres::Client;
15
16use crate::db;
17use crate::git::{self, WorktreeKind};
18use crate::secrets;
19use crate::utils::short_id;
20
21/// FalkorDB connection configuration.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FalkorConfig {
24    pub host: String,
25    pub port: u16,
26    pub password: Option<String>,
27    pub graph_name: String,
28}
29
30/// Qdrant connection configuration.
31pub type QdrantConfig = gobby_core::config::QdrantConfig;
32
33/// Embedding API configuration (OpenAI-compatible endpoint).
34pub type EmbeddingConfig = gobby_core::config::EmbeddingConfig;
35
36pub const FALKORDB_GRAPH_NAME: &str = "gobby_code";
37pub const CODE_SYMBOL_COLLECTION_PREFIX: &str = "code_symbols_";
38pub const GOBBY_EMBEDDING_VECTOR_DIM_ENV: &str = "GOBBY_EMBEDDING_VECTOR_DIM";
39pub const EMBEDDING_VECTOR_DIM_CONFIG_KEY: &str = "embeddings.vector_dim";
40
41pub const GOBBY_FALKORDB_HOST_ENV: &str = "GOBBY_FALKORDB_HOST";
42pub const GOBBY_FALKORDB_PORT_ENV: &str = "GOBBY_FALKORDB_PORT";
43pub const GOBBY_FALKORDB_PASSWORD_ENV: &str = "GOBBY_FALKORDB_PASSWORD";
44
45pub const FALKORDB_HOST_CONFIG_KEY: &str = "databases.falkordb.host";
46pub const FALKORDB_PORT_CONFIG_KEY: &str = "databases.falkordb.port";
47pub const FALKORDB_PASSWORD_CONFIG_KEY: &str = "databases.falkordb.requirepass";
48
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct CodeVectorSettings {
51    pub vector_dim: Option<usize>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum CodeVectorConfigError {
56    InvalidVectorDim { source: &'static str, value: String },
57}
58
59impl fmt::Display for CodeVectorConfigError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::InvalidVectorDim { source, value } => write!(
63                f,
64                "invalid code vector dimension from {source}: `{value}` must be a positive integer"
65            ),
66        }
67    }
68}
69
70impl std::error::Error for CodeVectorConfigError {}
71
72impl FalkorConfig {
73    pub fn connection_config(&self) -> gobby_core::config::FalkorConfig {
74        gobby_core::config::FalkorConfig {
75            host: self.host.clone(),
76            port: self.port,
77            password: self.password.clone(),
78        }
79    }
80}
81
82/// Resolved runtime context for gcode commands.
83pub struct Context {
84    /// PostgreSQL hub DSN
85    pub database_url: String,
86    /// Project root directory
87    pub project_root: PathBuf,
88    /// Project ID (from .gobby/project.json or DB lookup)
89    pub project_id: String,
90    /// Suppress warnings
91    pub quiet: bool,
92    /// FalkorDB config (None if unavailable)
93    pub falkordb: Option<FalkorConfig>,
94    /// Qdrant config (None if unavailable)
95    pub qdrant: Option<QdrantConfig>,
96    /// Embedding API config (None if unavailable → no semantic search)
97    pub embedding: Option<EmbeddingConfig>,
98    /// Code-symbol vector projection settings owned by gcode.
99    pub code_vectors: CodeVectorSettings,
100    /// Gobby daemon base URL (e.g. http://localhost:60887)
101    pub daemon_url: Option<String>,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum MissingIdentity {
106    Error,
107    Generate,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum ProjectIdentitySource {
112    ProjectJson,
113    GcodeJson,
114    IsolatedRoot,
115    LinkedWorktree,
116    Generated,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ProjectIdentity {
121    pub project_id: String,
122    pub root: PathBuf,
123    pub source: ProjectIdentitySource,
124    pub warning: Option<String>,
125    pub should_write_gcode_json: bool,
126}
127
128impl Context {
129    /// Resolve context from CLI args and filesystem state.
130    pub fn resolve(project_override: Option<&str>, quiet: bool) -> anyhow::Result<Self> {
131        let database_url = db::resolve_database_url()?;
132        let project_root = match project_override {
133            Some(p) => {
134                let path = PathBuf::from(p);
135                if path.is_dir() {
136                    path.canonicalize()?
137                } else {
138                    // Not a directory — try name lookup in the PostgreSQL hub.
139                    resolve_project_by_name(p, &database_url)?
140                }
141            }
142            None => detect_project_root()?,
143        };
144
145        let identity = resolve_project_identity(&project_root, MissingIdentity::Error)?;
146        warn_project_identity(&identity, quiet);
147        let project_id = identity.project_id;
148
149        // Resolve service configs from config_store (best-effort).
150        let standalone_config = read_standalone_config();
151        let mut conn = db::connect_readonly(&database_url)?;
152        let falkordb = resolve_falkordb_config(&mut conn, standalone_config.clone(), quiet);
153        let qdrant = resolve_qdrant_config(&mut conn, standalone_config.clone(), quiet);
154        let embedding = resolve_embedding_config(&mut conn, standalone_config.clone(), quiet);
155        let code_vectors = resolve_code_vector_settings(&mut conn, standalone_config)?;
156
157        let daemon_url = resolve_daemon_url();
158
159        Ok(Self {
160            database_url,
161            project_root,
162            project_id,
163            quiet,
164            falkordb,
165            qdrant,
166            embedding,
167            code_vectors,
168            daemon_url,
169        })
170    }
171}
172
173pub fn resolve_project_identity(
174    project_root: &Path,
175    missing: MissingIdentity,
176) -> anyhow::Result<ProjectIdentity> {
177    let root = project_root
178        .canonicalize()
179        .unwrap_or_else(|_| absolute_fallback(project_root));
180
181    if let Some(marker) = crate::project::read_isolation_marker(&root)
182        && !is_self_referential_isolation_marker(&marker, &root)
183    {
184        return Ok(ProjectIdentity {
185            project_id: crate::project::code_index_id_for_root(&root),
186            root,
187            source: ProjectIdentitySource::IsolatedRoot,
188            warning: None,
189            should_write_gcode_json: false,
190        });
191    }
192
193    let worktree = git::worktree_info(&root)?;
194    if worktree.kind == WorktreeKind::Linked {
195        let project_id = crate::project::code_index_id_for_root(&worktree.top_level);
196        let copied_id = read_project_id(&worktree.top_level).ok();
197        let warning = copied_id
198            .filter(|id| id != &project_id)
199            .map(|id| {
200                format!(
201                    "linked git worktree {} has copied .gobby/project.json id {}; using filesystem-scoped code index id {}",
202                    worktree.top_level.display(),
203                    short_id(&id),
204                    short_id(&project_id)
205                )
206            });
207
208        return Ok(ProjectIdentity {
209            project_id,
210            root: worktree.top_level,
211            source: ProjectIdentitySource::LinkedWorktree,
212            warning,
213            should_write_gcode_json: false,
214        });
215    }
216
217    let gobby_dir = root.join(".gobby");
218    if gobby_dir.join("project.json").exists() {
219        return Ok(ProjectIdentity {
220            project_id: read_project_id(&root)?,
221            root,
222            source: ProjectIdentitySource::ProjectJson,
223            warning: None,
224            should_write_gcode_json: false,
225        });
226    }
227    if gobby_dir.join("gcode.json").exists() {
228        return Ok(ProjectIdentity {
229            project_id: crate::project::read_gcode_json(&root)?,
230            root,
231            source: ProjectIdentitySource::GcodeJson,
232            warning: None,
233            should_write_gcode_json: false,
234        });
235    }
236
237    match missing {
238        MissingIdentity::Generate => Ok(ProjectIdentity {
239            project_id: crate::project::code_index_id_for_root(&root),
240            root,
241            source: ProjectIdentitySource::Generated,
242            warning: None,
243            should_write_gcode_json: true,
244        }),
245        MissingIdentity::Error => anyhow::bail!(
246            "No gcode project found. Run `gcode init` to initialize, \
247             or use `--project <path>` to specify a project directory."
248        ),
249    }
250}
251
252fn is_self_referential_isolation_marker(
253    marker: &crate::project::IsolationMarker,
254    root: &Path,
255) -> bool {
256    let Some(parent_project_path) = marker.parent_project_path.as_deref() else {
257        return false;
258    };
259    let parent = PathBuf::from(parent_project_path);
260    let parent = if parent.is_absolute() {
261        parent
262    } else {
263        root.join(parent)
264    };
265    let parent = parent.canonicalize().unwrap_or(parent);
266    parent == root
267}
268
269pub fn warn_project_identity(identity: &ProjectIdentity, quiet: bool) {
270    if quiet {
271        return;
272    }
273    if let Some(warning) = &identity.warning {
274        eprintln!("Warning: {warning}");
275    }
276}
277
278/// Resolve a `--project` name to a project root by looking up `code_indexed_projects`.
279///
280/// Matches against the basename of `root_path` in the PostgreSQL hub.
281fn resolve_project_by_name(name: &str, database_url: &str) -> anyhow::Result<PathBuf> {
282    let mut conn = db::connect_readonly(database_url)?;
283    let rows = conn.query(
284        "SELECT root_path FROM code_indexed_projects
285         WHERE root_path = $1 OR root_path LIKE '%' || '/' || $1
286         ORDER BY last_indexed_at DESC NULLS LAST
287         LIMIT 1",
288        &[&name],
289    )?;
290
291    if let Some(row) = rows.first() {
292        let root_path: String = row.try_get("root_path")?;
293        let path = PathBuf::from(&root_path);
294        if path.is_dir() {
295            return Ok(path);
296        }
297    }
298
299    anyhow::bail!(
300        "Project '{}' not found. Run `gcode projects` to see indexed projects.",
301        name
302    )
303}
304
305/// Detect project root by walking up the directory tree.
306///
307/// Resolution order:
308/// 1. `.gobby/project.json` or `.gobby/gcode.json` (identity file)
309/// 2. VCS root (`.git` or `.hg`)
310/// 3. Current working directory
311pub fn detect_project_root() -> anyhow::Result<PathBuf> {
312    let cwd = std::env::current_dir()?;
313    detect_project_root_from(&cwd)
314}
315
316pub fn detect_project_root_from(start: &Path) -> anyhow::Result<PathBuf> {
317    let start = start
318        .canonicalize()
319        .unwrap_or_else(|_| absolute_fallback(start));
320    let start = if start.is_file() {
321        start
322            .parent()
323            .map(Path::to_path_buf)
324            .unwrap_or_else(|| start.clone())
325    } else {
326        start
327    };
328
329    // First: look for an identity file (.gobby/project.json or .gobby/gcode.json)
330    if let Some(root) = find_project_root(&start) {
331        return Ok(root.canonicalize().unwrap_or(root));
332    }
333
334    // Second: prefer the Git worktree top-level, including linked worktrees.
335    if let Ok(info) = git::worktree_info(&start)
336        && info.kind != WorktreeKind::NotGit
337    {
338        return Ok(info.top_level);
339    }
340
341    // Third: fall back to VCS root
342    let mut dir = start.as_path();
343    loop {
344        if dir.join(".git").exists() || dir.join(".hg").exists() {
345            return Ok(dir.to_path_buf());
346        }
347        match dir.parent() {
348            Some(parent) => dir = parent,
349            None => return Ok(start), // Last resort: start
350        }
351    }
352}
353
354/// Resolve Gobby daemon base URL.
355///
356/// Resolution order:
357/// 1. `GOBBY_PORT` env var (explicit override)
358/// 2. `~/.gobby/bootstrap.yaml` `daemon_port` + `bind_host` keys
359/// 3. Default: `http://localhost:60887`
360pub(crate) fn resolve_daemon_url() -> Option<String> {
361    // Env var override takes priority (empty value falls through to defaults)
362    if let Ok(port) = std::env::var("GOBBY_PORT")
363        && !port.is_empty()
364    {
365        return Some(format!("http://localhost:{port}"));
366    }
367
368    // Read from bootstrap.yaml
369    let bootstrap_path = db::bootstrap_path().ok()?;
370    if let Ok(contents) = std::fs::read_to_string(&bootstrap_path)
371        && let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents)
372        && let Some(port) = yaml.get("daemon_port").and_then(|v| v.as_u64())
373    {
374        let host = yaml
375            .get("bind_host")
376            .and_then(|v| v.as_str())
377            .unwrap_or("localhost");
378        return Some(format!("http://{host}:{port}"));
379    }
380
381    // Well-known default (matches gsqz)
382    Some("http://localhost:60887".to_string())
383}
384
385/// Resolve project ID from identity files or generate deterministically.
386///
387/// Resolution order:
388/// 1. `.gobby/project.json` — gobby's file (reads `"id"`, falls back to `"project_id"`)
389/// 2. `.gobby/gcode.json` — gcode's standalone identity
390/// 3. Generate deterministic UUID5 from canonical path (no filesystem writes)
391#[cfg(test)]
392fn resolve_project_id(project_root: &Path) -> anyhow::Result<String> {
393    Ok(resolve_project_identity(project_root, MissingIdentity::Error)?.project_id)
394}
395
396fn absolute_fallback(path: &Path) -> PathBuf {
397    if path.is_absolute() {
398        path.to_path_buf()
399    } else {
400        std::env::current_dir()
401            .unwrap_or_else(|_| PathBuf::from("."))
402            .join(path)
403    }
404}
405
406// ── Config store adapter ─────────────────────────────────────────────
407
408pub(crate) struct PostgresConfigSource<'a> {
409    conn: &'a mut Client,
410}
411
412impl gobby_core::config::ConfigSource for PostgresConfigSource<'_> {
413    fn config_value(&mut self, key: &str) -> Option<String> {
414        let key = canonical_config_key(key);
415        gobby_core::postgres::read_config_value(self.conn, key)
416            .ok()
417            .flatten()
418            .and_then(|raw| gobby_core::config::decode_config_value(&raw))
419    }
420
421    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
422        secrets::resolve_config_value(value, self.conn)
423    }
424}
425
426struct FallbackConfigSource<'a> {
427    postgres: PostgresConfigSource<'a>,
428    standalone: Option<StandaloneConfig>,
429}
430
431impl ConfigSource for FallbackConfigSource<'_> {
432    fn config_value(&mut self, key: &str) -> Option<String> {
433        self.postgres.config_value(key).or_else(|| {
434            self.standalone
435                .as_mut()
436                .and_then(|standalone| standalone.config_value(key))
437        })
438    }
439
440    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
441        self.postgres.resolve_value(value)
442    }
443}
444
445fn read_standalone_config() -> Option<StandaloneConfig> {
446    let home = db::gobby_home().ok()?;
447    StandaloneConfig::read_at(&home.join(GCORE_CONFIG_FILENAME))
448        .ok()
449        .flatten()
450}
451
452#[cfg(test)]
453struct ClosureConfigSource<R, S> {
454    read_config_value: R,
455    resolve_value: S,
456}
457
458#[cfg(test)]
459impl<R, S> ConfigSource for ClosureConfigSource<R, S>
460where
461    R: FnMut(&str) -> Option<String>,
462    S: FnMut(&str) -> anyhow::Result<String>,
463{
464    fn config_value(&mut self, key: &str) -> Option<String> {
465        (self.read_config_value)(key).and_then(|raw| gobby_core::config::decode_config_value(&raw))
466    }
467
468    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
469        (self.resolve_value)(value)
470    }
471}
472
473fn canonical_config_key(key: &str) -> &str {
474    match key {
475        FALKORDB_HOST_CONFIG_KEY => FALKORDB_HOST_CONFIG_KEY,
476        FALKORDB_PORT_CONFIG_KEY => FALKORDB_PORT_CONFIG_KEY,
477        FALKORDB_PASSWORD_CONFIG_KEY => FALKORDB_PASSWORD_CONFIG_KEY,
478        _ => key,
479    }
480}
481
482#[cfg(test)]
483fn resolve_falkordb_config_from_values<R, S>(
484    read_config_value: R,
485    resolve_value: S,
486) -> Option<FalkorConfig>
487where
488    R: FnMut(&str) -> Option<String>,
489    S: FnMut(&str) -> anyhow::Result<String>,
490{
491    let mut source = ClosureConfigSource {
492        read_config_value,
493        resolve_value,
494    };
495    resolve_falkordb_config_from_source(&mut source)
496}
497
498#[cfg(test)]
499fn resolve_qdrant_config_from_values<R, S>(
500    read_config_value: R,
501    resolve_value: S,
502) -> Option<QdrantConfig>
503where
504    R: FnMut(&str) -> Option<String>,
505    S: FnMut(&str) -> anyhow::Result<String>,
506{
507    let mut source = ClosureConfigSource {
508        read_config_value,
509        resolve_value,
510    };
511    gobby_core::config::resolve_qdrant_config(&mut source)
512}
513
514#[cfg(test)]
515fn resolve_embedding_config_from_values<R, S>(
516    read_config_value: R,
517    resolve_value: S,
518) -> Option<EmbeddingConfig>
519where
520    R: FnMut(&str) -> Option<String>,
521    S: FnMut(&str) -> anyhow::Result<String>,
522{
523    let mut source = ClosureConfigSource {
524        read_config_value,
525        resolve_value,
526    };
527    gobby_core::config::resolve_embedding_config(&mut source)
528}
529
530#[cfg(test)]
531fn resolve_code_vector_settings_from_values<R>(
532    read_config_value: R,
533) -> Result<CodeVectorSettings, CodeVectorConfigError>
534where
535    R: FnMut(&str) -> Option<String>,
536{
537    let mut source = ClosureConfigSource {
538        read_config_value,
539        resolve_value: |value: &str| Ok(value.to_string()),
540    };
541    resolve_code_vector_settings_from_source(&mut source)
542}
543
544/// Resolve FalkorDB configuration from config_store + env vars.
545fn resolve_falkordb_config(
546    conn: &mut Client,
547    standalone: Option<StandaloneConfig>,
548    _quiet: bool,
549) -> Option<FalkorConfig> {
550    let mut source = FallbackConfigSource {
551        postgres: PostgresConfigSource { conn },
552        standalone,
553    };
554    resolve_falkordb_config_from_source(&mut source)
555}
556
557fn resolve_falkordb_config_from_source(source: &mut impl ConfigSource) -> Option<FalkorConfig> {
558    let connection = gobby_core::config::resolve_falkordb_config(source)?;
559
560    Some(FalkorConfig {
561        host: connection.host,
562        port: connection.port,
563        password: connection.password,
564        graph_name: FALKORDB_GRAPH_NAME.to_string(),
565    })
566}
567
568/// Resolve Qdrant configuration from config_store + env vars.
569fn resolve_qdrant_config(
570    conn: &mut Client,
571    standalone: Option<StandaloneConfig>,
572    _quiet: bool,
573) -> Option<QdrantConfig> {
574    let mut source = FallbackConfigSource {
575        postgres: PostgresConfigSource { conn },
576        standalone,
577    };
578    gobby_core::config::resolve_qdrant_config(&mut source)
579}
580
581/// Resolve embedding API configuration from config_store + env vars.
582///
583/// Returns None if no api_base is found (→ no semantic search, BM25 only).
584fn resolve_embedding_config(
585    conn: &mut Client,
586    standalone: Option<StandaloneConfig>,
587    _quiet: bool,
588) -> Option<EmbeddingConfig> {
589    let mut source = FallbackConfigSource {
590        postgres: PostgresConfigSource { conn },
591        standalone,
592    };
593    gobby_core::config::resolve_embedding_config(&mut source)
594}
595
596pub(crate) fn resolve_code_vector_settings(
597    conn: &mut Client,
598    standalone: Option<StandaloneConfig>,
599) -> Result<CodeVectorSettings, CodeVectorConfigError> {
600    let mut source = FallbackConfigSource {
601        postgres: PostgresConfigSource { conn },
602        standalone,
603    };
604    resolve_code_vector_settings_from_source(&mut source)
605}
606
607pub(crate) fn resolve_code_vector_settings_from_source(
608    source: &mut impl ConfigSource,
609) -> Result<CodeVectorSettings, CodeVectorConfigError> {
610    let vector_dim = match std::env::var(GOBBY_EMBEDDING_VECTOR_DIM_ENV)
611        .ok()
612        .filter(|value| !value.trim().is_empty())
613    {
614        Some(value) => Some(parse_vector_dim(
615            GOBBY_EMBEDDING_VECTOR_DIM_ENV,
616            value.trim(),
617        )?),
618        None => source
619            .config_value(EMBEDDING_VECTOR_DIM_CONFIG_KEY)
620            .map(|value| parse_vector_dim(EMBEDDING_VECTOR_DIM_CONFIG_KEY, value.trim()))
621            .transpose()?,
622    };
623
624    Ok(CodeVectorSettings { vector_dim })
625}
626
627fn parse_vector_dim(source: &'static str, value: &str) -> Result<usize, CodeVectorConfigError> {
628    value
629        .parse::<usize>()
630        .ok()
631        .filter(|size| *size > 0)
632        .ok_or_else(|| CodeVectorConfigError::InvalidVectorDim {
633            source,
634            value: value.to_string(),
635        })
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use std::process::Command;
642
643    fn write_project_json(root: &Path, json: serde_json::Value) {
644        let gobby_dir = root.join(".gobby");
645        std::fs::create_dir_all(&gobby_dir).expect("create .gobby");
646        std::fs::write(
647            gobby_dir.join("project.json"),
648            serde_json::to_string_pretty(&json).expect("serialize project json"),
649        )
650        .expect("write project json");
651    }
652
653    fn run_git(dir: &Path, args: &[&str]) {
654        let status = Command::new("git")
655            .arg("-C")
656            .arg(dir)
657            .args(args)
658            .status()
659            .expect("run git");
660        assert!(status.success(), "git {:?} failed", args);
661    }
662
663    fn create_linked_worktree(tmp: &tempfile::TempDir) -> (PathBuf, PathBuf) {
664        let repo = tmp.path().join("repo");
665        let linked = tmp.path().join("linked");
666        std::fs::create_dir(&repo).expect("create repo");
667        run_git(&repo, &["init"]);
668        std::fs::write(repo.join("README.md"), "hello\n").expect("write readme");
669        run_git(&repo, &["add", "README.md"]);
670        run_git(
671            &repo,
672            &[
673                "-c",
674                "user.email=test@example.com",
675                "-c",
676                "user.name=Test User",
677                "commit",
678                "-m",
679                "initial",
680            ],
681        );
682        run_git(
683            &repo,
684            &[
685                "worktree",
686                "add",
687                "-b",
688                "linked-branch",
689                linked.to_str().unwrap(),
690            ],
691        );
692        (repo, linked)
693    }
694
695    fn clear_service_env() {
696        for key in [
697            "GOBBY_FALKORDB_HOST",
698            "GOBBY_FALKORDB_PORT",
699            "GOBBY_FALKORDB_PASSWORD",
700            "GOBBY_QDRANT_URL",
701            "GOBBY_QDRANT_API_KEY",
702            "GOBBY_EMBEDDING_URL",
703            "GOBBY_EMBEDDING_MODEL",
704            "GOBBY_EMBEDDING_API_KEY",
705            "GOBBY_EMBEDDING_VECTOR_DIM",
706        ] {
707            unsafe { std::env::remove_var(key) };
708        }
709    }
710
711    fn config_value_for<'a>(
712        values: &'a std::collections::HashMap<&'a str, &'a str>,
713    ) -> impl FnMut(&str) -> Option<String> + 'a {
714        |key| values.get(key).map(|value| (*value).to_string())
715    }
716
717    #[test]
718    #[serial_test::serial]
719    fn adapter_env_precedence_and_json_decode() {
720        clear_service_env();
721        unsafe { std::env::set_var("GOBBY_FALKORDB_HOST", "env-falkor.local") };
722        let values = std::collections::HashMap::from([
723            ("databases.falkordb.host", r#""stored-falkor.local""#),
724            ("databases.falkordb.port", r#""16380""#),
725            ("databases.falkordb.requirepass", r#""stored-pass""#),
726            ("databases.qdrant.url", r#""http://qdrant.local:6333""#),
727            ("databases.qdrant.api_key", r#""qdrant-key""#),
728            ("embeddings.api_base", r#""http://embeddings.local:11434""#),
729            ("embeddings.model", r#""embed-model""#),
730            ("embeddings.api_key", "null"),
731        ]);
732
733        let falkor = resolve_falkordb_config_from_values(config_value_for(&values), |value| {
734            Ok(value.to_string())
735        })
736        .expect("falkordb config");
737        let qdrant = resolve_qdrant_config_from_values(config_value_for(&values), |value| {
738            Ok(value.to_string())
739        })
740        .expect("qdrant config");
741        let embedding = resolve_embedding_config_from_values(config_value_for(&values), |value| {
742            Ok(value.to_string())
743        })
744        .expect("embedding config");
745
746        assert_eq!(falkor.host, "env-falkor.local");
747        assert_eq!(falkor.port, 16380);
748        assert_eq!(falkor.password.as_deref(), Some("stored-pass"));
749        assert_eq!(qdrant.url.as_deref(), Some("http://qdrant.local:6333"));
750        assert_eq!(qdrant.api_key.as_deref(), Some("qdrant-key"));
751        assert_eq!(embedding.api_base, "http://embeddings.local:11434");
752        assert_eq!(embedding.model, "embed-model");
753        assert_eq!(embedding.api_key, None);
754        clear_service_env();
755    }
756
757    #[test]
758    #[serial_test::serial]
759    fn adapter_resolves_config_store_secrets() {
760        clear_service_env();
761        let values = std::collections::HashMap::from([
762            ("databases.falkordb.host", "falkor.local"),
763            (
764                "databases.falkordb.requirepass",
765                "$secret:falkordb_password",
766            ),
767            ("databases.qdrant.url", "http://qdrant.local:6333"),
768            ("databases.qdrant.api_key", "$secret:qdrant_api_key"),
769            ("embeddings.api_base", "http://embeddings.local:11434"),
770            ("embeddings.api_key", "$secret:embedding_api_key"),
771        ]);
772
773        fn resolve_secret_stub(value: &str) -> anyhow::Result<String> {
774            match value {
775                "$secret:falkordb_password" => Ok("resolved-falkor".to_string()),
776                "$secret:qdrant_api_key" => Ok("resolved-qdrant".to_string()),
777                "$secret:embedding_api_key" => Ok("resolved-embedding".to_string()),
778                value => Ok(value.to_string()),
779            }
780        }
781
782        let falkor =
783            resolve_falkordb_config_from_values(config_value_for(&values), resolve_secret_stub)
784                .expect("falkordb config");
785        let qdrant =
786            resolve_qdrant_config_from_values(config_value_for(&values), resolve_secret_stub)
787                .expect("qdrant config");
788        let embedding =
789            resolve_embedding_config_from_values(config_value_for(&values), resolve_secret_stub)
790                .expect("embedding config");
791
792        assert_eq!(falkor.password.as_deref(), Some("resolved-falkor"));
793        assert_eq!(qdrant.api_key.as_deref(), Some("resolved-qdrant"));
794        assert_eq!(embedding.api_key.as_deref(), Some("resolved-embedding"));
795    }
796
797    #[test]
798    #[serial_test::serial]
799    fn vector_dim_setting_resolves_env_and_config_store() {
800        clear_service_env();
801        let values = std::collections::HashMap::from([("embeddings.vector_dim", "1536")]);
802
803        let settings = resolve_code_vector_settings_from_values(config_value_for(&values))
804            .expect("config-store vector settings");
805        assert_eq!(settings.vector_dim, Some(1536));
806
807        unsafe { std::env::set_var("GOBBY_EMBEDDING_VECTOR_DIM", "3072") };
808        let settings = resolve_code_vector_settings_from_values(config_value_for(&values))
809            .expect("env vector settings");
810        assert_eq!(settings.vector_dim, Some(3072));
811
812        unsafe { std::env::remove_var("GOBBY_EMBEDDING_VECTOR_DIM") };
813        let null_values = std::collections::HashMap::from([("embeddings.vector_dim", "null")]);
814        let settings = resolve_code_vector_settings_from_values(config_value_for(&null_values))
815            .expect("null config-store vector settings");
816        assert_eq!(settings.vector_dim, None);
817
818        let invalid_values =
819            std::collections::HashMap::from([("embeddings.vector_dim", r#""wide""#)]);
820        let err = resolve_code_vector_settings_from_values(config_value_for(&invalid_values))
821            .expect_err("invalid vector dim must error");
822        assert!(matches!(
823            err,
824            CodeVectorConfigError::InvalidVectorDim { .. }
825        ));
826        clear_service_env();
827    }
828
829    #[test]
830    fn falkor_config_wrapper_shape() {
831        let source = include_str!("config.rs");
832        assert!(source.contains("pub struct FalkorConfig"));
833        assert!(source.contains("pub graph_name: String"));
834        assert!(source.contains("gobby_core::config::resolve_falkordb_config"));
835        assert!(source.contains("graph_name: FALKORDB_GRAPH_NAME.to_string()"));
836    }
837
838    #[test]
839    fn phase7_context_and_falkor_resolver_visible() {
840        let source = include_str!("config.rs");
841        assert!(source.contains("pub falkordb: Option<FalkorConfig>"));
842        assert!(source.contains("let falkordb = resolve_falkordb_config("));
843        assert!(source.contains("pub const FALKORDB_GRAPH_NAME: &str = \"gobby_code\";"));
844        assert!(source.contains("graph_name: FALKORDB_GRAPH_NAME.to_string()"));
845    }
846
847    #[test]
848    fn phase7_falkordb_config_store_keys_visible() {
849        let source = include_str!("config.rs");
850        for key in [
851            FALKORDB_HOST_CONFIG_KEY,
852            FALKORDB_PORT_CONFIG_KEY,
853            FALKORDB_PASSWORD_CONFIG_KEY,
854            GOBBY_FALKORDB_HOST_ENV,
855            GOBBY_FALKORDB_PORT_ENV,
856            GOBBY_FALKORDB_PASSWORD_ENV,
857        ] {
858            assert!(source.contains(key), "missing {key}");
859        }
860    }
861
862    #[test]
863    fn phase7_neo4j_transition_state_absent() {
864        let source = include_str!("config.rs");
865        let config_type = ["pub struct Neo", "4jConfig"].concat();
866        let resolver = ["resolve_neo", "4j_config"].concat();
867        let context_field = ["pub neo", "4j: Option<Neo", "4jConfig>"].concat();
868        assert!(!source.contains(&config_type));
869        assert!(!source.contains(&resolver));
870        assert!(!source.contains(&context_field));
871    }
872
873    #[test]
874    fn test_resolve_project_id_requires_project_context() {
875        let tmp = tempfile::tempdir().expect("tempdir");
876        let err = resolve_project_id(tmp.path()).expect_err("missing project context must fail");
877
878        assert!(
879            err.to_string().contains("No gcode project found"),
880            "unexpected error: {err}"
881        );
882        assert!(
883            err.to_string().contains("gcode init"),
884            "unexpected error: {err}"
885        );
886    }
887
888    #[test]
889    fn main_repo_keeps_project_json_id() {
890        let tmp = tempfile::tempdir().expect("tempdir");
891        write_project_json(
892            tmp.path(),
893            serde_json::json!({
894                "id": "main-project-id",
895                "name": "main"
896            }),
897        );
898
899        let identity =
900            resolve_project_identity(tmp.path(), MissingIdentity::Error).expect("identity");
901
902        assert_eq!(identity.project_id, "main-project-id");
903        assert_eq!(identity.source, ProjectIdentitySource::ProjectJson);
904        assert!(!identity.should_write_gcode_json);
905        assert!(identity.warning.is_none());
906    }
907
908    #[test]
909    fn self_referential_parent_marker_keeps_project_json_id() {
910        let tmp = tempfile::tempdir().expect("tempdir");
911        let root = tmp.path().canonicalize().expect("canonical root");
912        write_project_json(
913            &root,
914            serde_json::json!({
915                "id": "main-project-id",
916                "name": "main",
917                "parent_project_path": root.to_string_lossy(),
918                "parent_project_id": "main-project-id"
919            }),
920        );
921
922        let identity = resolve_project_identity(&root, MissingIdentity::Error).expect("identity");
923
924        assert_eq!(identity.project_id, "main-project-id");
925        assert_eq!(identity.source, ProjectIdentitySource::ProjectJson);
926        assert!(!identity.should_write_gcode_json);
927        assert!(identity.warning.is_none());
928    }
929
930    #[test]
931    fn isolated_marker_uses_path_derived_id_without_warning() {
932        let tmp = tempfile::tempdir().expect("tempdir");
933        write_project_json(
934            tmp.path(),
935            serde_json::json!({
936                "id": "parent-id",
937                "parent_project_path": "/parent",
938                "parent_project_id": "parent-id"
939            }),
940        );
941
942        let identity =
943            resolve_project_identity(tmp.path(), MissingIdentity::Error).expect("identity");
944
945        assert_eq!(
946            identity.project_id,
947            crate::project::code_index_id_for_root(tmp.path())
948        );
949        assert_eq!(identity.source, ProjectIdentitySource::IsolatedRoot);
950        assert!(!identity.should_write_gcode_json);
951        assert!(identity.warning.is_none());
952    }
953
954    #[test]
955    fn linked_worktree_uses_path_id_and_warns_only_for_copied_project_id() {
956        let tmp = tempfile::tempdir().expect("tempdir");
957        let (_repo, linked) = create_linked_worktree(&tmp);
958
959        let identity = resolve_project_identity(&linked, MissingIdentity::Error).expect("identity");
960
961        assert_eq!(
962            identity.project_id,
963            crate::project::code_index_id_for_root(&linked)
964        );
965        assert_eq!(identity.source, ProjectIdentitySource::LinkedWorktree);
966        assert!(identity.warning.is_none());
967        assert!(!identity.should_write_gcode_json);
968
969        write_project_json(
970            &linked,
971            serde_json::json!({
972                "id": "copied-parent-id",
973                "name": "linked"
974            }),
975        );
976        let copied =
977            resolve_project_identity(&linked, MissingIdentity::Error).expect("copied identity");
978
979        assert_eq!(copied.source, ProjectIdentitySource::LinkedWorktree);
980        assert_eq!(
981            copied.project_id,
982            crate::project::code_index_id_for_root(&linked)
983        );
984        assert!(copied.warning.as_deref().unwrap_or("").contains("copied"));
985        assert!(!copied.should_write_gcode_json);
986    }
987
988    #[test]
989    fn generated_identity_writes_only_for_non_isolated_roots() {
990        let tmp = tempfile::tempdir().expect("tempdir");
991
992        let identity =
993            resolve_project_identity(tmp.path(), MissingIdentity::Generate).expect("identity");
994
995        assert_eq!(identity.source, ProjectIdentitySource::Generated);
996        assert!(identity.should_write_gcode_json);
997        assert_eq!(
998            identity.project_id,
999            crate::project::code_index_id_for_root(tmp.path())
1000        );
1001    }
1002}