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::PathBuf;
46 use std::sync::atomic::{AtomicU64, Ordering};
47
48 use crate::ServerError;
49
50 use super::load_from_file;
51
52 static NEXT_TEMP_FILE_ID: AtomicU64 = AtomicU64::new(0);
53
54 fn valid_toml() -> &'static str {
55 r#"
56listen_address = "127.0.0.1:8080"
57health_listen_address = "127.0.0.1:8081"
58drain_timeout_ms = 30000
59persistence_path = "/tmp"
60
61[[channels]]
62name = "orders"
63schema_ref = "schemas/orders.json"
64durable = true
65
66[[routing_rules]]
67source_channel = "orders"
68target_channel = "orders"
69predicate = "true"
70
71[cluster]
72node_name = "node-a"
73listen_address = "127.0.0.1:9000"
74seed_nodes = ["127.0.0.1:9001"]
75"#
76 }
77
78 fn temp_config_path(label: &str) -> PathBuf {
79 let id = NEXT_TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed);
80 std::env::temp_dir().join(format!(
81 "liminal-server-{label}-{}-{id}.toml",
82 std::process::id()
83 ))
84 }
85
86 fn write_temp_config(
87 label: &str,
88 contents: &str,
89 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
90 let path = temp_config_path(label);
91 fs::write(&path, contents)?;
92 Ok(path)
93 }
94
95 fn remove_temp_file(path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
96 if path.exists() {
97 fs::remove_file(path)?;
98 }
99 Ok(())
100 }
101
102 #[test]
103 fn valid_toml_parses_into_server_config() -> Result<(), Box<dyn std::error::Error>> {
104 let path = write_temp_config("valid", valid_toml())?;
105 let config = load_from_file(&path)?;
106 remove_temp_file(&path)?;
107
108 assert_eq!(config.listen_address.to_string(), "127.0.0.1:8080");
109 assert_eq!(config.health_listen_address.to_string(), "127.0.0.1:8081");
110 assert_eq!(config.drain_timeout_ms, 30_000);
111 assert_eq!(config.channels.len(), 1);
112 assert_eq!(config.channels[0].name, "orders");
113 assert_eq!(config.routing_rules.len(), 1);
114 assert_eq!(
115 config.persistence_path.as_deref(),
116 Some(std::path::Path::new("/tmp"))
117 );
118 let cluster = config
119 .cluster
120 .as_ref()
121 .ok_or("cluster section should be present")?;
122 assert_eq!(cluster.node_name, "node-a");
123 assert_eq!(cluster.listen_address.to_string(), "127.0.0.1:9000");
124 assert_eq!(cluster.seed_nodes.len(), 1);
125 assert_eq!(cluster.cookie, crate::config::types::DEFAULT_COOKIE);
128
129 Ok(())
130 }
131
132 #[test]
133 fn websocket_section_parses_and_absent_section_stays_none()
134 -> Result<(), Box<dyn std::error::Error>> {
135 let absent_path = write_temp_config("ws-absent", valid_toml())?;
137 let absent = load_from_file(&absent_path)?;
138 remove_temp_file(&absent_path)?;
139 assert!(absent.websocket.is_none());
140
141 let toml = format!(
144 "{}\n[websocket]\nlisten_address = \"127.0.0.1:8090\"\npath = \"/liminal\"\n\
145 allowed_origins = [\"https://app.example.com\"]\nping_interval_ms = 30000\n",
146 valid_toml()
147 );
148 let path = write_temp_config("ws-present", &toml)?;
149 let config = load_from_file(&path)?;
150 remove_temp_file(&path)?;
151 let websocket = config.websocket.ok_or("websocket section should parse")?;
152 assert_eq!(websocket.listen_address.to_string(), "127.0.0.1:8090");
153 assert_eq!(websocket.path, "/liminal");
154 assert_eq!(
155 websocket.allowed_origins,
156 vec!["https://app.example.com".to_owned()]
157 );
158 assert_eq!(websocket.ping_interval_ms, Some(30_000));
159
160 let minimal = format!(
163 "{}\n[websocket]\nlisten_address = \"127.0.0.1:8091\"\npath = \"/liminal\"\n",
164 valid_toml()
165 );
166 let minimal_path = write_temp_config("ws-minimal", &minimal)?;
167 let minimal_config = load_from_file(&minimal_path)?;
168 remove_temp_file(&minimal_path)?;
169 let websocket = minimal_config
170 .websocket
171 .ok_or("minimal websocket section should parse")?;
172 assert!(websocket.allowed_origins.is_empty());
173 assert_eq!(websocket.ping_interval_ms, None);
174 Ok(())
175 }
176
177 #[test]
178 fn missing_file_returns_config_load() {
179 let path = temp_config_path("missing");
180 let result = load_from_file(&path);
181
182 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
183 }
184
185 #[test]
186 fn malformed_toml_returns_config_load_with_parse_details()
187 -> Result<(), Box<dyn std::error::Error>> {
188 let path = write_temp_config("malformed", "listen_address =")?;
189 let result = load_from_file(&path);
190 remove_temp_file(&path)?;
191
192 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
193 let Err(ServerError::ConfigLoad { message }) = result else {
194 return Ok(());
195 };
196 assert!(message.contains("parse"));
197
198 Ok(())
199 }
200
201 #[test]
202 fn unknown_fields_return_config_load() -> Result<(), Box<dyn std::error::Error>> {
203 let toml = format!("{}\nunknown_field = true\n", valid_toml());
204 let path = write_temp_config("unknown", &toml)?;
205 let result = load_from_file(&path);
206 remove_temp_file(&path)?;
207
208 assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
209 let Err(ServerError::ConfigLoad { message }) = result else {
210 return Ok(());
211 };
212 assert!(message.contains("unknown") || message.contains("unexpected"));
213
214 Ok(())
215 }
216}