1use std::path::{Path, PathBuf};
18
19pub const DEFAULT_DAEMON_PORT: u16 = 60887;
21
22pub const DEFAULT_BIND_HOST: &str = "127.0.0.1";
24
25const BOOTSTRAP_FILENAME: &str = "bootstrap.yaml";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct HubDatabaseBootstrap {
29 pub hub_backend: Option<String>,
30 pub database_url: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct DaemonEndpoint {
40 pub host: String,
41 pub port: u16,
42}
43
44impl Default for DaemonEndpoint {
45 fn default() -> Self {
46 Self {
47 host: DEFAULT_BIND_HOST.to_string(),
48 port: DEFAULT_DAEMON_PORT,
49 }
50 }
51}
52
53pub fn bootstrap_path() -> Option<PathBuf> {
59 crate::gobby_home().ok().map(|h| h.join(BOOTSTRAP_FILENAME))
60}
61
62pub fn read_daemon_endpoint() -> DaemonEndpoint {
67 match bootstrap_path() {
68 Some(path) => read_daemon_endpoint_at(&path),
69 None => DaemonEndpoint::default(),
70 }
71}
72
73pub fn read_daemon_endpoint_at(path: &Path) -> DaemonEndpoint {
78 let Ok(contents) = std::fs::read_to_string(path) else {
79 return DaemonEndpoint::default();
80 };
81 let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) else {
82 return DaemonEndpoint::default();
83 };
84
85 let port = yaml
86 .get("daemon_port")
87 .and_then(|v| v.as_u64())
88 .and_then(|n| u16::try_from(n).ok())
89 .unwrap_or(DEFAULT_DAEMON_PORT);
90
91 let host = yaml
92 .get("bind_host")
93 .and_then(|v| v.as_str())
94 .map(str::to_owned)
95 .unwrap_or_else(|| DEFAULT_BIND_HOST.to_string());
96
97 DaemonEndpoint { host, port }
98}
99
100pub fn read_hub_database_bootstrap_file(
101 path: &Path,
102) -> anyhow::Result<Option<HubDatabaseBootstrap>> {
103 if !path.exists() {
104 return Ok(None);
105 }
106
107 let contents = std::fs::read_to_string(path).map_err(|error| {
108 anyhow::anyhow!(
109 "failed to read Gobby bootstrap at {}: {error}",
110 path.display()
111 )
112 })?;
113 parse_hub_database_bootstrap(&contents)
114 .map_err(|error| anyhow::anyhow!("failed to parse {}: {error}", path.display()))
115}
116
117pub fn parse_hub_database_bootstrap(
118 contents: &str,
119) -> anyhow::Result<Option<HubDatabaseBootstrap>> {
120 if contents.trim().is_empty() {
121 return Ok(None);
122 }
123 let yaml: serde_yaml::Value = serde_yaml::from_str(contents)?;
124 if yaml.is_null() {
125 return Ok(None);
126 }
127 let Some(map) = yaml.as_mapping() else {
128 anyhow::bail!("bootstrap.yaml must be a mapping");
129 };
130
131 Ok(Some(HubDatabaseBootstrap {
132 hub_backend: optional_string_field(map, "hub_backend")?,
133 database_url: optional_string_field(map, "database_url")?,
134 }))
135}
136
137pub fn postgres_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
138 let Some(bootstrap) = read_hub_database_bootstrap_file(path)? else {
139 return Ok(None);
140 };
141 Ok(postgres_database_url_from_bootstrap(&bootstrap))
142}
143
144pub fn postgres_database_url_from_bootstrap(bootstrap: &HubDatabaseBootstrap) -> Option<String> {
145 if matches!(bootstrap.hub_backend.as_deref(), Some("postgres")) {
146 bootstrap.database_url.clone()
147 } else {
148 None
149 }
150}
151
152fn optional_string_field(map: &serde_yaml::Mapping, name: &str) -> anyhow::Result<Option<String>> {
153 let key = serde_yaml::Value::String(name.to_string());
154 match map.get(&key) {
155 Some(value) => match value.as_str() {
156 Some(text) => Ok(non_empty_trimmed(text)),
157 None => anyhow::bail!("bootstrap.yaml field `{name}` must be a string"),
158 },
159 None => Ok(None),
160 }
161}
162
163fn non_empty_trimmed(value: &str) -> Option<String> {
164 let trimmed = value.trim();
165 (!trimmed.is_empty()).then(|| trimmed.to_string())
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use std::fs;
172 use tempfile::tempdir;
173
174 #[test]
175 fn missing_file_returns_defaults() {
176 let dir = tempdir().unwrap();
177 let path = dir.path().join("does-not-exist.yaml");
178 assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
179 }
180
181 #[test]
182 fn malformed_yaml_returns_defaults() {
183 let dir = tempdir().unwrap();
184 let path = dir.path().join("bootstrap.yaml");
185 fs::write(&path, ": : not valid yaml ::\n\t").unwrap();
186 assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
187 }
188
189 #[test]
190 fn empty_file_returns_defaults() {
191 let dir = tempdir().unwrap();
192 let path = dir.path().join("bootstrap.yaml");
193 fs::write(&path, "").unwrap();
194 assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
195 }
196
197 #[test]
198 fn missing_fields_return_defaults() {
199 let dir = tempdir().unwrap();
200 let path = dir.path().join("bootstrap.yaml");
201 fs::write(&path, "other_field: value\n").unwrap();
202 assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
203 }
204
205 #[test]
206 fn reads_custom_port() {
207 let dir = tempdir().unwrap();
208 let path = dir.path().join("bootstrap.yaml");
209 fs::write(&path, "daemon_port: 61234\n").unwrap();
210 let ep = read_daemon_endpoint_at(&path);
211 assert_eq!(ep.port, 61234);
212 assert_eq!(ep.host, DEFAULT_BIND_HOST);
213 }
214
215 #[test]
216 fn reads_custom_host_and_port() {
217 let dir = tempdir().unwrap();
218 let path = dir.path().join("bootstrap.yaml");
219 fs::write(&path, "daemon_port: 60887\nbind_host: 0.0.0.0\n").unwrap();
220 let ep = read_daemon_endpoint_at(&path);
221 assert_eq!(ep.port, 60887);
222 assert_eq!(ep.host, "0.0.0.0");
223 }
224
225 #[test]
226 fn out_of_range_port_falls_back() {
227 let dir = tempdir().unwrap();
228 let path = dir.path().join("bootstrap.yaml");
229 fs::write(&path, "daemon_port: 70000\n").unwrap();
230 assert_eq!(read_daemon_endpoint_at(&path).port, DEFAULT_DAEMON_PORT);
231 }
232
233 #[test]
234 fn bootstrap_path_respects_gobby_home() {
235 let _lock = crate::config::TEST_ENV_LOCK
236 .lock()
237 .unwrap_or_else(|poisoned| poisoned.into_inner());
238 let previous = std::env::var_os("GOBBY_HOME");
239 let dir = tempdir().unwrap();
240 unsafe { std::env::set_var("GOBBY_HOME", dir.path()) };
243 let path = bootstrap_path();
244 unsafe {
246 match &previous {
247 Some(value) => std::env::set_var("GOBBY_HOME", value),
248 None => std::env::remove_var("GOBBY_HOME"),
249 }
250 }
251 assert_eq!(path, Some(dir.path().join("bootstrap.yaml")));
252 }
253
254 #[test]
255 fn postgres_database_url_missing_file_returns_none() {
256 let dir = tempdir().unwrap();
257 let path = dir.path().join("does-not-exist.yaml");
258
259 assert_eq!(
260 postgres_database_url_from_bootstrap_file(&path).unwrap(),
261 None
262 );
263 }
264
265 #[test]
266 fn postgres_database_url_empty_file_returns_none() {
267 let dir = tempdir().unwrap();
268 let path = dir.path().join("bootstrap.yaml");
269 fs::write(&path, "").unwrap();
270
271 assert_eq!(
272 postgres_database_url_from_bootstrap_file(&path).unwrap(),
273 None
274 );
275 }
276
277 #[test]
278 fn postgres_database_url_null_file_returns_none() {
279 let dir = tempdir().unwrap();
280 let path = dir.path().join("bootstrap.yaml");
281 fs::write(&path, "null\n").unwrap();
282
283 assert_eq!(
284 postgres_database_url_from_bootstrap_file(&path).unwrap(),
285 None
286 );
287 }
288
289 #[test]
290 fn postgres_database_url_reads_postgres_url() {
291 let dir = tempdir().unwrap();
292 let path = dir.path().join("bootstrap.yaml");
293 fs::write(
294 &path,
295 "hub_backend: postgres\ndatabase_url: postgresql://localhost/gobby\n",
296 )
297 .unwrap();
298
299 assert_eq!(
300 postgres_database_url_from_bootstrap_file(&path)
301 .unwrap()
302 .as_deref(),
303 Some("postgresql://localhost/gobby")
304 );
305 }
306
307 #[test]
308 fn postgres_database_url_trims_url() {
309 let dir = tempdir().unwrap();
310 let path = dir.path().join("bootstrap.yaml");
311 fs::write(
312 &path,
313 "hub_backend: postgres\ndatabase_url: ' postgresql://localhost/gobby '\n",
314 )
315 .unwrap();
316
317 assert_eq!(
318 postgres_database_url_from_bootstrap_file(&path)
319 .unwrap()
320 .as_deref(),
321 Some("postgresql://localhost/gobby")
322 );
323 }
324
325 #[test]
326 fn postgres_database_url_ignores_non_postgres_backend() {
327 let dir = tempdir().unwrap();
328 let path = dir.path().join("bootstrap.yaml");
329 fs::write(
330 &path,
331 "hub_backend: local-file\ndatabase_url: postgresql://localhost/gobby\n",
332 )
333 .unwrap();
334
335 assert_eq!(
336 postgres_database_url_from_bootstrap_file(&path).unwrap(),
337 None
338 );
339 }
340
341 #[test]
342 fn postgres_database_url_malformed_yaml_errors() {
343 let dir = tempdir().unwrap();
344 let path = dir.path().join("bootstrap.yaml");
345 fs::write(&path, "hub_backend: [").unwrap();
346
347 assert!(postgres_database_url_from_bootstrap_file(&path).is_err());
348 }
349
350 #[test]
351 fn postgres_database_url_non_empty_scalar_yaml_errors() {
352 let dir = tempdir().unwrap();
353 let path = dir.path().join("bootstrap.yaml");
354 fs::write(&path, "postgres\n").unwrap();
355
356 assert!(postgres_database_url_from_bootstrap_file(&path).is_err());
357 }
358}