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::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        // The cookie is omitted from the fixture, so it must fall back to the
126        // shared default rather than parse-failing.
127        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        // Absent section: no websocket configuration exists at all.
136        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        // Present section: every field parses, including the optional
142        // keepalive interval and origin allow-list.
143        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        // Minimal section: origins default to the fail-closed empty list and
161        // the keepalive stays disabled.
162        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}