Skip to main content

gobby_core/
bootstrap.rs

1//! Bootstrap config resolution.
2//!
3//! Reads `~/.gobby/bootstrap.yaml` to discover how the Gobby daemon is
4//! reachable: its TCP port and bind host. Falls back to loopback defaults
5//! when the file is missing, unreadable, or malformed — clients should
6//! always get *something* usable rather than error on startup.
7//!
8//! The daemon advertises `bind_host` as a listen address. `0.0.0.0` and
9//! `::` are valid listen addresses but invalid dial addresses — a user who
10//! sets `bind_host: 0.0.0.0` to expose the daemon on their LAN must still
11//! connect to `127.0.0.1` locally. Normalization lives in [`daemon_url`]
12//! (the caller concerned with dialing), not here; this module returns the
13//! raw endpoint as written.
14//!
15//! [`daemon_url`]: crate::daemon_url
16
17use std::path::{Path, PathBuf};
18
19/// Default daemon port when bootstrap.yaml is missing or malformed.
20pub const DEFAULT_DAEMON_PORT: u16 = 60887;
21
22/// Default bind host when bootstrap.yaml is missing or malformed.
23pub const DEFAULT_BIND_HOST: &str = "127.0.0.1";
24
25const BOOTSTRAP_RELATIVE_PATH: &str = ".gobby/bootstrap.yaml";
26
27/// A daemon endpoint as advertised by bootstrap.yaml.
28///
29/// `host` is returned verbatim from the config (or [`DEFAULT_BIND_HOST`]);
30/// callers that dial should apply [`crate::daemon_url`] to normalize
31/// unroutable listen addresses.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DaemonEndpoint {
34    pub host: String,
35    pub port: u16,
36}
37
38impl Default for DaemonEndpoint {
39    fn default() -> Self {
40        Self {
41            host: DEFAULT_BIND_HOST.to_string(),
42            port: DEFAULT_DAEMON_PORT,
43        }
44    }
45}
46
47/// Resolve the path to `~/.gobby/bootstrap.yaml`.
48///
49/// Returns `None` when the home directory cannot be determined.
50pub fn bootstrap_path() -> Option<PathBuf> {
51    dirs::home_dir().map(|h| h.join(BOOTSTRAP_RELATIVE_PATH))
52}
53
54/// Read the daemon endpoint from the default bootstrap path.
55///
56/// Falls back to [`DaemonEndpoint::default`] on any failure — missing file,
57/// unreadable file, malformed YAML, missing fields, or no home directory.
58pub fn read_daemon_endpoint() -> DaemonEndpoint {
59    match bootstrap_path() {
60        Some(path) => read_daemon_endpoint_at(&path),
61        None => DaemonEndpoint::default(),
62    }
63}
64
65/// Read the daemon endpoint from a specific bootstrap file path.
66///
67/// Exposed for tests and for callers that know the path explicitly.
68/// Same fallback semantics as [`read_daemon_endpoint`].
69pub fn read_daemon_endpoint_at(path: &Path) -> DaemonEndpoint {
70    let Ok(contents) = std::fs::read_to_string(path) else {
71        return DaemonEndpoint::default();
72    };
73    let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&contents) else {
74        return DaemonEndpoint::default();
75    };
76
77    let port = yaml
78        .get("daemon_port")
79        .and_then(|v| v.as_u64())
80        .and_then(|n| u16::try_from(n).ok())
81        .unwrap_or(DEFAULT_DAEMON_PORT);
82
83    let host = yaml
84        .get("bind_host")
85        .and_then(|v| v.as_str())
86        .map(str::to_owned)
87        .unwrap_or_else(|| DEFAULT_BIND_HOST.to_string());
88
89    DaemonEndpoint { host, port }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::fs;
96    use tempfile::tempdir;
97
98    #[test]
99    fn missing_file_returns_defaults() {
100        let dir = tempdir().unwrap();
101        let path = dir.path().join("does-not-exist.yaml");
102        assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
103    }
104
105    #[test]
106    fn malformed_yaml_returns_defaults() {
107        let dir = tempdir().unwrap();
108        let path = dir.path().join("bootstrap.yaml");
109        fs::write(&path, ": : not valid yaml ::\n\t").unwrap();
110        assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
111    }
112
113    #[test]
114    fn empty_file_returns_defaults() {
115        let dir = tempdir().unwrap();
116        let path = dir.path().join("bootstrap.yaml");
117        fs::write(&path, "").unwrap();
118        assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
119    }
120
121    #[test]
122    fn missing_fields_return_defaults() {
123        let dir = tempdir().unwrap();
124        let path = dir.path().join("bootstrap.yaml");
125        fs::write(&path, "other_field: value\n").unwrap();
126        assert_eq!(read_daemon_endpoint_at(&path), DaemonEndpoint::default());
127    }
128
129    #[test]
130    fn reads_custom_port() {
131        let dir = tempdir().unwrap();
132        let path = dir.path().join("bootstrap.yaml");
133        fs::write(&path, "daemon_port: 61234\n").unwrap();
134        let ep = read_daemon_endpoint_at(&path);
135        assert_eq!(ep.port, 61234);
136        assert_eq!(ep.host, DEFAULT_BIND_HOST);
137    }
138
139    #[test]
140    fn reads_custom_host_and_port() {
141        let dir = tempdir().unwrap();
142        let path = dir.path().join("bootstrap.yaml");
143        fs::write(&path, "daemon_port: 60887\nbind_host: 0.0.0.0\n").unwrap();
144        let ep = read_daemon_endpoint_at(&path);
145        assert_eq!(ep.port, 60887);
146        assert_eq!(ep.host, "0.0.0.0");
147    }
148
149    #[test]
150    fn out_of_range_port_falls_back() {
151        let dir = tempdir().unwrap();
152        let path = dir.path().join("bootstrap.yaml");
153        fs::write(&path, "daemon_port: 70000\n").unwrap();
154        assert_eq!(read_daemon_endpoint_at(&path).port, DEFAULT_DAEMON_PORT);
155    }
156}