liminal_server/config/
file.rs1use std::path::Path;
2
3use crate::ServerError;
4
5use super::env::apply_env_overrides;
6use super::types::ServerConfig;
7use super::validation::validate;
8
9pub 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 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 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 #[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 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 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 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 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 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 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 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 #[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 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 #[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}