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 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}