Skip to main content

liminal_server/config/
file.rs

1use std::path::Path;
2
3use crate::ServerError;
4
5use super::env::apply_env_overrides;
6use super::types::ServerConfig;
7use super::validation::validate;
8
9/// Loads a server configuration from a TOML file.
10///
11/// # Errors
12///
13/// Returns [`ServerError::ConfigLoad`] when the file cannot be read, the TOML is
14/// malformed, or strict deserialization rejects an unknown field.
15pub fn load_from_file(path: impl AsRef<Path>) -> Result<ServerConfig, ServerError> {
16    let path = path.as_ref();
17    let contents = std::fs::read_to_string(path).map_err(|error| ServerError::ConfigLoad {
18        message: format!(
19            "failed to read configuration file '{}': {error}",
20            path.display()
21        ),
22    })?;
23
24    toml::from_str::<ServerConfig>(&contents).map_err(|error| ServerError::ConfigLoad {
25        message: format!(
26            "failed to parse configuration file '{}': {error}",
27            path.display()
28        ),
29    })
30}
31
32pub(crate) fn load_config(path: impl AsRef<Path>) -> Result<ServerConfig, ServerError> {
33    let path = path.as_ref();
34    let config = load_from_file(path)?;
35    let mut config = apply_env_overrides(config)?;
36    // Channel `schema_ref` paths are resolved relative to the directory holding
37    // the config file, so validation loads each schema from there.
38    validate(&mut config, path.parent())?;
39    Ok(config)
40}
41
42#[cfg(test)]
43mod tests {
44    use std::fs;
45    use std::path::{Path, PathBuf};
46    use std::sync::atomic::{AtomicU64, Ordering};
47
48    use crate::ServerError;
49
50    use super::{load_config, load_from_file};
51
52    static NEXT_TEMP_FILE_ID: AtomicU64 = AtomicU64::new(0);
53
54    /// Absolute path to the config example shipped in the repository.
55    ///
56    /// Resolved from `CARGO_MANIFEST_DIR` (`<repo>/crates/liminal-server`) rather
57    /// than the process working directory, so the test finds the file identically
58    /// under `cargo test`, `cargo nextest`, and any invocation directory.
59    fn shipped_example_config_path() -> PathBuf {
60        Path::new(env!("CARGO_MANIFEST_DIR"))
61            .join("..")
62            .join("..")
63            .join("config")
64            .join("liminal.example.toml")
65    }
66
67    /// The shipped example must boot-load through the SAME entry point the binary
68    /// uses — `load_config` (file parse + environment overrides + validation), the
69    /// one `server::runtime::run` calls. This is the anti-rot pin: an example that
70    /// drifts from the schema, names an unknown field, or references a channel that
71    /// does not exist stops the build here rather than at a newcomer's first boot.
72    #[test]
73    fn shipped_example_config_loads_through_the_real_loader()
74    -> Result<(), Box<dyn std::error::Error>> {
75        let path = shipped_example_config_path();
76        let config = load_config(&path).map_err(|error| {
77            format!(
78                "the shipped example config '{}' must load and validate through the real loader: \
79                 {error}",
80                path.display()
81            )
82        })?;
83
84        // A newcomer's first boot needs at least one channel and the mandatory
85        // routing_rules key populated, not an empty husk.
86        assert!(
87            !config.channels.is_empty(),
88            "the example must declare at least one channel"
89        );
90        assert!(
91            !config.routing_rules.is_empty(),
92            "the example must exercise the mandatory routing_rules key"
93        );
94        // `persistence_path` must stay unset in the shipped file: validation
95        // requires the directory to already exist, so pinning one would make the
96        // example fail to validate on every checkout that lacks it.
97        assert!(
98            config.persistence_path.is_none(),
99            "the example must not pin a persistence_path — validation requires the \
100             directory to exist, which no fresh checkout can guarantee"
101        );
102
103        Ok(())
104    }
105
106    fn valid_toml() -> &'static str {
107        r#"
108listen_address = "127.0.0.1:8080"
109health_listen_address = "127.0.0.1:8081"
110drain_timeout_ms = 30000
111persistence_path = "/tmp"
112
113[[channels]]
114name = "orders"
115schema_ref = "schemas/orders.json"
116durable = true
117
118[[routing_rules]]
119source_channel = "orders"
120target_channel = "orders"
121predicate = "true"
122
123[cluster]
124node_name = "node-a"
125listen_address = "127.0.0.1:9000"
126seed_nodes = ["127.0.0.1:9001"]
127"#
128    }
129
130    fn temp_config_path(label: &str) -> PathBuf {
131        let id = NEXT_TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed);
132        std::env::temp_dir().join(format!(
133            "liminal-server-{label}-{}-{id}.toml",
134            std::process::id()
135        ))
136    }
137
138    fn write_temp_config(
139        label: &str,
140        contents: &str,
141    ) -> Result<PathBuf, Box<dyn std::error::Error>> {
142        let path = temp_config_path(label);
143        fs::write(&path, contents)?;
144        Ok(path)
145    }
146
147    fn remove_temp_file(path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
148        if path.exists() {
149            fs::remove_file(path)?;
150        }
151        Ok(())
152    }
153
154    #[test]
155    fn valid_toml_parses_into_server_config() -> Result<(), Box<dyn std::error::Error>> {
156        let path = write_temp_config("valid", valid_toml())?;
157        let config = load_from_file(&path)?;
158        remove_temp_file(&path)?;
159
160        assert_eq!(config.listen_address.to_string(), "127.0.0.1:8080");
161        assert_eq!(config.health_listen_address.to_string(), "127.0.0.1:8081");
162        assert_eq!(config.drain_timeout_ms, 30_000);
163        assert_eq!(config.channels.len(), 1);
164        assert_eq!(config.channels[0].name, "orders");
165        assert_eq!(config.routing_rules.len(), 1);
166        assert_eq!(
167            config.persistence_path.as_deref(),
168            Some(std::path::Path::new("/tmp"))
169        );
170        let cluster = config
171            .cluster
172            .as_ref()
173            .ok_or("cluster section should be present")?;
174        assert_eq!(cluster.node_name, "node-a");
175        assert_eq!(cluster.listen_address.to_string(), "127.0.0.1:9000");
176        assert_eq!(cluster.seed_nodes.len(), 1);
177        // The cookie is omitted from the fixture, so it must fall back to the
178        // shared default rather than parse-failing.
179        assert_eq!(cluster.cookie, crate::config::types::DEFAULT_COOKIE);
180
181        Ok(())
182    }
183
184    #[test]
185    fn websocket_section_parses_and_absent_section_stays_none()
186    -> Result<(), Box<dyn std::error::Error>> {
187        // Absent section: no websocket configuration exists at all.
188        let absent_path = write_temp_config("ws-absent", valid_toml())?;
189        let absent = load_from_file(&absent_path)?;
190        remove_temp_file(&absent_path)?;
191        assert!(absent.websocket.is_none());
192
193        // Present section: every field parses, including the optional
194        // keepalive interval and origin allow-list.
195        let toml = format!(
196            "{}\n[websocket]\nlisten_address = \"127.0.0.1:8090\"\npath = \"/liminal\"\n\
197             allowed_origins = [\"https://app.example.com\"]\nping_interval_ms = 30000\n",
198            valid_toml()
199        );
200        let path = write_temp_config("ws-present", &toml)?;
201        let config = load_from_file(&path)?;
202        remove_temp_file(&path)?;
203        let websocket = config.websocket.ok_or("websocket section should parse")?;
204        assert_eq!(websocket.listen_address.to_string(), "127.0.0.1:8090");
205        assert_eq!(websocket.path, "/liminal");
206        assert_eq!(
207            websocket.allowed_origins,
208            vec!["https://app.example.com".to_owned()]
209        );
210        assert_eq!(websocket.ping_interval_ms, Some(30_000));
211
212        // Minimal section: origins default to the fail-closed empty list and
213        // the keepalive stays disabled.
214        let minimal = format!(
215            "{}\n[websocket]\nlisten_address = \"127.0.0.1:8091\"\npath = \"/liminal\"\n",
216            valid_toml()
217        );
218        let minimal_path = write_temp_config("ws-minimal", &minimal)?;
219        let minimal_config = load_from_file(&minimal_path)?;
220        remove_temp_file(&minimal_path)?;
221        let websocket = minimal_config
222            .websocket
223            .ok_or("minimal websocket section should parse")?;
224        assert!(websocket.allowed_origins.is_empty());
225        assert_eq!(websocket.ping_interval_ms, None);
226        Ok(())
227    }
228
229    /// A minimal config that validates from ANY directory: no `schema_ref`, so
230    /// validation has no file to resolve relative to the temp dir the limits pins
231    /// write into. `valid_toml` deliberately carries a schema reference, which
232    /// makes it the wrong fixture for a pin about `[limits]`.
233    fn schema_free_toml() -> &'static str {
234        r#"
235listen_address = "127.0.0.1:8080"
236health_listen_address = "127.0.0.1:8081"
237drain_timeout_ms = 30000
238
239[[channels]]
240name = "orders"
241durable = false
242
243[[routing_rules]]
244source_channel = "orders"
245target_channel = "orders"
246"#
247    }
248
249    /// P0 #55 part 2: the two delivery caps are DEFAULTS, not constants, and the
250    /// operator's number must survive the WHOLE pipeline — file parse, environment
251    /// overrides, validation — not just `serde`.
252    ///
253    /// `load_config` is deliberately the entry point here rather than
254    /// `load_from_file`: the environment-override pass runs between parse and
255    /// validation, and a pin that stopped at the parse would go green on a loader
256    /// that silently reset limits afterwards.
257    ///
258    /// The section sets only TWO of the nine caps on purpose. A partial `[limits]`
259    /// table is the shape an operator actually writes, and it is the shape that
260    /// catches a `#[serde(default)]` regression on the whole struct: if the caps
261    /// ever stop defaulting per FIELD, the seven untouched ones collapse to zero
262    /// and validation refuses the file.
263    #[test]
264    fn operator_set_delivery_caps_survive_the_whole_load_pipeline()
265    -> Result<(), Box<dyn std::error::Error>> {
266        let operator_toml = format!(
267            "{}\n[limits]\nmax_subscription_inbox_depth = 9001\ndelivery_slice_budget = 7\n",
268            schema_free_toml()
269        );
270        let path = write_temp_config("operator-limits", &operator_toml)?;
271
272        let config = load_config(&path)?;
273
274        assert_eq!(config.limits.max_subscription_inbox_depth, 9001);
275        assert_eq!(config.limits.delivery_slice_budget, 7);
276        // The seven caps the operator did NOT name still carry their defaults —
277        // the per-field default survived a partially-populated section.
278        assert_eq!(config.limits.max_connections, 256);
279        assert_eq!(config.limits.max_connection_inbox_bytes, 4 * 1024 * 1024);
280
281        remove_temp_file(&path)?;
282        Ok(())
283    }
284
285    /// The other half of the pin above: an ABSENT `[limits]` section resolves both
286    /// delivery caps to the shipped defaults through the same pipeline. Without
287    /// this, the override pin alone could not tell a working default from a
288    /// coincidence — it only ever observes numbers the file supplied.
289    #[test]
290    fn absent_limits_section_resolves_the_shipped_delivery_defaults()
291    -> Result<(), Box<dyn std::error::Error>> {
292        let path = write_temp_config("absent-limits", schema_free_toml())?;
293
294        let config = load_config(&path)?;
295
296        assert_eq!(config.limits.max_subscription_inbox_depth, 4096);
297        assert_eq!(config.limits.delivery_slice_budget, 32);
298
299        remove_temp_file(&path)?;
300        Ok(())
301    }
302
303    #[test]
304    fn missing_file_returns_config_load() {
305        let path = temp_config_path("missing");
306        let result = load_from_file(&path);
307
308        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
309    }
310
311    #[test]
312    fn malformed_toml_returns_config_load_with_parse_details()
313    -> Result<(), Box<dyn std::error::Error>> {
314        let path = write_temp_config("malformed", "listen_address =")?;
315        let result = load_from_file(&path);
316        remove_temp_file(&path)?;
317
318        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
319        let Err(ServerError::ConfigLoad { message }) = result else {
320            return Ok(());
321        };
322        assert!(message.contains("parse"));
323
324        Ok(())
325    }
326
327    #[test]
328    fn unknown_fields_return_config_load() -> Result<(), Box<dyn std::error::Error>> {
329        let toml = format!("{}\nunknown_field = true\n", valid_toml());
330        let path = write_temp_config("unknown", &toml)?;
331        let result = load_from_file(&path);
332        remove_temp_file(&path)?;
333
334        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
335        let Err(ServerError::ConfigLoad { message }) = result else {
336            return Ok(());
337        };
338        assert!(message.contains("unknown") || message.contains("unexpected"));
339
340        Ok(())
341    }
342}