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