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 read_graph_file_facts(
294    conn: &mut impl GenericClient,
295    project_id: &str,
296    file_path: &str,
297) -> anyhow::Result<GraphFileFacts> {
298    let imports = read_imports_for_file(conn, project_id, file_path)?;
299    let definitions = read_symbols_for_file(conn, project_id, file_path)?;
300    let calls = read_calls_for_file(conn, project_id, file_path)?;
301
302    Ok(GraphFileFacts {
303        file_path: file_path.to_string(),
304        imports,
305        definitions,
306        calls,
307    })
308}
309
310pub fn indexed_file_exists(
311    conn: &mut impl GenericClient,
312    project_id: &str,
313    file_path: &str,
314) -> anyhow::Result<bool> {
315    Ok(conn
316        .query_opt(
317            "SELECT 1 FROM code_indexed_files
318             WHERE project_id = $1 AND file_path = $2",
319            &[&project_id, &file_path],
320        )?
321        .is_some())
322}
323
324pub fn mark_graph_sync_attempted(
325    conn: &mut impl GenericClient,
326    project_id: &str,
327    file_path: &str,
328) -> anyhow::Result<bool> {
329    let updated = conn.execute(
330        "UPDATE code_indexed_files
331         SET graph_synced = false, graph_sync_attempted_at = NOW()
332         WHERE project_id = $1 AND file_path = $2",
333        &[&project_id, &file_path],
334    )?;
335    Ok(updated > 0)
336}
337
338pub fn mark_graph_synced(
339    conn: &mut impl GenericClient,
340    project_id: &str,
341    file_path: &str,
342) -> anyhow::Result<bool> {
343    let updated = conn.execute(
344        "UPDATE code_indexed_files
345         SET graph_synced = true, graph_sync_attempted_at = NOW()
346         WHERE project_id = $1 AND file_path = $2",
347        &[&project_id, &file_path],
348    )?;
349    Ok(updated > 0)
350}
351
352pub fn reset_graph_sync_for_project(
353    conn: &mut impl GenericClient,
354    project_id: &str,
355) -> anyhow::Result<u64> {
356    Ok(conn.execute(
357        "UPDATE code_indexed_files
358         SET graph_synced = false, graph_sync_attempted_at = NULL
359         WHERE project_id = $1",
360        &[&project_id],
361    )?)
362}
363
364pub fn mark_vectors_synced(
365    conn: &mut impl GenericClient,
366    project_id: &str,
367    file_path: &str,
368) -> anyhow::Result<bool> {
369    let updated = conn.execute(
370        "UPDATE code_indexed_files
371         SET vectors_synced = true
372         WHERE project_id = $1 AND file_path = $2",
373        &[&project_id, &file_path],
374    )?;
375    Ok(updated > 0)
376}
377
378pub fn mark_project_vectors_synced(
379    conn: &mut impl GenericClient,
380    project_id: &str,
381) -> anyhow::Result<u64> {
382    Ok(conn.execute(
383        "UPDATE code_indexed_files
384         SET vectors_synced = true
385         WHERE project_id = $1",
386        &[&project_id],
387    )?)
388}
389
390pub fn reset_vectors_sync_for_project(
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 = false
397         WHERE project_id = $1",
398        &[&project_id],
399    )?)
400}
401
402fn read_imports_for_file(
403    conn: &mut impl GenericClient,
404    project_id: &str,
405    file_path: &str,
406) -> anyhow::Result<Vec<ImportRelation>> {
407    let rows = conn.query(
408        "SELECT source_file, target_module
409         FROM code_imports
410         WHERE project_id = $1 AND source_file = $2
411         ORDER BY target_module",
412        &[&project_id, &file_path],
413    )?;
414    rows.into_iter()
415        .map(|row| {
416            Ok(ImportRelation {
417                file_path: row.try_get("source_file")?,
418                module_name: row.try_get("target_module")?,
419            })
420        })
421        .collect()
422}
423
424fn read_symbols_for_file(
425    conn: &mut impl GenericClient,
426    project_id: &str,
427    file_path: &str,
428) -> anyhow::Result<Vec<Symbol>> {
429    let query = format!(
430        "SELECT {} FROM code_symbols s
431         WHERE s.project_id = $1 AND s.file_path = $2
432         ORDER BY s.line_start, s.byte_start",
433        symbol_select_columns("s")
434    );
435    let rows = conn.query(&query, &[&project_id, &file_path])?;
436    rows.iter().map(Symbol::from_row).collect()
437}
438
439fn read_calls_for_file(
440    conn: &mut impl GenericClient,
441    project_id: &str,
442    file_path: &str,
443) -> anyhow::Result<Vec<CallRelation>> {
444    let rows = conn.query(
445        "SELECT caller_symbol_id, callee_symbol_id, callee_name,
446                callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
447         FROM code_calls
448         WHERE project_id = $1 AND file_path = $2
449         ORDER BY line, caller_symbol_id, callee_name",
450        &[&project_id, &file_path],
451    )?;
452    rows.into_iter()
453        .map(|row| {
454            let target_kind: String = row.try_get("callee_target_kind")?;
455            let callee_symbol_id: String = row.try_get("callee_symbol_id")?;
456            let callee_external_module: String = row.try_get("callee_external_module")?;
457            Ok(CallRelation {
458                caller_symbol_id: row.try_get("caller_symbol_id")?,
459                callee_symbol_id: non_empty(callee_symbol_id),
460                callee_name: row.try_get("callee_name")?,
461                callee_target_kind: call_target_kind_from_str(&target_kind)?,
462                callee_external_module: non_empty(callee_external_module),
463                file_path: row.try_get("file_path")?,
464                line: i64_to_usize(row.try_get("line")?, "line")?,
465            })
466        })
467        .collect()
468}
469
470fn non_empty(value: String) -> Option<String> {
471    if value.is_empty() { None } else { Some(value) }
472}
473
474fn call_target_kind_from_str(value: &str) -> anyhow::Result<CallTargetKind> {
475    match value {
476        "symbol" => Ok(CallTargetKind::Symbol),
477        "unresolved" => Ok(CallTargetKind::Unresolved),
478        "external" => Ok(CallTargetKind::External),
479        other => bail!("unknown code_calls.callee_target_kind `{other}`"),
480    }
481}
482
483fn i64_to_usize(value: i64, column: &str) -> anyhow::Result<usize> {
484    value
485        .try_into()
486        .with_context(|| format!("column `{column}` contains negative or too-large value {value}"))
487}
488
489pub fn symbol_select_columns(alias: &str) -> String {
490    let prefix = if alias.is_empty() {
491        String::new()
492    } else {
493        format!("{alias}.")
494    };
495    format!(
496        "{p}id, {p}project_id, {p}file_path, {p}name, {p}qualified_name, \
497         {p}kind, {p}language, {p}byte_start::BIGINT AS byte_start, \
498         {p}byte_end::BIGINT AS byte_end, {p}line_start::BIGINT AS line_start, \
499         {p}line_end::BIGINT AS line_end, {p}signature, {p}docstring, \
500         {p}parent_symbol_id, {p}content_hash, {p}summary, \
501         {p}created_at::TEXT AS created_at, {p}updated_at::TEXT AS updated_at",
502        p = prefix
503    )
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use std::io::{Read as _, Write as _};
510    use std::net::TcpListener;
511    use std::thread;
512
513    fn bootstrap(hub_backend: &str, database_url: Option<&str>) -> BootstrapDatabase {
514        BootstrapDatabase {
515            hub_backend: hub_backend.to_string(),
516            database_url: database_url.map(str::to_string),
517        }
518    }
519
520    #[test]
521    fn database_url_env_prefers_gcode_specific_var() {
522        let resolved = resolve_database_url_from_env(|name| match name {
523            GCODE_DATABASE_URL_ENV => Some(" postgresql://env/db ".to_string()),
524            GOBBY_POSTGRES_DSN_ENV => Some("postgresql://gobby/db".to_string()),
525            _ => None,
526        });
527
528        assert_eq!(resolved.as_deref(), Some("postgresql://env/db"));
529    }
530
531    #[test]
532    fn database_url_env_falls_back_to_gobby_postgres_dsn() {
533        let resolved = resolve_database_url_from_env(|name| match name {
534            GOBBY_POSTGRES_DSN_ENV => Some(" postgresql://gobby/db ".to_string()),
535            _ => None,
536        });
537
538        assert_eq!(resolved.as_deref(), Some("postgresql://gobby/db"));
539    }
540
541    #[test]
542    fn database_url_env_ignores_empty_values() {
543        let resolved = resolve_database_url_from_env(|name| match name {
544            GCODE_DATABASE_URL_ENV => Some("  ".to_string()),
545            GOBBY_POSTGRES_DSN_ENV => Some("\n\t".to_string()),
546            _ => None,
547        });
548
549        assert_eq!(resolved, None);
550    }
551
552    #[test]
553    fn database_url_sources_prefer_env_over_daemon_broker() {
554        let home = tempfile::tempdir().expect("temp home");
555
556        let resolved = resolve_database_url_from_sources(
557            home.path(),
558            |_| Ok("postgresql://broker/db".to_string()),
559            |name| match name {
560                GCODE_DATABASE_URL_ENV => Some("postgresql://env/db".to_string()),
561                _ => None,
562            },
563        )
564        .expect("resolve database url");
565
566        assert_eq!(resolved, "postgresql://env/db");
567    }
568
569    #[test]
570    fn database_url_sources_use_daemon_broker_after_env() {
571        let home = tempfile::tempdir().expect("temp home");
572
573        let resolved = resolve_database_url_from_sources(
574            home.path(),
575            |_| Ok("postgresql://broker/db".to_string()),
576            |_| None,
577        )
578        .expect("resolve database url");
579
580        assert_eq!(resolved, "postgresql://broker/db");
581    }
582
583    #[test]
584    fn database_url_sources_fall_back_to_bootstrap_inline_when_daemon_is_unavailable() {
585        let home = tempfile::tempdir().expect("temp home");
586        std::fs::write(
587            home.path().join("bootstrap.yaml"),
588            "hub_backend: postgres\ndatabase_url: postgresql://inline/db\n",
589        )
590        .expect("write bootstrap");
591
592        let resolved = resolve_database_url_from_sources(
593            home.path(),
594            |_| bail!("daemon unavailable"),
595            |_| None,
596        )
597        .expect("resolve database url");
598
599        assert_eq!(resolved, "postgresql://inline/db");
600    }
601
602    #[test]
603    fn database_url_sources_fall_back_to_gcore_before_legacy_gcode_config() {
604        let home = tempfile::tempdir().expect("temp home");
605        std::fs::write(
606            home.path().join(GCORE_CONFIG_FILENAME),
607            "databases.postgres.dsn: postgresql://gcore/db\n",
608        )
609        .expect("write gcore config");
610        std::fs::write(
611            home.path().join(GCODE_CONFIG_FILENAME),
612            "database_url: postgresql://legacy/db\n",
613        )
614        .expect("write legacy config");
615
616        let resolved = resolve_database_url_from_sources(
617            home.path(),
618            |_| bail!("daemon unavailable"),
619            |_| None,
620        )
621        .expect("resolve database url");
622
623        assert_eq!(resolved, "postgresql://gcore/db");
624    }
625
626    #[test]
627    fn gcode_config_accepts_database_url() {
628        let home = tempfile::tempdir().expect("temp home");
629        let path = home.path().join(GCODE_CONFIG_FILENAME);
630        std::fs::write(&path, "database_url: postgresql://config/db\n").expect("write config");
631
632        let resolved = resolve_database_url_from_config_file(&path)
633            .expect("config parses")
634            .expect("database_url present");
635
636        assert_eq!(resolved, "postgresql://config/db");
637    }
638
639    #[test]
640    fn gcode_config_missing_file_is_not_an_override() {
641        let home = tempfile::tempdir().expect("temp home");
642        let path = home.path().join(GCODE_CONFIG_FILENAME);
643
644        let resolved = resolve_database_url_from_config_file(&path).expect("missing config ok");
645
646        assert_eq!(resolved, None);
647    }
648
649    #[test]
650    fn gcode_config_empty_file_is_not_an_override() {
651        let home = tempfile::tempdir().expect("temp home");
652        let path = home.path().join(GCODE_CONFIG_FILENAME);
653        std::fs::write(&path, "").expect("write config");
654
655        let resolved = resolve_database_url_from_config_file(&path).expect("empty config ok");
656
657        assert_eq!(resolved, None);
658    }
659
660    #[test]
661    fn postgres_bootstrap_accepts_inline_url() {
662        let resolved = resolve_database_url_from_bootstrap(&bootstrap(
663            "postgres",
664            Some("postgresql://inline/db"),
665        ))
666        .expect("resolve inline url");
667
668        assert_eq!(resolved, "postgresql://inline/db");
669    }
670
671    #[test]
672    fn postgres_bootstrap_rejects_database_url_ref() {
673        let err = parse_bootstrap_database(
674            "hub_backend: postgres\n\
675             database_url_ref: deprecated\n",
676        )
677        .expect_err("database_url_ref must fail");
678
679        let message = err.to_string();
680        assert!(message.contains("database_url_ref is no longer supported"));
681        assert!(message.contains("database_url"));
682    }
683
684    #[test]
685    fn postgres_bootstrap_rejects_database_url_ref_even_with_inline_url() {
686        let err = parse_bootstrap_database(
687            "hub_backend: postgres\n\
688             database_url: postgresql://inline/db\n\
689             database_url_ref: deprecated\n",
690        )
691        .expect_err("database_url_ref must fail");
692
693        assert!(
694            err.to_string()
695                .contains("database_url_ref is no longer supported")
696        );
697    }
698
699    #[test]
700    fn non_postgres_bootstrap_fails_clearly() {
701        let err = resolve_database_url_from_bootstrap(&bootstrap("legacy-local", None))
702            .expect_err("non-postgres backend must fail");
703
704        let message = err.to_string();
705        assert!(message.contains("hub_backend: postgres"));
706        assert!(message.contains("legacy-local"));
707    }
708
709    #[test]
710    fn missing_hub_backend_fails_clearly() {
711        let err = parse_bootstrap_database("database_url: postgresql://inline/db\n")
712            .expect_err("missing hub_backend must fail");
713
714        assert!(err.to_string().contains("hub_backend: postgres"));
715    }
716
717    #[test]
718    fn missing_postgres_dsn_fails_clearly() {
719        let err = resolve_database_url_from_bootstrap(&bootstrap("postgres", None))
720            .expect_err("missing dsn must fail");
721
722        assert!(err.to_string().contains("database_url"));
723    }
724
725    #[test]
726    fn parse_bootstrap_database_reads_postgres_fields() {
727        let parsed = parse_bootstrap_database(
728            "hub_backend: postgres\n\
729             database_url: postgresql://inline/db\n",
730        )
731        .expect("parse bootstrap");
732
733        assert_eq!(parsed.hub_backend, "postgres");
734        assert_eq!(
735            parsed.database_url.as_deref(),
736            Some("postgresql://inline/db")
737        );
738    }
739
740    #[test]
741    fn broker_request_returns_database_url_and_sends_local_token() {
742        let (daemon_url, request) = spawn_http_response(http_response(
743            "200 OK",
744            r#"{"database_url":"postgresql://broker/db"}"#,
745        ));
746
747        let resolved =
748            request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
749        let request = request.join().expect("read request");
750
751        assert_eq!(resolved, "postgresql://broker/db");
752        assert!(request.starts_with("POST /api/local/runtime/database-url HTTP/1.1"));
753        assert!(
754            request
755                .to_ascii_lowercase()
756                .contains("x-gobby-local-token: token-123")
757        );
758    }
759
760    #[test]
761    fn broker_request_allows_cold_daemon_latency() {
762        let (daemon_url, request) = spawn_http_response_after(
763            http_response("200 OK", r#"{"database_url":"postgresql://broker/db"}"#),
764            Duration::from_millis(1100),
765        );
766
767        let resolved =
768            request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
769        let _ = request.join().expect("read request");
770
771        assert_eq!(resolved, "postgresql://broker/db");
772    }
773
774    #[test]
775    fn broker_missing_token_fails() {
776        let home = tempfile::tempdir().expect("temp home");
777        let bootstrap_path = write_bootstrap(home.path(), 60887);
778
779        let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
780            .expect_err("missing token must fail");
781
782        assert!(err.to_string().contains("missing local CLI token"));
783    }
784
785    #[test]
786    fn broker_daemon_down_fails() {
787        let home = tempfile::tempdir().expect("temp home");
788        std::fs::write(home.path().join(LOCAL_CLI_TOKEN_FILENAME), "token\n").expect("write token");
789        let bootstrap_path = write_bootstrap(home.path(), 9);
790
791        let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
792            .expect_err("daemon down must fail");
793
794        assert!(
795            err.to_string()
796                .contains("database_url broker request failed")
797        );
798    }
799
800    #[test]
801    fn broker_auth_failure_fails() {
802        let (daemon_url, request) = spawn_http_response(http_response(
803            "401 Unauthorized",
804            r#"{"error":"bad token"}"#,
805        ));
806
807        let err = request_broker_database_url(&daemon_url, "bad-token")
808            .expect_err("auth failure must fail");
809        let _ = request.join().expect("read request");
810
811        assert!(
812            err.to_string()
813                .contains("database_url broker request failed")
814        );
815    }
816
817    #[test]
818    fn broker_non_success_status_fails() {
819        let (daemon_url, request) = spawn_http_response(http_response(
820            "503 Service Unavailable",
821            r#"{"error":"unavailable"}"#,
822        ));
823
824        let err = request_broker_database_url(&daemon_url, "token")
825            .expect_err("non-success status must fail");
826        let _ = request.join().expect("read request");
827
828        assert!(
829            err.to_string()
830                .contains("database_url broker request failed")
831        );
832    }
833
834    #[test]
835    fn broker_invalid_json_fails() {
836        let (daemon_url, request) = spawn_http_response(http_response("200 OK", "not json"));
837
838        let err =
839            request_broker_database_url(&daemon_url, "token").expect_err("invalid JSON must fail");
840        let _ = request.join().expect("read request");
841
842        assert!(
843            err.to_string()
844                .contains("database_url broker response was not valid JSON")
845        );
846    }
847
848    #[test]
849    fn broker_empty_database_url_fails() {
850        let (daemon_url, request) =
851            spawn_http_response(http_response("200 OK", r#"{"database_url":"  "}"#));
852
853        let err =
854            request_broker_database_url(&daemon_url, "token").expect_err("empty DSN must fail");
855        let _ = request.join().expect("read request");
856
857        assert!(
858            err.to_string()
859                .contains("database_url broker response was empty")
860        );
861    }
862
863    fn write_bootstrap(home: &Path, daemon_port: u16) -> PathBuf {
864        let path = home.join("bootstrap.yaml");
865        std::fs::write(
866            &path,
867            format!("hub_backend: postgres\ndaemon_port: {daemon_port}\nbind_host: 127.0.0.1\n"),
868        )
869        .expect("write bootstrap");
870        path
871    }
872
873    fn http_response(status: &str, body: &str) -> String {
874        format!(
875            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
876            body.len()
877        )
878    }
879
880    fn spawn_http_response(response: String) -> (String, thread::JoinHandle<String>) {
881        spawn_http_response_after(response, Duration::ZERO)
882    }
883
884    fn spawn_http_response_after(
885        response: String,
886        delay: Duration,
887    ) -> (String, thread::JoinHandle<String>) {
888        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
889        let addr = listener.local_addr().expect("local addr");
890        let handle = thread::spawn(move || {
891            let (mut stream, _) = listener.accept().expect("accept request");
892            let mut request = Vec::new();
893            let mut buffer = [0_u8; 1024];
894            loop {
895                let read = stream.read(&mut buffer).expect("read request");
896                if read == 0 {
897                    break;
898                }
899                request.extend_from_slice(&buffer[..read]);
900                if request.windows(4).any(|window| window == b"\r\n\r\n") {
901                    break;
902                }
903            }
904            thread::sleep(delay);
905            stream
906                .write_all(response.as_bytes())
907                .expect("write response");
908            String::from_utf8_lossy(&request).into_owned()
909        });
910        (format!("http://{addr}"), handle)
911    }
912}