Skip to main content

gobby_code/config/
context.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 anyhow::Context as _;
12use gobby_core::project::{find_project_root, read_project_id};
13use postgres::Client;
14use uuid::Uuid;
15
16use super::services::{
17    read_standalone_config_optional, resolve_code_vector_settings, resolve_embedding_config,
18    resolve_falkordb_config, resolve_qdrant_config,
19};
20use crate::db;
21use crate::git::{self, WorktreeKind};
22use crate::utils::short_id;
23
24/// FalkorDB connection configuration.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct FalkorConfig {
27    pub host: String,
28    pub port: u16,
29    pub password: Option<String>,
30    pub graph_name: String,
31}
32
33/// Qdrant connection configuration.
34pub type QdrantConfig = gobby_core::config::QdrantConfig;
35
36/// Embedding API configuration (OpenAI-compatible endpoint).
37pub type EmbeddingConfig = gobby_core::config::EmbeddingConfig;
38
39pub const FALKORDB_GRAPH_NAME: &str = "gobby_code";
40pub const CODE_SYMBOL_COLLECTION_PREFIX: &str = "code_symbols_";
41
42pub const GOBBY_FALKORDB_HOST_ENV: &str = "GOBBY_FALKORDB_HOST";
43pub const GOBBY_FALKORDB_PORT_ENV: &str = "GOBBY_FALKORDB_PORT";
44pub const GOBBY_FALKORDB_PASSWORD_ENV: &str = "GOBBY_FALKORDB_PASSWORD";
45
46pub const FALKORDB_HOST_CONFIG_KEY: &str = "databases.falkordb.host";
47pub const FALKORDB_PORT_CONFIG_KEY: &str = "databases.falkordb.port";
48pub const FALKORDB_PASSWORD_CONFIG_KEY: &str = "databases.falkordb.requirepass";
49
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
51pub struct CodeVectorSettings {
52    pub vector_dim: Option<usize>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct ServiceConfigSelection {
57    pub falkordb: bool,
58    pub qdrant: bool,
59    pub embedding: bool,
60    pub code_vectors: bool,
61}
62
63impl ServiceConfigSelection {
64    pub const fn all() -> Self {
65        Self {
66            falkordb: true,
67            qdrant: true,
68            embedding: true,
69            code_vectors: true,
70        }
71    }
72
73    pub const fn database_only() -> Self {
74        Self {
75            falkordb: false,
76            qdrant: false,
77            embedding: false,
78            code_vectors: false,
79        }
80    }
81
82    pub const fn falkordb_only() -> Self {
83        Self {
84            falkordb: true,
85            qdrant: false,
86            embedding: false,
87            code_vectors: false,
88        }
89    }
90
91    pub const fn vectors() -> Self {
92        Self {
93            falkordb: false,
94            qdrant: true,
95            embedding: true,
96            code_vectors: true,
97        }
98    }
99
100    pub const fn hybrid_search() -> Self {
101        Self {
102            falkordb: true,
103            qdrant: true,
104            embedding: true,
105            code_vectors: false,
106        }
107    }
108}
109
110impl Default for ServiceConfigSelection {
111    fn default() -> Self {
112        Self::all()
113    }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum CodeVectorConfigError {
118    InvalidVectorDim { source: &'static str, value: String },
119    Read { source: String },
120}
121
122impl fmt::Display for CodeVectorConfigError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            Self::InvalidVectorDim { source, value } => write!(
126                f,
127                "invalid code vector dimension from {source}: `{value}` must be a positive integer"
128            ),
129            Self::Read { source } => write!(f, "failed to read code vector config: {source}"),
130        }
131    }
132}
133
134impl std::error::Error for CodeVectorConfigError {}
135
136impl FalkorConfig {
137    pub fn connection_config(&self) -> gobby_core::config::FalkorConfig {
138        gobby_core::config::FalkorConfig {
139            host: self.host.clone(),
140            port: self.port,
141            password: self.password.clone(),
142        }
143    }
144}
145
146/// Resolved runtime context for gcode commands.
147#[derive(Debug, Clone)]
148pub struct Context {
149    /// PostgreSQL hub DSN
150    pub database_url: String,
151    /// Project root directory
152    pub project_root: PathBuf,
153    /// Project ID (from .gobby/project.json or DB lookup)
154    pub project_id: String,
155    /// Suppress warnings
156    pub quiet: bool,
157    /// FalkorDB config (None if unavailable)
158    pub falkordb: Option<FalkorConfig>,
159    /// Qdrant config (None if unavailable)
160    pub qdrant: Option<QdrantConfig>,
161    /// Embedding API config (None if unavailable → no semantic search)
162    pub embedding: Option<EmbeddingConfig>,
163    /// Code-symbol vector projection settings owned by gcode.
164    pub code_vectors: CodeVectorSettings,
165    /// Gobby daemon base URL (e.g. http://localhost:60887)
166    pub daemon_url: Option<String>,
167    /// Project read/index scope.
168    pub index_scope: ProjectIndexScope,
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub enum ProjectIndexScope {
173    #[default]
174    Single,
175    Overlay {
176        overlay_project_id: String,
177        overlay_root: PathBuf,
178        parent_project_id: String,
179        parent_root: PathBuf,
180    },
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum MissingIdentity {
185    Error,
186    Generate,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum ProjectIdentitySource {
191    ProjectJson,
192    GcodeJson,
193    IsolatedRoot,
194    IsolatedOverlay,
195    LinkedWorktree,
196    Generated,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct ProjectIdentity {
201    pub project_id: String,
202    pub root: PathBuf,
203    pub source: ProjectIdentitySource,
204    pub warning: Option<String>,
205    pub should_write_gcode_json: bool,
206    pub index_scope: ProjectIndexScope,
207}
208
209impl Context {
210    /// Resolve context from CLI args and filesystem state.
211    pub fn resolve(project_override: Option<&str>, quiet: bool) -> anyhow::Result<Self> {
212        Self::resolve_with_services(project_override, quiet, ServiceConfigSelection::all())
213    }
214
215    pub fn resolve_with_services(
216        project_override: Option<&str>,
217        quiet: bool,
218        services: ServiceConfigSelection,
219    ) -> anyhow::Result<Self> {
220        let database_url = db::resolve_database_url()?;
221        let project_root = match project_override {
222            Some(p) => {
223                let path = PathBuf::from(p);
224                if path.is_dir() {
225                    path.canonicalize()?
226                } else {
227                    // Not a directory — try name lookup in the PostgreSQL hub.
228                    resolve_project_by_name(p, &database_url)?
229                }
230            }
231            None => detect_project_root()?,
232        };
233
234        let identity = resolve_project_identity(&project_root, MissingIdentity::Error)?;
235        warn_project_identity(&identity, quiet);
236        let project_id = identity.project_id;
237        let index_scope = identity.index_scope;
238
239        // Resolve service configs from config_store (best-effort).
240        let standalone_config = read_standalone_config_optional();
241        let mut conn = db::connect_readonly(&database_url)?;
242        validate_parent_code_index(&mut conn, &index_scope)?;
243        let falkordb = if services.falkordb {
244            resolve_falkordb_config(&mut conn, standalone_config.clone(), quiet)?
245        } else {
246            None
247        };
248        let qdrant = if services.qdrant {
249            resolve_qdrant_config(&mut conn, standalone_config.clone(), quiet)?
250        } else {
251            None
252        };
253        let embedding = if services.embedding {
254            resolve_embedding_config(&mut conn, standalone_config.clone(), quiet)
255        } else {
256            None
257        };
258        let code_vectors = if services.code_vectors {
259            resolve_code_vector_settings(&mut conn, standalone_config)?
260        } else {
261            CodeVectorSettings::default()
262        };
263
264        let daemon_url = resolve_daemon_url();
265
266        Ok(Self {
267            database_url,
268            project_root,
269            project_id,
270            quiet,
271            falkordb,
272            qdrant,
273            embedding,
274            code_vectors,
275            daemon_url,
276            index_scope,
277        })
278    }
279
280    /// Resolve service config for a caller-supplied project id without touching cwd identity.
281    ///
282    /// This is for daemon-style calls that already know the target project id and must not
283    /// discover a project root from cwd. The returned context therefore has an empty
284    /// `project_root`, default code-vector settings, and `None` for services that are not
285    /// needed by project-id-only graph operations.
286    pub fn resolve_for_project_id(project_id: &str, quiet: bool) -> anyhow::Result<Self> {
287        let project_id = normalize_project_id(project_id)?;
288        let database_url = db::resolve_database_url()?;
289
290        let standalone_config = read_standalone_config_optional();
291        let mut conn = db::connect_readonly(&database_url)?;
292        let falkordb = resolve_falkordb_config(&mut conn, standalone_config, quiet)?;
293
294        let daemon_url = resolve_daemon_url();
295
296        Ok(Self {
297            database_url,
298            project_root: PathBuf::new(),
299            project_id,
300            quiet,
301            falkordb,
302            qdrant: None,
303            embedding: None,
304            code_vectors: CodeVectorSettings::default(),
305            daemon_url,
306            index_scope: ProjectIndexScope::Single,
307        })
308    }
309}
310
311pub fn resolve_project_identity(
312    project_root: &Path,
313    missing: MissingIdentity,
314) -> anyhow::Result<ProjectIdentity> {
315    let root = project_root
316        .canonicalize()
317        .unwrap_or_else(|_| absolute_fallback(project_root));
318
319    if let Some(marker) = crate::project::read_isolation_marker(&root) {
320        if marker.parent_project_path.is_some() ^ marker.parent_project_id.is_some() {
321            anyhow::bail!(
322                "invalid isolation marker in {}: parent_project_path and parent_project_id must be set together",
323                root.join(".gobby").join("project.json").display()
324            );
325        }
326
327        if is_self_referential_isolation_marker(&marker, &root) {
328            return resolve_non_isolated_project_identity(root, missing);
329        }
330
331        if let (Some(parent_project_path), Some(parent_project_id)) = (
332            marker.parent_project_path.as_deref(),
333            marker.parent_project_id.as_deref(),
334        ) {
335            let overlay_project_id = crate::project::code_index_id_for_root(&root);
336            let parent_root = resolve_parent_project_root(&root, parent_project_path);
337            let parent_project_id = normalize_project_id(parent_project_id)?;
338            return Ok(ProjectIdentity {
339                project_id: overlay_project_id.clone(),
340                root: root.clone(),
341                source: ProjectIdentitySource::IsolatedOverlay,
342                warning: None,
343                should_write_gcode_json: false,
344                index_scope: ProjectIndexScope::Overlay {
345                    overlay_project_id,
346                    overlay_root: root,
347                    parent_project_id,
348                    parent_root,
349                },
350            });
351        }
352
353        return Ok(ProjectIdentity {
354            project_id: crate::project::code_index_id_for_root(&root),
355            root,
356            source: ProjectIdentitySource::IsolatedRoot,
357            warning: None,
358            should_write_gcode_json: false,
359            index_scope: ProjectIndexScope::Single,
360        });
361    }
362
363    resolve_non_isolated_project_identity(root, missing)
364}
365
366fn resolve_non_isolated_project_identity(
367    root: PathBuf,
368    missing: MissingIdentity,
369) -> anyhow::Result<ProjectIdentity> {
370    let worktree = git::worktree_info(&root)?;
371    if worktree.kind == WorktreeKind::Linked {
372        let project_id = crate::project::code_index_id_for_root(&worktree.top_level);
373        let copied_id = read_project_id(&worktree.top_level).ok();
374        let warning = copied_id
375            .filter(|id| id != &project_id)
376            .map(|id| {
377                format!(
378                    "linked git worktree {} has copied .gobby/project.json id {}; using filesystem-scoped code index id {}",
379                    worktree.top_level.display(),
380                    short_id(&id),
381                    short_id(&project_id)
382                )
383            });
384
385        return Ok(ProjectIdentity {
386            project_id,
387            root: worktree.top_level,
388            source: ProjectIdentitySource::LinkedWorktree,
389            warning,
390            should_write_gcode_json: false,
391            index_scope: ProjectIndexScope::Single,
392        });
393    }
394
395    let gobby_dir = root.join(".gobby");
396    if gobby_dir.join("project.json").exists() {
397        return Ok(ProjectIdentity {
398            project_id: read_project_id(&root)?,
399            root,
400            source: ProjectIdentitySource::ProjectJson,
401            warning: None,
402            should_write_gcode_json: false,
403            index_scope: ProjectIndexScope::Single,
404        });
405    }
406    if gobby_dir.join("gcode.json").exists() {
407        return Ok(ProjectIdentity {
408            project_id: crate::project::read_gcode_json(&root)?,
409            root,
410            source: ProjectIdentitySource::GcodeJson,
411            warning: None,
412            should_write_gcode_json: false,
413            index_scope: ProjectIndexScope::Single,
414        });
415    }
416
417    match missing {
418        MissingIdentity::Generate => Ok(ProjectIdentity {
419            project_id: crate::project::code_index_id_for_root(&root),
420            root,
421            source: ProjectIdentitySource::Generated,
422            warning: None,
423            should_write_gcode_json: true,
424            index_scope: ProjectIndexScope::Single,
425        }),
426        MissingIdentity::Error => anyhow::bail!(
427            "No gcode project found. Run `gcode init` to initialize, \
428             or use `--project <path>` to specify a project directory."
429        ),
430    }
431}
432
433fn is_self_referential_isolation_marker(
434    marker: &crate::project::IsolationMarker,
435    root: &Path,
436) -> bool {
437    let Some(parent_project_path) = marker.parent_project_path.as_deref() else {
438        return false;
439    };
440    resolve_parent_project_root(root, parent_project_path) == root
441}
442
443fn resolve_parent_project_root(root: &Path, parent_project_path: &str) -> PathBuf {
444    let parent = PathBuf::from(parent_project_path);
445    let parent = if parent.is_absolute() {
446        parent
447    } else {
448        root.join(parent)
449    };
450    parent.canonicalize().unwrap_or(parent)
451}
452
453fn normalize_project_id(project_id: &str) -> anyhow::Result<String> {
454    let project_id = project_id.trim();
455    if project_id.is_empty() {
456        anyhow::bail!("--project-id must not be empty");
457    }
458    Uuid::parse_str(project_id)
459        .map(|id| id.to_string())
460        .with_context(|| format!("--project-id must be a UUID, got `{project_id}`"))
461}
462
463pub(crate) fn validate_parent_code_index(
464    conn: &mut Client,
465    scope: &ProjectIndexScope,
466) -> anyhow::Result<()> {
467    let ProjectIndexScope::Overlay {
468        parent_project_id,
469        parent_root,
470        ..
471    } = scope
472    else {
473        return Ok(());
474    };
475
476    let exists = conn
477        .query_one(
478            "SELECT EXISTS(
479                SELECT 1 FROM code_indexed_files WHERE project_id = $1
480            )",
481            &[parent_project_id],
482        )
483        .and_then(|row| row.try_get::<_, bool>(0))?;
484
485    if !exists {
486        anyhow::bail!(
487            "parent code index missing for {} ({})",
488            parent_root.display(),
489            short_id(parent_project_id)
490        );
491    }
492
493    Ok(())
494}
495
496pub fn warn_project_identity(identity: &ProjectIdentity, quiet: bool) {
497    if quiet {
498        return;
499    }
500    if let Some(warning) = &identity.warning {
501        eprintln!("Warning: {warning}");
502    }
503}
504
505/// Resolve a `--project` name to a project root by looking up `code_indexed_projects`.
506///
507/// Matches against the basename of `root_path` in the PostgreSQL hub.
508fn resolve_project_by_name(name: &str, database_url: &str) -> anyhow::Result<PathBuf> {
509    let mut conn = db::connect_readonly(database_url)?;
510    let (slash_suffix, backslash_suffix) = project_name_suffixes(name);
511    let rows = conn.query(
512        "SELECT root_path FROM code_indexed_projects
513         WHERE root_path = $1
514            OR right(root_path, length($2)) = $2
515            OR right(root_path, length($3)) = $3
516         ORDER BY last_indexed_at DESC NULLS LAST",
517        &[&name, &slash_suffix, &backslash_suffix],
518    )?;
519
520    for row in rows {
521        let root_path: String = row.try_get("root_path")?;
522        let path = PathBuf::from(&root_path);
523        if path.is_dir() {
524            return Ok(path);
525        }
526    }
527
528    anyhow::bail!(
529        "Project '{}' not found. Run `gcode projects` to see indexed projects.",
530        name
531    )
532}
533
534pub(super) fn project_name_suffixes(name: &str) -> (String, String) {
535    (format!("/{name}"), format!("\\{name}"))
536}
537
538/// Detect project root by walking up the directory tree.
539///
540/// Resolution order:
541/// 1. `.gobby/project.json` or `.gobby/gcode.json` (identity file)
542/// 2. VCS root (`.git` or `.hg`)
543/// 3. Current working directory
544pub fn detect_project_root() -> anyhow::Result<PathBuf> {
545    let cwd = std::env::current_dir()?;
546    detect_project_root_from(&cwd)
547}
548
549pub fn detect_project_root_from(start: &Path) -> anyhow::Result<PathBuf> {
550    let start = start
551        .canonicalize()
552        .unwrap_or_else(|_| absolute_fallback(start));
553    let start = if start.is_file() {
554        start
555            .parent()
556            .map(Path::to_path_buf)
557            .unwrap_or_else(|| start.clone())
558    } else {
559        start
560    };
561
562    // First: look for an identity file (.gobby/project.json or .gobby/gcode.json)
563    if let Some(root) = find_project_root(&start) {
564        return Ok(root.canonicalize().unwrap_or(root));
565    }
566
567    // Second: prefer the Git worktree top-level, including linked worktrees.
568    if let Ok(info) = git::worktree_info(&start)
569        && info.kind != WorktreeKind::NotGit
570    {
571        return Ok(info.top_level);
572    }
573
574    // Third: fall back to VCS root
575    let mut dir = start.as_path();
576    loop {
577        if dir.join(".git").exists() || dir.join(".hg").exists() {
578            return Ok(dir.to_path_buf());
579        }
580        match dir.parent() {
581            Some(parent) => dir = parent,
582            None => return Ok(start), // Last resort: start
583        }
584    }
585}
586
587/// Resolve Gobby daemon base URL.
588///
589/// Resolution order:
590/// 1. Non-empty `GOBBY_PORT` env var, composed as `http://localhost:{GOBBY_PORT}`.
591/// 2. `~/.gobby/bootstrap.yaml` `daemon_port` plus optional `bind_host`.
592/// 3. `http://localhost:60887` when the env var is empty/missing or bootstrap
593///    is unavailable, unreadable, malformed, or missing `daemon_port`.
594pub(crate) fn resolve_daemon_url() -> Option<String> {
595    // Env var override takes priority (empty value falls through to defaults)
596    if let Ok(port) = std::env::var("GOBBY_PORT")
597        && !port.is_empty()
598    {
599        return Some(format!("http://localhost:{port}"));
600    }
601
602    // Read from bootstrap.yaml
603    let bootstrap_path = db::bootstrap_path().ok();
604    if let Some(bootstrap_path) = bootstrap_path
605        && let Ok(contents) = std::fs::read_to_string(&bootstrap_path)
606        && let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents)
607        && let Some(port) = yaml.get("daemon_port").and_then(|v| v.as_u64())
608    {
609        let host = yaml
610            .get("bind_host")
611            .and_then(|v| v.as_str())
612            .unwrap_or("localhost");
613        return Some(format!("http://{}:{port}", client_daemon_host(host)));
614    }
615
616    // Well-known default (matches gsqz)
617    Some("http://localhost:60887".to_string())
618}
619
620fn client_daemon_host(host: &str) -> String {
621    match host.trim() {
622        "" | "0.0.0.0" | "::" | "[::]" => "localhost".to_string(),
623        host if host.contains(':') && !host.starts_with('[') => format!("[{host}]"),
624        host => host.to_string(),
625    }
626}
627
628/// Resolve project ID from identity files or generate deterministically.
629///
630/// Resolution order:
631/// 1. `.gobby/project.json` — gobby's file (reads `"id"`, falls back to `"project_id"`)
632/// 2. `.gobby/gcode.json` — gcode's standalone identity
633/// 3. Generate deterministic UUID5 from canonical path (no filesystem writes)
634#[cfg(test)]
635pub(super) fn resolve_project_id(project_root: &Path) -> anyhow::Result<String> {
636    Ok(resolve_project_identity(project_root, MissingIdentity::Error)?.project_id)
637}
638
639fn absolute_fallback(path: &Path) -> PathBuf {
640    if path.is_absolute() {
641        path.to_path_buf()
642    } else {
643        std::env::current_dir()
644            .unwrap_or_else(|_| std::env::temp_dir())
645            .join(path)
646    }
647}