use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context as _, anyhow, bail};
use gobby_core::provisioning::{GCORE_CONFIG_FILENAME, StandaloneConfig};
use postgres::{Client, GenericClient};
use serde::Deserialize;
use crate::models::{CallRelation, CallTargetKind, ImportRelation, Symbol};
use crate::schema;
const GCODE_DATABASE_URL_ENV: &str = "GCODE_DATABASE_URL";
const GOBBY_POSTGRES_DSN_ENV: &str = "GOBBY_POSTGRES_DSN";
const GCODE_CONFIG_FILENAME: &str = "gcode.yaml";
const LOCAL_CLI_TOKEN_FILENAME: &str = "local_cli_token";
const BROKER_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Deserialize)]
struct BrokerDatabaseUrlResponse {
database_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BootstrapDatabase {
hub_backend: String,
database_url: Option<String>,
}
pub fn gobby_home() -> anyhow::Result<PathBuf> {
if let Some(home) = std::env::var_os("GOBBY_HOME") {
return Ok(PathBuf::from(home));
}
Ok(dirs::home_dir()
.context("cannot determine home directory")?
.join(".gobby"))
}
pub fn bootstrap_path() -> anyhow::Result<PathBuf> {
Ok(gobby_home()?.join("bootstrap.yaml"))
}
pub fn resolve_database_url() -> anyhow::Result<String> {
let home = gobby_home()?;
resolve_database_url_from_sources(
&home,
|bootstrap_path| resolve_brokered_database_url_at(&home, bootstrap_path),
|name| std::env::var(name).ok(),
)
}
fn resolve_database_url_from_sources(
home: &Path,
broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
get_var: impl FnMut(&str) -> Option<String>,
) -> anyhow::Result<String> {
let path = home.join("bootstrap.yaml");
if let Some(database_url) = resolve_database_url_from_env(get_var) {
return Ok(database_url);
}
if let Ok(database_url) = broker_resolver(&path) {
return Ok(database_url);
}
if let Some(database_url) = resolve_database_url_from_bootstrap_file(&path)? {
return Ok(database_url);
}
if let Some(database_url) = resolve_database_url_from_gcore_config(home)? {
return Ok(database_url);
}
if let Some(database_url) =
resolve_database_url_from_config_file(&home.join(GCODE_CONFIG_FILENAME))?
{
return Ok(database_url);
}
bail!(
"missing Gobby PostgreSQL configuration. Run `gcode setup --standalone`, set {GCODE_DATABASE_URL_ENV}, or configure the Gobby daemon bootstrap."
)
}
fn resolve_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read Gobby bootstrap at {}", path.display()))?;
let bootstrap = parse_bootstrap_database(&contents)?;
resolve_database_url_from_bootstrap(&bootstrap).map(Some)
}
fn resolve_database_url_from_gcore_config(home: &Path) -> anyhow::Result<Option<String>> {
let Some(config) = StandaloneConfig::read_at(&home.join(GCORE_CONFIG_FILENAME))? else {
return Ok(None);
};
Ok(config
.get("databases.postgres.dsn")
.and_then(|value| non_empty_trimmed(Some(value.to_string()))))
}
fn resolve_database_url_from_env(
mut get_var: impl FnMut(&str) -> Option<String>,
) -> Option<String> {
for name in [GCODE_DATABASE_URL_ENV, GOBBY_POSTGRES_DSN_ENV] {
if let Some(value) = non_empty_trimmed(get_var(name)) {
return Some(value);
}
}
None
}
fn resolve_database_url_from_config_file(path: &Path) -> anyhow::Result<Option<String>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
parse_gcode_config_database_url(&contents)
.with_context(|| format!("failed to parse {}", path.display()))
}
fn parse_gcode_config_database_url(contents: &str) -> anyhow::Result<Option<String>> {
let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
let Some(map) = yaml.as_mapping() else {
if yaml.is_null() {
return Ok(None);
}
bail!("gcode.yaml must be a mapping");
};
let key = serde_yaml::Value::String("database_url".to_string());
match map.get(&key) {
Some(value) => match value.as_str() {
Some(text) => Ok(non_empty_trimmed(Some(text.to_string()))),
None => bail!("gcode.yaml field `database_url` must be a string"),
},
None => Ok(None),
}
}
fn parse_bootstrap_database(contents: &str) -> anyhow::Result<BootstrapDatabase> {
let yaml: serde_yaml::Value =
serde_yaml::from_str(contents).context("failed to parse bootstrap.yaml")?;
let Some(map) = yaml.as_mapping() else {
bail!("bootstrap.yaml must be a mapping");
};
let get_string = |name: &str| -> anyhow::Result<Option<String>> {
let key = serde_yaml::Value::String(name.to_string());
match map.get(&key) {
Some(value) => match value.as_str() {
Some(text) if !text.trim().is_empty() => Ok(Some(text.to_string())),
Some(_) | None => bail!("bootstrap.yaml field `{name}` must be a string"),
},
None => Ok(None),
}
};
let database_url_ref = get_string("database_url_ref")?;
if database_url_ref.is_some() {
bail!(
"database_url_ref is no longer supported in bootstrap.yaml; store the local PostgreSQL DSN in database_url"
);
}
Ok(BootstrapDatabase {
hub_backend: get_string("hub_backend")?
.context("bootstrap.yaml must include `hub_backend: postgres`")?,
database_url: get_string("database_url")?,
})
}
fn resolve_database_url_from_bootstrap(bootstrap: &BootstrapDatabase) -> anyhow::Result<String> {
if bootstrap.hub_backend != "postgres" {
bail!(
"gcode requires `hub_backend: postgres` in bootstrap.yaml. Current hub_backend is `{}`. Configure the Gobby PostgreSQL hub before running gcode.",
bootstrap.hub_backend
);
}
if let Some(database_url) = bootstrap.database_url.as_deref() {
return Ok(database_url.to_string());
}
bail!("hub_backend=postgres requires `database_url` in bootstrap.yaml")
}
fn non_empty_trimmed(value: Option<String>) -> Option<String> {
let trimmed = value.as_ref()?.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn resolve_brokered_database_url_at(
gobby_home: &Path,
bootstrap_path: &Path,
) -> anyhow::Result<String> {
let token = read_local_cli_token_at(gobby_home)?;
let daemon_url = gobby_core::daemon_url::daemon_url_at(bootstrap_path);
request_broker_database_url(&daemon_url, &token)
}
fn read_local_cli_token_at(gobby_home: &Path) -> anyhow::Result<String> {
let path = gobby_home.join(LOCAL_CLI_TOKEN_FILENAME);
let token = std::fs::read_to_string(&path)
.with_context(|| format!("missing local CLI token at {}", path.display()))?;
let token = token.trim().to_string();
if token.is_empty() {
bail!("local CLI token at {} is empty", path.display());
}
Ok(token)
}
fn request_broker_database_url(daemon_url: &str, token: &str) -> anyhow::Result<String> {
let url = format!(
"{}/api/local/runtime/database-url",
daemon_url.trim_end_matches('/')
);
let agent = ureq::AgentBuilder::new().timeout(BROKER_TIMEOUT).build();
let response = agent
.post(&url)
.set("X-Gobby-Local-Token", token)
.call()
.map_err(|err| anyhow!("database_url broker request failed: {err}"))?;
let body: BrokerDatabaseUrlResponse = response
.into_json()
.context("database_url broker response was not valid JSON")?;
let database_url = body.database_url.trim().to_string();
if database_url.is_empty() {
bail!("database_url broker response was empty");
}
Ok(database_url)
}
pub fn connect_readwrite(database_url: &str) -> anyhow::Result<Client> {
let mut client = gobby_core::postgres::connect_readwrite(database_url)?;
schema::validate_runtime_schema(&mut client)?;
Ok(client)
}
pub fn connect_readonly(database_url: &str) -> anyhow::Result<Client> {
let mut client = gobby_core::postgres::connect_readonly(database_url)?;
schema::validate_runtime_schema(&mut client)?;
Ok(client)
}
pub fn read_config_value(conn: &mut Client, key: &str) -> anyhow::Result<Option<String>> {
gobby_core::postgres::read_config_value(conn, key)
}
#[derive(Debug, Clone)]
pub struct GraphFileFacts {
pub file_path: String,
pub imports: Vec<ImportRelation>,
pub definitions: Vec<Symbol>,
pub calls: Vec<CallRelation>,
}
pub fn list_indexed_file_paths(
conn: &mut impl GenericClient,
project_id: &str,
) -> anyhow::Result<Vec<String>> {
let rows = conn.query(
"SELECT file_path FROM code_indexed_files WHERE project_id = $1 ORDER BY file_path",
&[&project_id],
)?;
rows.into_iter()
.map(|row| row.try_get("file_path").map_err(Into::into))
.collect()
}
pub fn read_graph_file_facts(
conn: &mut impl GenericClient,
project_id: &str,
file_path: &str,
) -> anyhow::Result<GraphFileFacts> {
let imports = read_imports_for_file(conn, project_id, file_path)?;
let definitions = read_symbols_for_file(conn, project_id, file_path)?;
let calls = read_calls_for_file(conn, project_id, file_path)?;
Ok(GraphFileFacts {
file_path: file_path.to_string(),
imports,
definitions,
calls,
})
}
pub fn indexed_file_exists(
conn: &mut impl GenericClient,
project_id: &str,
file_path: &str,
) -> anyhow::Result<bool> {
Ok(conn
.query_opt(
"SELECT 1 FROM code_indexed_files
WHERE project_id = $1 AND file_path = $2",
&[&project_id, &file_path],
)?
.is_some())
}
pub fn mark_graph_sync_attempted(
conn: &mut impl GenericClient,
project_id: &str,
file_path: &str,
) -> anyhow::Result<bool> {
let updated = conn.execute(
"UPDATE code_indexed_files
SET graph_synced = false, graph_sync_attempted_at = NOW()
WHERE project_id = $1 AND file_path = $2",
&[&project_id, &file_path],
)?;
Ok(updated > 0)
}
pub fn mark_graph_synced(
conn: &mut impl GenericClient,
project_id: &str,
file_path: &str,
) -> anyhow::Result<bool> {
let updated = conn.execute(
"UPDATE code_indexed_files
SET graph_synced = true, graph_sync_attempted_at = NOW()
WHERE project_id = $1 AND file_path = $2",
&[&project_id, &file_path],
)?;
Ok(updated > 0)
}
pub fn reset_graph_sync_for_project(
conn: &mut impl GenericClient,
project_id: &str,
) -> anyhow::Result<u64> {
Ok(conn.execute(
"UPDATE code_indexed_files
SET graph_synced = false, graph_sync_attempted_at = NULL
WHERE project_id = $1",
&[&project_id],
)?)
}
pub fn mark_vectors_synced(
conn: &mut impl GenericClient,
project_id: &str,
file_path: &str,
) -> anyhow::Result<bool> {
let updated = conn.execute(
"UPDATE code_indexed_files
SET vectors_synced = true
WHERE project_id = $1 AND file_path = $2",
&[&project_id, &file_path],
)?;
Ok(updated > 0)
}
pub fn mark_project_vectors_synced(
conn: &mut impl GenericClient,
project_id: &str,
) -> anyhow::Result<u64> {
Ok(conn.execute(
"UPDATE code_indexed_files
SET vectors_synced = true
WHERE project_id = $1",
&[&project_id],
)?)
}
pub fn reset_vectors_sync_for_project(
conn: &mut impl GenericClient,
project_id: &str,
) -> anyhow::Result<u64> {
Ok(conn.execute(
"UPDATE code_indexed_files
SET vectors_synced = false
WHERE project_id = $1",
&[&project_id],
)?)
}
fn read_imports_for_file(
conn: &mut impl GenericClient,
project_id: &str,
file_path: &str,
) -> anyhow::Result<Vec<ImportRelation>> {
let rows = conn.query(
"SELECT source_file, target_module
FROM code_imports
WHERE project_id = $1 AND source_file = $2
ORDER BY target_module",
&[&project_id, &file_path],
)?;
rows.into_iter()
.map(|row| {
Ok(ImportRelation {
file_path: row.try_get("source_file")?,
module_name: row.try_get("target_module")?,
})
})
.collect()
}
fn read_symbols_for_file(
conn: &mut impl GenericClient,
project_id: &str,
file_path: &str,
) -> anyhow::Result<Vec<Symbol>> {
let query = format!(
"SELECT {} FROM code_symbols s
WHERE s.project_id = $1 AND s.file_path = $2
ORDER BY s.line_start, s.byte_start",
symbol_select_columns("s")
);
let rows = conn.query(&query, &[&project_id, &file_path])?;
rows.iter().map(Symbol::from_row).collect()
}
fn read_calls_for_file(
conn: &mut impl GenericClient,
project_id: &str,
file_path: &str,
) -> anyhow::Result<Vec<CallRelation>> {
let rows = conn.query(
"SELECT caller_symbol_id, callee_symbol_id, callee_name,
callee_target_kind, callee_external_module, file_path, line::BIGINT AS line
FROM code_calls
WHERE project_id = $1 AND file_path = $2
ORDER BY line, caller_symbol_id, callee_name",
&[&project_id, &file_path],
)?;
rows.into_iter()
.map(|row| {
let target_kind: String = row.try_get("callee_target_kind")?;
let callee_symbol_id: String = row.try_get("callee_symbol_id")?;
let callee_external_module: String = row.try_get("callee_external_module")?;
Ok(CallRelation {
caller_symbol_id: row.try_get("caller_symbol_id")?,
callee_symbol_id: non_empty(callee_symbol_id),
callee_name: row.try_get("callee_name")?,
callee_target_kind: call_target_kind_from_str(&target_kind)?,
callee_external_module: non_empty(callee_external_module),
file_path: row.try_get("file_path")?,
line: i64_to_usize(row.try_get("line")?, "line")?,
})
})
.collect()
}
fn non_empty(value: String) -> Option<String> {
if value.is_empty() { None } else { Some(value) }
}
fn call_target_kind_from_str(value: &str) -> anyhow::Result<CallTargetKind> {
match value {
"symbol" => Ok(CallTargetKind::Symbol),
"unresolved" => Ok(CallTargetKind::Unresolved),
"external" => Ok(CallTargetKind::External),
other => bail!("unknown code_calls.callee_target_kind `{other}`"),
}
}
fn i64_to_usize(value: i64, column: &str) -> anyhow::Result<usize> {
value
.try_into()
.with_context(|| format!("column `{column}` contains negative or too-large value {value}"))
}
pub fn symbol_select_columns(alias: &str) -> String {
let prefix = if alias.is_empty() {
String::new()
} else {
format!("{alias}.")
};
format!(
"{p}id, {p}project_id, {p}file_path, {p}name, {p}qualified_name, \
{p}kind, {p}language, {p}byte_start::BIGINT AS byte_start, \
{p}byte_end::BIGINT AS byte_end, {p}line_start::BIGINT AS line_start, \
{p}line_end::BIGINT AS line_end, {p}signature, {p}docstring, \
{p}parent_symbol_id, {p}content_hash, {p}summary, \
{p}created_at::TEXT AS created_at, {p}updated_at::TEXT AS updated_at",
p = prefix
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
use std::thread;
fn bootstrap(hub_backend: &str, database_url: Option<&str>) -> BootstrapDatabase {
BootstrapDatabase {
hub_backend: hub_backend.to_string(),
database_url: database_url.map(str::to_string),
}
}
#[test]
fn database_url_env_prefers_gcode_specific_var() {
let resolved = resolve_database_url_from_env(|name| match name {
GCODE_DATABASE_URL_ENV => Some(" postgresql://env/db ".to_string()),
GOBBY_POSTGRES_DSN_ENV => Some("postgresql://gobby/db".to_string()),
_ => None,
});
assert_eq!(resolved.as_deref(), Some("postgresql://env/db"));
}
#[test]
fn database_url_env_falls_back_to_gobby_postgres_dsn() {
let resolved = resolve_database_url_from_env(|name| match name {
GOBBY_POSTGRES_DSN_ENV => Some(" postgresql://gobby/db ".to_string()),
_ => None,
});
assert_eq!(resolved.as_deref(), Some("postgresql://gobby/db"));
}
#[test]
fn database_url_env_ignores_empty_values() {
let resolved = resolve_database_url_from_env(|name| match name {
GCODE_DATABASE_URL_ENV => Some(" ".to_string()),
GOBBY_POSTGRES_DSN_ENV => Some("\n\t".to_string()),
_ => None,
});
assert_eq!(resolved, None);
}
#[test]
fn database_url_sources_prefer_env_over_daemon_broker() {
let home = tempfile::tempdir().expect("temp home");
let resolved = resolve_database_url_from_sources(
home.path(),
|_| Ok("postgresql://broker/db".to_string()),
|name| match name {
GCODE_DATABASE_URL_ENV => Some("postgresql://env/db".to_string()),
_ => None,
},
)
.expect("resolve database url");
assert_eq!(resolved, "postgresql://env/db");
}
#[test]
fn database_url_sources_use_daemon_broker_after_env() {
let home = tempfile::tempdir().expect("temp home");
let resolved = resolve_database_url_from_sources(
home.path(),
|_| Ok("postgresql://broker/db".to_string()),
|_| None,
)
.expect("resolve database url");
assert_eq!(resolved, "postgresql://broker/db");
}
#[test]
fn database_url_sources_fall_back_to_bootstrap_inline_when_daemon_is_unavailable() {
let home = tempfile::tempdir().expect("temp home");
std::fs::write(
home.path().join("bootstrap.yaml"),
"hub_backend: postgres\ndatabase_url: postgresql://inline/db\n",
)
.expect("write bootstrap");
let resolved = resolve_database_url_from_sources(
home.path(),
|_| bail!("daemon unavailable"),
|_| None,
)
.expect("resolve database url");
assert_eq!(resolved, "postgresql://inline/db");
}
#[test]
fn database_url_sources_fall_back_to_gcore_before_legacy_gcode_config() {
let home = tempfile::tempdir().expect("temp home");
std::fs::write(
home.path().join(GCORE_CONFIG_FILENAME),
"databases.postgres.dsn: postgresql://gcore/db\n",
)
.expect("write gcore config");
std::fs::write(
home.path().join(GCODE_CONFIG_FILENAME),
"database_url: postgresql://legacy/db\n",
)
.expect("write legacy config");
let resolved = resolve_database_url_from_sources(
home.path(),
|_| bail!("daemon unavailable"),
|_| None,
)
.expect("resolve database url");
assert_eq!(resolved, "postgresql://gcore/db");
}
#[test]
fn gcode_config_accepts_database_url() {
let home = tempfile::tempdir().expect("temp home");
let path = home.path().join(GCODE_CONFIG_FILENAME);
std::fs::write(&path, "database_url: postgresql://config/db\n").expect("write config");
let resolved = resolve_database_url_from_config_file(&path)
.expect("config parses")
.expect("database_url present");
assert_eq!(resolved, "postgresql://config/db");
}
#[test]
fn gcode_config_missing_file_is_not_an_override() {
let home = tempfile::tempdir().expect("temp home");
let path = home.path().join(GCODE_CONFIG_FILENAME);
let resolved = resolve_database_url_from_config_file(&path).expect("missing config ok");
assert_eq!(resolved, None);
}
#[test]
fn gcode_config_empty_file_is_not_an_override() {
let home = tempfile::tempdir().expect("temp home");
let path = home.path().join(GCODE_CONFIG_FILENAME);
std::fs::write(&path, "").expect("write config");
let resolved = resolve_database_url_from_config_file(&path).expect("empty config ok");
assert_eq!(resolved, None);
}
#[test]
fn postgres_bootstrap_accepts_inline_url() {
let resolved = resolve_database_url_from_bootstrap(&bootstrap(
"postgres",
Some("postgresql://inline/db"),
))
.expect("resolve inline url");
assert_eq!(resolved, "postgresql://inline/db");
}
#[test]
fn postgres_bootstrap_rejects_database_url_ref() {
let err = parse_bootstrap_database(
"hub_backend: postgres\n\
database_url_ref: deprecated\n",
)
.expect_err("database_url_ref must fail");
let message = err.to_string();
assert!(message.contains("database_url_ref is no longer supported"));
assert!(message.contains("database_url"));
}
#[test]
fn postgres_bootstrap_rejects_database_url_ref_even_with_inline_url() {
let err = parse_bootstrap_database(
"hub_backend: postgres\n\
database_url: postgresql://inline/db\n\
database_url_ref: deprecated\n",
)
.expect_err("database_url_ref must fail");
assert!(
err.to_string()
.contains("database_url_ref is no longer supported")
);
}
#[test]
fn non_postgres_bootstrap_fails_clearly() {
let err = resolve_database_url_from_bootstrap(&bootstrap("legacy-local", None))
.expect_err("non-postgres backend must fail");
let message = err.to_string();
assert!(message.contains("hub_backend: postgres"));
assert!(message.contains("legacy-local"));
}
#[test]
fn missing_hub_backend_fails_clearly() {
let err = parse_bootstrap_database("database_url: postgresql://inline/db\n")
.expect_err("missing hub_backend must fail");
assert!(err.to_string().contains("hub_backend: postgres"));
}
#[test]
fn missing_postgres_dsn_fails_clearly() {
let err = resolve_database_url_from_bootstrap(&bootstrap("postgres", None))
.expect_err("missing dsn must fail");
assert!(err.to_string().contains("database_url"));
}
#[test]
fn parse_bootstrap_database_reads_postgres_fields() {
let parsed = parse_bootstrap_database(
"hub_backend: postgres\n\
database_url: postgresql://inline/db\n",
)
.expect("parse bootstrap");
assert_eq!(parsed.hub_backend, "postgres");
assert_eq!(
parsed.database_url.as_deref(),
Some("postgresql://inline/db")
);
}
#[test]
fn broker_request_returns_database_url_and_sends_local_token() {
let (daemon_url, request) = spawn_http_response(http_response(
"200 OK",
r#"{"database_url":"postgresql://broker/db"}"#,
));
let resolved =
request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
let request = request.join().expect("read request");
assert_eq!(resolved, "postgresql://broker/db");
assert!(request.starts_with("POST /api/local/runtime/database-url HTTP/1.1"));
assert!(
request
.to_ascii_lowercase()
.contains("x-gobby-local-token: token-123")
);
}
#[test]
fn broker_request_allows_cold_daemon_latency() {
let (daemon_url, request) = spawn_http_response_after(
http_response("200 OK", r#"{"database_url":"postgresql://broker/db"}"#),
Duration::from_millis(1100),
);
let resolved =
request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
let _ = request.join().expect("read request");
assert_eq!(resolved, "postgresql://broker/db");
}
#[test]
fn broker_missing_token_fails() {
let home = tempfile::tempdir().expect("temp home");
let bootstrap_path = write_bootstrap(home.path(), 60887);
let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
.expect_err("missing token must fail");
assert!(err.to_string().contains("missing local CLI token"));
}
#[test]
fn broker_daemon_down_fails() {
let home = tempfile::tempdir().expect("temp home");
std::fs::write(home.path().join(LOCAL_CLI_TOKEN_FILENAME), "token\n").expect("write token");
let bootstrap_path = write_bootstrap(home.path(), 9);
let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
.expect_err("daemon down must fail");
assert!(
err.to_string()
.contains("database_url broker request failed")
);
}
#[test]
fn broker_auth_failure_fails() {
let (daemon_url, request) = spawn_http_response(http_response(
"401 Unauthorized",
r#"{"error":"bad token"}"#,
));
let err = request_broker_database_url(&daemon_url, "bad-token")
.expect_err("auth failure must fail");
let _ = request.join().expect("read request");
assert!(
err.to_string()
.contains("database_url broker request failed")
);
}
#[test]
fn broker_non_success_status_fails() {
let (daemon_url, request) = spawn_http_response(http_response(
"503 Service Unavailable",
r#"{"error":"unavailable"}"#,
));
let err = request_broker_database_url(&daemon_url, "token")
.expect_err("non-success status must fail");
let _ = request.join().expect("read request");
assert!(
err.to_string()
.contains("database_url broker request failed")
);
}
#[test]
fn broker_invalid_json_fails() {
let (daemon_url, request) = spawn_http_response(http_response("200 OK", "not json"));
let err =
request_broker_database_url(&daemon_url, "token").expect_err("invalid JSON must fail");
let _ = request.join().expect("read request");
assert!(
err.to_string()
.contains("database_url broker response was not valid JSON")
);
}
#[test]
fn broker_empty_database_url_fails() {
let (daemon_url, request) =
spawn_http_response(http_response("200 OK", r#"{"database_url":" "}"#));
let err =
request_broker_database_url(&daemon_url, "token").expect_err("empty DSN must fail");
let _ = request.join().expect("read request");
assert!(
err.to_string()
.contains("database_url broker response was empty")
);
}
fn write_bootstrap(home: &Path, daemon_port: u16) -> PathBuf {
let path = home.join("bootstrap.yaml");
std::fs::write(
&path,
format!("hub_backend: postgres\ndaemon_port: {daemon_port}\nbind_host: 127.0.0.1\n"),
)
.expect("write bootstrap");
path
}
fn http_response(status: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
fn spawn_http_response(response: String) -> (String, thread::JoinHandle<String>) {
spawn_http_response_after(response, Duration::ZERO)
}
fn spawn_http_response_after(
response: String,
delay: Duration,
) -> (String, thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
let addr = listener.local_addr().expect("local addr");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept request");
let mut request = Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let read = stream.read(&mut buffer).expect("read request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
thread::sleep(delay);
stream
.write_all(response.as_bytes())
.expect("write response");
String::from_utf8_lossy(&request).into_owned()
});
(format!("http://{addr}"), handle)
}
}