Skip to main content

gobby_code/
db.rs

1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use anyhow::{Context as _, anyhow, bail};
5use gobby_core::provisioning::{GCORE_CONFIG_FILENAME, StandaloneConfig};
6use postgres::{Client, GenericClient};
7use serde::Deserialize;
8
9use crate::models::{CallRelation, CallTargetKind, ImportRelation, Symbol};
10use crate::schema;
11
12const GCODE_DATABASE_URL_ENV: &str = "GCODE_DATABASE_URL";
13const GOBBY_POSTGRES_DSN_ENV: &str = "GOBBY_POSTGRES_DSN";
14const GCODE_CONFIG_FILENAME: &str = "gcode.yaml";
15const LOCAL_CLI_TOKEN_FILENAME: &str = "local_cli_token";
16const BROKER_TIMEOUT: Duration = Duration::from_secs(3);
17
18#[derive(Debug, Deserialize)]
19struct BrokerDatabaseUrlResponse {
20    database_url: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24struct BootstrapDatabase {
25    hub_backend: String,
26    database_url: Option<String>,
27}
28
29/// Return Gobby home, respecting `GOBBY_HOME` when the daemon was configured with it.
30pub fn gobby_home() -> anyhow::Result<PathBuf> {
31    gobby_core::gobby_home()
32}
33
34pub fn bootstrap_path() -> anyhow::Result<PathBuf> {
35    Ok(gobby_home()?.join("bootstrap.yaml"))
36}
37
38/// Resolve the PostgreSQL hub DSN from explicit overrides or Gobby bootstrap config.
39///
40/// gcode intentionally has no local database fallback. It asks the long-lived daemon
41/// broker first, then falls back to explicit DSN sources for daemonless operation.
42pub fn resolve_database_url() -> anyhow::Result<String> {
43    let home = gobby_home()?;
44    resolve_database_url_from_sources_with_identity_and_reachability(
45        &home,
46        |bootstrap_path| resolve_brokered_database_url_at(&home, bootstrap_path),
47        |name| std::env::var(name).ok(),
48        |url| gobby_core::postgres::connect_readonly(url).is_ok(),
49        gobby_core::provisioning::probe_postgres_hub_identity,
50    )
51}
52
53#[cfg(test)]
54fn resolve_database_url_from_sources(
55    home: &Path,
56    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
57    get_var: impl FnMut(&str) -> Option<String>,
58    database_reachable: impl FnMut(&str) -> bool,
59) -> anyhow::Result<String> {
60    resolve_database_url_from_sources_with_identity_and_reachability(
61        home,
62        broker_resolver,
63        get_var,
64        database_reachable,
65        gobby_core::provisioning::probe_postgres_hub_identity,
66    )
67}
68
69#[cfg(test)]
70fn resolve_database_url_from_sources_with_identity(
71    home: &Path,
72    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
73    get_var: impl FnMut(&str) -> Option<String>,
74    database_reachable: impl FnMut(&str) -> bool,
75    identity_probe: impl FnMut(&str) -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
76) -> anyhow::Result<String> {
77    resolve_database_url_from_sources_with_identity_and_reachability(
78        home,
79        broker_resolver,
80        get_var,
81        database_reachable,
82        identity_probe,
83    )
84}
85
86fn resolve_database_url_from_sources_with_identity_and_reachability(
87    home: &Path,
88    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
89    get_var: impl FnMut(&str) -> Option<String>,
90    mut database_reachable: impl FnMut(&str) -> bool,
91    mut identity_probe: impl FnMut(
92        &str,
93    )
94        -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
95) -> anyhow::Result<String> {
96    let path = home.join("bootstrap.yaml");
97
98    if let Some(database_url) = resolve_database_url_from_env(get_var) {
99        return Ok(database_url);
100    }
101
102    let gcore_database_url = match resolve_database_url_from_gcore_config(home) {
103        Ok(database_url) => database_url,
104        Err(error) => {
105            log::warn!("failed to read gcore config database URL: {error}");
106            None
107        }
108    };
109
110    if let Ok(database_url) = broker_resolver(&path) {
111        if let Some(database_url) = resolve_recorded_hub_database_url(
112            gcore_database_url.as_deref(),
113            &database_url,
114            &mut database_reachable,
115            &mut identity_probe,
116        )? {
117            return Ok(database_url);
118        }
119        return Ok(database_url);
120    }
121
122    if let Some(database_url) = resolve_database_url_from_bootstrap_file(&path)? {
123        if let Some(database_url) = resolve_recorded_hub_database_url(
124            gcore_database_url.as_deref(),
125            &database_url,
126            &mut database_reachable,
127            &mut identity_probe,
128        )? {
129            return Ok(database_url);
130        }
131        return Ok(database_url);
132    }
133
134    if let Some(database_url) = gcore_database_url {
135        return Ok(database_url);
136    }
137
138    if let Some(database_url) =
139        resolve_database_url_from_config_file(&home.join(GCODE_CONFIG_FILENAME))?
140    {
141        return Ok(database_url);
142    }
143
144    bail!(
145        "missing Gobby PostgreSQL configuration. Run `gcode setup --standalone`, set {GCODE_DATABASE_URL_ENV}, or configure the Gobby daemon bootstrap."
146    )
147}
148
149fn resolve_recorded_hub_database_url(
150    gcore_database_url: Option<&str>,
151    candidate_database_url: &str,
152    database_reachable: &mut impl FnMut(&str) -> bool,
153    identity_probe: &mut impl FnMut(
154        &str,
155    )
156        -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
157) -> anyhow::Result<Option<String>> {
158    Ok(gobby_core::provisioning::resolve_recorded_hub_database_url(
159        gcore_database_url,
160        Some(candidate_database_url),
161        database_reachable,
162        identity_probe,
163    )?
164    .map(|resolution| resolution.database_url))
165}
166
167fn resolve_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
168    if !path.exists() {
169        return Ok(None);
170    }
171    let contents = std::fs::read_to_string(path)
172        .with_context(|| format!("failed to read Gobby bootstrap at {}", path.display()))?;
173    let bootstrap = parse_bootstrap_database(&contents)?;
174    resolve_database_url_from_bootstrap(&bootstrap).map(Some)
175}
176
177fn resolve_database_url_from_gcore_config(home: &Path) -> anyhow::Result<Option<String>> {
178    let Some(config) = StandaloneConfig::read_at(&home.join(GCORE_CONFIG_FILENAME))? else {
179        return Ok(None);
180    };
181    Ok(config
182        .get("databases.postgres.dsn")
183        .and_then(|value| non_empty_trimmed(Some(value.to_string()))))
184}
185
186fn resolve_database_url_from_env(
187    mut get_var: impl FnMut(&str) -> Option<String>,
188) -> Option<String> {
189    for name in [GCODE_DATABASE_URL_ENV, GOBBY_POSTGRES_DSN_ENV] {
190        if let Some(value) = non_empty_trimmed(get_var(name)) {
191            return Some(value);
192        }
193    }
194    None
195}
196
197fn resolve_database_url_from_config_file(path: &Path) -> anyhow::Result<Option<String>> {
198    if !path.exists() {
199        return Ok(None);
200    }
201    let contents = std::fs::read_to_string(path)
202        .with_context(|| format!("failed to read {}", path.display()))?;
203    parse_gcode_config_database_url(&contents)
204        .with_context(|| format!("failed to parse {}", path.display()))
205}
206
207fn parse_gcode_config_database_url(contents: &str) -> anyhow::Result<Option<String>> {
208    let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
209    let Some(map) = yaml.as_mapping() else {
210        if yaml.is_null() {
211            return Ok(None);
212        }
213        bail!("gcode.yaml must be a mapping");
214    };
215
216    let key = serde_yaml::Value::String("database_url".to_string());
217    match map.get(&key) {
218        Some(value) => match value.as_str() {
219            Some(text) => Ok(non_empty_trimmed(Some(text.to_string()))),
220            None => bail!("gcode.yaml field `database_url` must be a string"),
221        },
222        None => Ok(None),
223    }
224}
225
226fn parse_bootstrap_database(contents: &str) -> anyhow::Result<BootstrapDatabase> {
227    let yaml: serde_yaml::Value =
228        serde_yaml::from_str(contents).context("failed to parse bootstrap.yaml")?;
229    let Some(map) = yaml.as_mapping() else {
230        bail!("bootstrap.yaml must be a mapping");
231    };
232
233    let get_string = |name: &str| -> anyhow::Result<Option<String>> {
234        let key = serde_yaml::Value::String(name.to_string());
235        match map.get(&key) {
236            Some(value) => match value.as_str() {
237                Some(text) if !text.trim().is_empty() => Ok(Some(text.to_string())),
238                Some(_) | None => bail!("bootstrap.yaml field `{name}` must be a string"),
239            },
240            None => Ok(None),
241        }
242    };
243
244    let database_url_ref = get_string("database_url_ref")?;
245    if database_url_ref.is_some() {
246        bail!(
247            "database_url_ref is no longer supported in bootstrap.yaml; store the local PostgreSQL DSN in database_url"
248        );
249    }
250
251    Ok(BootstrapDatabase {
252        hub_backend: get_string("hub_backend")?
253            .context("bootstrap.yaml must include `hub_backend: postgres`")?,
254        database_url: get_string("database_url")?,
255    })
256}
257
258fn resolve_database_url_from_bootstrap(bootstrap: &BootstrapDatabase) -> anyhow::Result<String> {
259    if bootstrap.hub_backend != "postgres" {
260        bail!(
261            "gcode requires `hub_backend: postgres` in bootstrap.yaml. Current hub_backend is `{}`. Configure the Gobby PostgreSQL hub before running gcode.",
262            bootstrap.hub_backend
263        );
264    }
265
266    if let Some(database_url) = bootstrap.database_url.as_deref() {
267        return Ok(database_url.to_string());
268    }
269
270    bail!("hub_backend=postgres requires `database_url` in bootstrap.yaml")
271}
272
273fn non_empty_trimmed(value: Option<String>) -> Option<String> {
274    let trimmed = value.as_ref()?.trim();
275    if trimmed.is_empty() {
276        None
277    } else {
278        Some(trimmed.to_string())
279    }
280}
281
282fn resolve_brokered_database_url_at(
283    gobby_home: &Path,
284    bootstrap_path: &Path,
285) -> anyhow::Result<String> {
286    let token = read_local_cli_token_at(gobby_home)?;
287    let daemon_url = gobby_core::daemon_url::daemon_url_at(bootstrap_path);
288    request_broker_database_url(&daemon_url, &token)
289}
290
291fn read_local_cli_token_at(gobby_home: &Path) -> anyhow::Result<String> {
292    let path = gobby_home.join(LOCAL_CLI_TOKEN_FILENAME);
293    let token = std::fs::read_to_string(&path)
294        .with_context(|| format!("missing local CLI token at {}", path.display()))?;
295    let token = token.trim().to_string();
296    if token.is_empty() {
297        bail!("local CLI token at {} is empty", path.display());
298    }
299    Ok(token)
300}
301
302fn request_broker_database_url(daemon_url: &str, token: &str) -> anyhow::Result<String> {
303    let url = format!(
304        "{}/api/local/runtime/database-url",
305        daemon_url.trim_end_matches('/')
306    );
307    let agent = ureq::AgentBuilder::new().timeout(BROKER_TIMEOUT).build();
308    let response = agent
309        .post(&url)
310        .set("X-Gobby-Local-Token", token)
311        .call()
312        .map_err(|err| anyhow!("database_url broker request failed: {err}"))?;
313    let body: BrokerDatabaseUrlResponse = response
314        .into_json()
315        .context("database_url broker response was not valid JSON")?;
316    let database_url = body.database_url.trim().to_string();
317    if database_url.is_empty() {
318        bail!("database_url broker response was empty");
319    }
320    Ok(database_url)
321}
322
323/// Open a connection for command paths that may write to the hub.
324///
325/// This currently shares the same connection logic as read-only callers, but
326/// keeping the intent explicit preserves a routing point for future pools,
327/// permissions, or replicas.
328pub fn connect_readwrite(database_url: &str) -> anyhow::Result<Client> {
329    let mut client = gobby_core::postgres::connect_readwrite(database_url)?;
330    schema::validate_runtime_schema(&mut client)?;
331    Ok(client)
332}
333
334/// Open a connection for command paths that only read from the hub.
335///
336/// This currently shares the same connection logic as read-write callers, but
337/// keeping the intent explicit preserves a routing point for future pools,
338/// permissions, or replicas.
339pub fn connect_readonly(database_url: &str) -> anyhow::Result<Client> {
340    let mut client = gobby_core::postgres::connect_readonly(database_url)?;
341    schema::validate_runtime_schema(&mut client)?;
342    Ok(client)
343}
344
345pub fn read_config_value(conn: &mut Client, key: &str) -> anyhow::Result<Option<String>> {
346    gobby_core::postgres::read_config_value(conn, key)
347}
348
349#[derive(Debug, Clone)]
350pub struct GraphFileFacts {
351    pub file_path: String,
352    pub imports: Vec<ImportRelation>,
353    pub definitions: Vec<Symbol>,
354    pub calls: Vec<CallRelation>,
355}
356
357pub fn list_indexed_file_paths(
358    conn: &mut impl GenericClient,
359    project_id: &str,
360) -> anyhow::Result<Vec<String>> {
361    let rows = conn.query(
362        "SELECT file_path FROM code_indexed_files WHERE project_id = $1 ORDER BY file_path",
363        &[&project_id],
364    )?;
365    rows.into_iter()
366        .map(|row| row.try_get("file_path").map_err(Into::into))
367        .collect()
368}
369
370pub fn indexed_project_exists(
371    conn: &mut impl GenericClient,
372    project_id: &str,
373) -> anyhow::Result<bool> {
374    Ok(conn
375        .query_opt(
376            "SELECT 1 FROM code_indexed_projects WHERE id = $1",
377            &[&project_id],
378        )?
379        .is_some())
380}
381
382pub fn read_graph_file_facts(
383    conn: &mut impl GenericClient,
384    project_id: &str,
385    file_path: &str,
386) -> anyhow::Result<GraphFileFacts> {
387    let imports = read_imports_for_file(conn, project_id, file_path)?;
388    let definitions = read_symbols_for_file(conn, project_id, file_path)?;
389    let calls = read_calls_for_file(conn, project_id, file_path)?;
390
391    Ok(GraphFileFacts {
392        file_path: file_path.to_string(),
393        imports,
394        definitions,
395        calls,
396    })
397}
398
399pub fn indexed_file_exists(
400    conn: &mut impl GenericClient,
401    project_id: &str,
402    file_path: &str,
403) -> anyhow::Result<bool> {
404    Ok(conn
405        .query_opt(
406            "SELECT 1 FROM code_indexed_files
407             WHERE project_id = $1 AND file_path = $2",
408            &[&project_id, &file_path],
409        )?
410        .is_some())
411}
412
413pub fn mark_graph_sync_attempted(
414    conn: &mut impl GenericClient,
415    project_id: &str,
416    file_path: &str,
417) -> anyhow::Result<bool> {
418    let updated = conn.execute(
419        "UPDATE code_indexed_files
420         SET graph_synced = false, graph_sync_attempted_at = NOW()
421         WHERE project_id = $1 AND file_path = $2",
422        &[&project_id, &file_path],
423    )?;
424    Ok(updated > 0)
425}
426
427pub fn mark_graph_synced(
428    conn: &mut impl GenericClient,
429    project_id: &str,
430    file_path: &str,
431) -> anyhow::Result<bool> {
432    let updated = conn.execute(
433        "UPDATE code_indexed_files
434         SET graph_synced = true, graph_sync_attempted_at = NOW()
435         WHERE project_id = $1 AND file_path = $2",
436        &[&project_id, &file_path],
437    )?;
438    Ok(updated > 0)
439}
440
441pub fn reset_graph_sync_for_project(
442    conn: &mut impl GenericClient,
443    project_id: &str,
444) -> anyhow::Result<u64> {
445    Ok(conn.execute(
446        "UPDATE code_indexed_files
447         SET graph_synced = false, graph_sync_attempted_at = NULL
448         WHERE project_id = $1",
449        &[&project_id],
450    )?)
451}
452
453pub fn mark_vectors_synced(
454    conn: &mut impl GenericClient,
455    project_id: &str,
456    file_path: &str,
457) -> anyhow::Result<bool> {
458    let updated = conn.execute(
459        "UPDATE code_indexed_files
460         SET vectors_synced = true
461         WHERE project_id = $1 AND file_path = $2",
462        &[&project_id, &file_path],
463    )?;
464    Ok(updated > 0)
465}
466
467pub fn mark_project_vectors_synced(
468    conn: &mut impl GenericClient,
469    project_id: &str,
470) -> anyhow::Result<u64> {
471    Ok(conn.execute(
472        "UPDATE code_indexed_files
473         SET vectors_synced = true
474         WHERE project_id = $1",
475        &[&project_id],
476    )?)
477}
478
479/// Return the vector sync state for an indexed file.
480///
481/// `None` means the file is not present in `code_indexed_files`; `Some(value)`
482/// means the file exists and reports that `vectors_synced` state.
483pub fn file_vectors_synced(
484    conn: &mut impl GenericClient,
485    project_id: &str,
486    file_path: &str,
487) -> anyhow::Result<Option<bool>> {
488    let synced = conn
489        .query_opt(
490            "SELECT vectors_synced
491             FROM code_indexed_files
492             WHERE project_id = $1 AND file_path = $2",
493            &[&project_id, &file_path],
494        )?
495        .map(|row| row.try_get::<_, bool>("vectors_synced"))
496        .transpose()?;
497    Ok(synced)
498}
499
500pub fn reset_vectors_sync_for_project(
501    conn: &mut impl GenericClient,
502    project_id: &str,
503) -> anyhow::Result<u64> {
504    Ok(conn.execute(
505        "UPDATE code_indexed_files
506         SET vectors_synced = false
507         WHERE project_id = $1",
508        &[&project_id],
509    )?)
510}
511
512fn read_imports_for_file(
513    conn: &mut impl GenericClient,
514    project_id: &str,
515    file_path: &str,
516) -> anyhow::Result<Vec<ImportRelation>> {
517    let rows = conn.query(
518        "SELECT source_file, target_module
519         FROM code_imports
520         WHERE project_id = $1 AND source_file = $2
521         ORDER BY target_module",
522        &[&project_id, &file_path],
523    )?;
524    rows.into_iter()
525        .map(|row| {
526            Ok(ImportRelation {
527                file_path: row.try_get("source_file")?,
528                module_name: row.try_get("target_module")?,
529            })
530        })
531        .collect()
532}
533
534fn read_symbols_for_file(
535    conn: &mut impl GenericClient,
536    project_id: &str,
537    file_path: &str,
538) -> anyhow::Result<Vec<Symbol>> {
539    let query = format!(
540        "SELECT {} FROM code_symbols s
541         WHERE s.project_id = $1 AND s.file_path = $2
542         ORDER BY s.line_start, s.byte_start",
543        symbol_select_columns("s")
544    );
545    let rows = conn.query(&query, &[&project_id, &file_path])?;
546    rows.iter().map(Symbol::from_row).collect()
547}
548
549fn read_calls_for_file(
550    conn: &mut impl GenericClient,
551    project_id: &str,
552    file_path: &str,
553) -> anyhow::Result<Vec<CallRelation>> {
554    let rows = conn.query(
555        "SELECT caller_symbol_id, callee_symbol_id, callee_name,
556                callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
557         FROM code_calls
558         WHERE project_id = $1 AND file_path = $2
559         ORDER BY line, caller_symbol_id, callee_name",
560        &[&project_id, &file_path],
561    )?;
562    rows.into_iter()
563        .map(|row| {
564            let target_kind: String = row.try_get("callee_target_kind")?;
565            let callee_symbol_id: String = row.try_get("callee_symbol_id")?;
566            let callee_external_module: String = row.try_get("callee_external_module")?;
567            Ok(CallRelation {
568                caller_symbol_id: row.try_get("caller_symbol_id")?,
569                callee_symbol_id: non_empty(callee_symbol_id),
570                callee_name: row.try_get("callee_name")?,
571                callee_target_kind: call_target_kind_from_str(&target_kind)?,
572                callee_external_module: non_empty(callee_external_module),
573                file_path: row.try_get("file_path")?,
574                line: i64_to_usize(row.try_get("line")?, "line")?,
575            })
576        })
577        .collect()
578}
579
580fn non_empty(value: String) -> Option<String> {
581    if value.is_empty() { None } else { Some(value) }
582}
583
584fn call_target_kind_from_str(value: &str) -> anyhow::Result<CallTargetKind> {
585    match value {
586        "symbol" => Ok(CallTargetKind::Symbol),
587        "unresolved" => Ok(CallTargetKind::Unresolved),
588        "external" => Ok(CallTargetKind::External),
589        other => bail!("unknown code_calls.callee_target_kind `{other}`"),
590    }
591}
592
593fn i64_to_usize(value: i64, column: &str) -> anyhow::Result<usize> {
594    value
595        .try_into()
596        .with_context(|| format!("column `{column}` contains negative or too-large value {value}"))
597}
598
599pub fn symbol_select_columns(alias: &str) -> String {
600    let prefix = if alias.is_empty() {
601        String::new()
602    } else {
603        format!("{alias}.")
604    };
605    format!(
606        "{p}id, {p}project_id, {p}file_path, {p}name, {p}qualified_name, \
607         {p}kind, {p}language, {p}byte_start::BIGINT AS byte_start, \
608         {p}byte_end::BIGINT AS byte_end, {p}line_start::BIGINT AS line_start, \
609         {p}line_end::BIGINT AS line_end, {p}signature, {p}docstring, \
610         {p}parent_symbol_id, {p}content_hash, {p}summary, \
611         {p}created_at::TEXT AS created_at, {p}updated_at::TEXT AS updated_at",
612        p = prefix
613    )
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use std::io::{Read as _, Write as _};
620    use std::net::TcpListener;
621    use std::thread;
622
623    fn bootstrap(hub_backend: &str, database_url: Option<&str>) -> BootstrapDatabase {
624        BootstrapDatabase {
625            hub_backend: hub_backend.to_string(),
626            database_url: database_url.map(str::to_string),
627        }
628    }
629
630    #[test]
631    fn database_url_env_prefers_gcode_specific_var() {
632        let resolved = resolve_database_url_from_env(|name| match name {
633            GCODE_DATABASE_URL_ENV => Some(" postgresql://env/db ".to_string()),
634            GOBBY_POSTGRES_DSN_ENV => Some("postgresql://gobby/db".to_string()),
635            _ => None,
636        });
637
638        assert_eq!(resolved.as_deref(), Some("postgresql://env/db"));
639    }
640
641    #[test]
642    fn database_url_env_falls_back_to_gobby_postgres_dsn() {
643        let resolved = resolve_database_url_from_env(|name| match name {
644            GOBBY_POSTGRES_DSN_ENV => Some(" postgresql://gobby/db ".to_string()),
645            _ => None,
646        });
647
648        assert_eq!(resolved.as_deref(), Some("postgresql://gobby/db"));
649    }
650
651    #[test]
652    fn database_url_env_ignores_empty_values() {
653        let resolved = resolve_database_url_from_env(|name| match name {
654            GCODE_DATABASE_URL_ENV => Some("  ".to_string()),
655            GOBBY_POSTGRES_DSN_ENV => Some("\n\t".to_string()),
656            _ => None,
657        });
658
659        assert_eq!(resolved, None);
660    }
661
662    #[test]
663    fn database_url_sources_prefer_env_over_daemon_broker() {
664        let home = tempfile::tempdir().expect("temp home");
665
666        let resolved = resolve_database_url_from_sources(
667            home.path(),
668            |_| Ok("postgresql://broker/db".to_string()),
669            |name| match name {
670                GCODE_DATABASE_URL_ENV => Some("postgresql://env/db".to_string()),
671                _ => None,
672            },
673            |_| true,
674        )
675        .expect("resolve database url");
676
677        assert_eq!(resolved, "postgresql://env/db");
678    }
679
680    #[test]
681    fn database_url_sources_use_daemon_broker_after_env() {
682        let home = tempfile::tempdir().expect("temp home");
683
684        let resolved = resolve_database_url_from_sources(
685            home.path(),
686            |_| Ok("postgresql://broker/db".to_string()),
687            |_| None,
688            |_| true,
689        )
690        .expect("resolve database url");
691
692        assert_eq!(resolved, "postgresql://broker/db");
693    }
694
695    #[test]
696    fn database_url_sources_fall_back_to_bootstrap_inline_when_daemon_is_unavailable() {
697        let home = tempfile::tempdir().expect("temp home");
698        std::fs::write(
699            home.path().join("bootstrap.yaml"),
700            "hub_backend: postgres\ndatabase_url: postgresql://inline/db\n",
701        )
702        .expect("write bootstrap");
703
704        let resolved = resolve_database_url_from_sources(
705            home.path(),
706            |_| bail!("daemon unavailable"),
707            |_| None,
708            |_| true,
709        )
710        .expect("resolve database url");
711
712        assert_eq!(resolved, "postgresql://inline/db");
713    }
714
715    #[test]
716    fn database_url_sources_fall_back_to_gcore_before_legacy_gcode_config() {
717        let home = tempfile::tempdir().expect("temp home");
718        std::fs::write(
719            home.path().join(GCORE_CONFIG_FILENAME),
720            "databases.postgres.dsn: postgresql://gcore/db\n",
721        )
722        .expect("write gcore config");
723        std::fs::write(
724            home.path().join(GCODE_CONFIG_FILENAME),
725            "database_url: postgresql://legacy/db\n",
726        )
727        .expect("write legacy config");
728
729        let resolved = resolve_database_url_from_sources(
730            home.path(),
731            |_| bail!("daemon unavailable"),
732            |_| None,
733            |_| true,
734        )
735        .expect("resolve database url");
736
737        assert_eq!(resolved, "postgresql://gcore/db");
738    }
739
740    #[test]
741    fn adopted_hub_resolves_without_conflict() {
742        let home = tempfile::tempdir().expect("temp home");
743        std::fs::write(
744            home.path().join(GCORE_CONFIG_FILENAME),
745            "databases.postgres.dsn: postgresql://adopted/gobby\n",
746        )
747        .expect("write gcore config");
748
749        let resolved = resolve_database_url_from_sources_with_identity(
750            home.path(),
751            |_| Ok("postgresql://adopted/gobby".to_string()),
752            |_| None,
753            |_| true,
754            |_| {
755                Ok(gobby_core::provisioning::HubIdentityProbeResult::Known(
756                    gobby_core::provisioning::HubIdentity {
757                        system_identifier: "cluster-a".to_string(),
758                        database_name: "gobby".to_string(),
759                    },
760                ))
761            },
762        )
763        .expect("resolve adopted hub");
764
765        assert_eq!(resolved, "postgresql://adopted/gobby");
766    }
767
768    #[test]
769    fn gcode_config_accepts_database_url() {
770        let home = tempfile::tempdir().expect("temp home");
771        let path = home.path().join(GCODE_CONFIG_FILENAME);
772        std::fs::write(&path, "database_url: postgresql://config/db\n").expect("write config");
773
774        let resolved = resolve_database_url_from_config_file(&path)
775            .expect("config parses")
776            .expect("database_url present");
777
778        assert_eq!(resolved, "postgresql://config/db");
779    }
780
781    #[test]
782    fn gcode_config_missing_file_is_not_an_override() {
783        let home = tempfile::tempdir().expect("temp home");
784        let path = home.path().join(GCODE_CONFIG_FILENAME);
785
786        let resolved = resolve_database_url_from_config_file(&path).expect("missing config ok");
787
788        assert_eq!(resolved, None);
789    }
790
791    #[test]
792    fn gcode_config_empty_file_is_not_an_override() {
793        let home = tempfile::tempdir().expect("temp home");
794        let path = home.path().join(GCODE_CONFIG_FILENAME);
795        std::fs::write(&path, "").expect("write config");
796
797        let resolved = resolve_database_url_from_config_file(&path).expect("empty config ok");
798
799        assert_eq!(resolved, None);
800    }
801
802    #[test]
803    fn postgres_bootstrap_accepts_inline_url() {
804        let resolved = resolve_database_url_from_bootstrap(&bootstrap(
805            "postgres",
806            Some("postgresql://inline/db"),
807        ))
808        .expect("resolve inline url");
809
810        assert_eq!(resolved, "postgresql://inline/db");
811    }
812
813    #[test]
814    fn postgres_bootstrap_rejects_database_url_ref() {
815        let err = parse_bootstrap_database(
816            "hub_backend: postgres\n\
817             database_url_ref: deprecated\n",
818        )
819        .expect_err("database_url_ref must fail");
820
821        let message = err.to_string();
822        assert!(message.contains("database_url_ref is no longer supported"));
823        assert!(message.contains("database_url"));
824    }
825
826    #[test]
827    fn postgres_bootstrap_rejects_database_url_ref_even_with_inline_url() {
828        let err = parse_bootstrap_database(
829            "hub_backend: postgres\n\
830             database_url: postgresql://inline/db\n\
831             database_url_ref: deprecated\n",
832        )
833        .expect_err("database_url_ref must fail");
834
835        assert!(
836            err.to_string()
837                .contains("database_url_ref is no longer supported")
838        );
839    }
840
841    #[test]
842    fn non_postgres_bootstrap_fails_clearly() {
843        let err = resolve_database_url_from_bootstrap(&bootstrap("legacy-local", None))
844            .expect_err("non-postgres backend must fail");
845
846        let message = err.to_string();
847        assert!(message.contains("hub_backend: postgres"));
848        assert!(message.contains("legacy-local"));
849    }
850
851    #[test]
852    fn missing_hub_backend_fails_clearly() {
853        let err = parse_bootstrap_database("database_url: postgresql://inline/db\n")
854            .expect_err("missing hub_backend must fail");
855
856        assert!(err.to_string().contains("hub_backend: postgres"));
857    }
858
859    #[test]
860    fn missing_postgres_dsn_fails_clearly() {
861        let err = resolve_database_url_from_bootstrap(&bootstrap("postgres", None))
862            .expect_err("missing dsn must fail");
863
864        assert!(err.to_string().contains("database_url"));
865    }
866
867    #[test]
868    fn parse_bootstrap_database_reads_postgres_fields() {
869        let parsed = parse_bootstrap_database(
870            "hub_backend: postgres\n\
871             database_url: postgresql://inline/db\n",
872        )
873        .expect("parse bootstrap");
874
875        assert_eq!(parsed.hub_backend, "postgres");
876        assert_eq!(
877            parsed.database_url.as_deref(),
878            Some("postgresql://inline/db")
879        );
880    }
881
882    #[test]
883    fn broker_request_returns_database_url_and_sends_local_token() {
884        let (daemon_url, request) = spawn_http_response(http_response(
885            "200 OK",
886            r#"{"database_url":"postgresql://broker/db"}"#,
887        ));
888
889        let resolved =
890            request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
891        let request = request.join().expect("read request");
892
893        assert_eq!(resolved, "postgresql://broker/db");
894        assert!(request.starts_with("POST /api/local/runtime/database-url HTTP/1.1"));
895        assert!(
896            request
897                .to_ascii_lowercase()
898                .contains("x-gobby-local-token: token-123")
899        );
900    }
901
902    #[test]
903    fn broker_request_allows_cold_daemon_latency() {
904        let (daemon_url, request) = spawn_http_response_after(
905            http_response("200 OK", r#"{"database_url":"postgresql://broker/db"}"#),
906            Duration::from_millis(1100),
907        );
908
909        let resolved =
910            request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
911        let _ = request.join().expect("read request");
912
913        assert_eq!(resolved, "postgresql://broker/db");
914    }
915
916    #[test]
917    fn broker_missing_token_fails() {
918        let home = tempfile::tempdir().expect("temp home");
919        let bootstrap_path = write_bootstrap(home.path(), 60887);
920
921        let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
922            .expect_err("missing token must fail");
923
924        assert!(err.to_string().contains("missing local CLI token"));
925    }
926
927    #[test]
928    fn broker_daemon_down_fails() {
929        let home = tempfile::tempdir().expect("temp home");
930        std::fs::write(home.path().join(LOCAL_CLI_TOKEN_FILENAME), "token\n").expect("write token");
931        let bootstrap_path = write_bootstrap(home.path(), 9);
932
933        let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
934            .expect_err("daemon down must fail");
935
936        assert!(
937            err.to_string()
938                .contains("database_url broker request failed")
939        );
940    }
941
942    #[test]
943    fn broker_auth_failure_fails() {
944        let (daemon_url, request) = spawn_http_response(http_response(
945            "401 Unauthorized",
946            r#"{"error":"bad token"}"#,
947        ));
948
949        let err = request_broker_database_url(&daemon_url, "bad-token")
950            .expect_err("auth failure must fail");
951        let _ = request.join().expect("read request");
952
953        assert!(
954            err.to_string()
955                .contains("database_url broker request failed")
956        );
957    }
958
959    #[test]
960    fn broker_non_success_status_fails() {
961        let (daemon_url, request) = spawn_http_response(http_response(
962            "503 Service Unavailable",
963            r#"{"error":"unavailable"}"#,
964        ));
965
966        let err = request_broker_database_url(&daemon_url, "token")
967            .expect_err("non-success status must fail");
968        let _ = request.join().expect("read request");
969
970        assert!(
971            err.to_string()
972                .contains("database_url broker request failed")
973        );
974    }
975
976    #[test]
977    fn broker_invalid_json_fails() {
978        let (daemon_url, request) = spawn_http_response(http_response("200 OK", "not json"));
979
980        let err =
981            request_broker_database_url(&daemon_url, "token").expect_err("invalid JSON must fail");
982        let _ = request.join().expect("read request");
983
984        assert!(
985            err.to_string()
986                .contains("database_url broker response was not valid JSON")
987        );
988    }
989
990    #[test]
991    fn broker_empty_database_url_fails() {
992        let (daemon_url, request) =
993            spawn_http_response(http_response("200 OK", r#"{"database_url":"  "}"#));
994
995        let err =
996            request_broker_database_url(&daemon_url, "token").expect_err("empty DSN must fail");
997        let _ = request.join().expect("read request");
998
999        assert!(
1000            err.to_string()
1001                .contains("database_url broker response was empty")
1002        );
1003    }
1004
1005    fn write_bootstrap(home: &Path, daemon_port: u16) -> PathBuf {
1006        let path = home.join("bootstrap.yaml");
1007        std::fs::write(
1008            &path,
1009            format!("hub_backend: postgres\ndaemon_port: {daemon_port}\nbind_host: 127.0.0.1\n"),
1010        )
1011        .expect("write bootstrap");
1012        path
1013    }
1014
1015    fn http_response(status: &str, body: &str) -> String {
1016        format!(
1017            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1018            body.len()
1019        )
1020    }
1021
1022    fn spawn_http_response(response: String) -> (String, thread::JoinHandle<String>) {
1023        spawn_http_response_after(response, Duration::ZERO)
1024    }
1025
1026    fn spawn_http_response_after(
1027        response: String,
1028        delay: Duration,
1029    ) -> (String, thread::JoinHandle<String>) {
1030        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
1031        let addr = listener.local_addr().expect("local addr");
1032        let handle = thread::spawn(move || {
1033            let (mut stream, _) = listener.accept().expect("accept request");
1034            let mut request = Vec::new();
1035            let mut buffer = [0_u8; 1024];
1036            loop {
1037                let read = stream.read(&mut buffer).expect("read request");
1038                if read == 0 {
1039                    break;
1040                }
1041                request.extend_from_slice(&buffer[..read]);
1042                if request.windows(4).any(|window| window == b"\r\n\r\n") {
1043                    break;
1044                }
1045            }
1046            thread::sleep(delay);
1047            stream
1048                .write_all(response.as_bytes())
1049                .expect("write response");
1050            String::from_utf8_lossy(&request).into_owned()
1051        });
1052        (format!("http://{addr}"), handle)
1053    }
1054}