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