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