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