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
29pub 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
43pub 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
246pub 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
257pub 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}