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 config = load_from_file(path)?;
34    let config = apply_env_overrides(config)?;
35    validate(&config)?;
36    Ok(config)
37}
38
39#[cfg(test)]
40mod tests {
41    use std::fs;
42    use std::path::PathBuf;
43    use std::sync::atomic::{AtomicU64, Ordering};
44
45    use crate::ServerError;
46
47    use super::load_from_file;
48
49    static NEXT_TEMP_FILE_ID: AtomicU64 = AtomicU64::new(0);
50
51    fn valid_toml() -> &'static str {
52        r#"
53listen_address = "127.0.0.1:8080"
54health_listen_address = "127.0.0.1:8081"
55drain_timeout_ms = 30000
56persistence_path = "/tmp"
57
58[[channels]]
59name = "orders"
60schema_ref = "schemas/orders.json"
61durable = true
62
63[[routing_rules]]
64source_channel = "orders"
65target_channel = "orders"
66predicate = "true"
67
68[cluster]
69node_name = "node-a"
70listen_address = "127.0.0.1:9000"
71seed_nodes = ["127.0.0.1:9001"]
72"#
73    }
74
75    fn temp_config_path(label: &str) -> PathBuf {
76        let id = NEXT_TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed);
77        std::env::temp_dir().join(format!(
78            "liminal-server-{label}-{}-{id}.toml",
79            std::process::id()
80        ))
81    }
82
83    fn write_temp_config(
84        label: &str,
85        contents: &str,
86    ) -> Result<PathBuf, Box<dyn std::error::Error>> {
87        let path = temp_config_path(label);
88        fs::write(&path, contents)?;
89        Ok(path)
90    }
91
92    fn remove_temp_file(path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
93        if path.exists() {
94            fs::remove_file(path)?;
95        }
96        Ok(())
97    }
98
99    #[test]
100    fn valid_toml_parses_into_server_config() -> Result<(), Box<dyn std::error::Error>> {
101        let path = write_temp_config("valid", valid_toml())?;
102        let config = load_from_file(&path)?;
103        remove_temp_file(&path)?;
104
105        assert_eq!(config.listen_address.to_string(), "127.0.0.1:8080");
106        assert_eq!(config.health_listen_address.to_string(), "127.0.0.1:8081");
107        assert_eq!(config.drain_timeout_ms, 30_000);
108        assert_eq!(config.channels.len(), 1);
109        assert_eq!(config.channels[0].name, "orders");
110        assert_eq!(config.routing_rules.len(), 1);
111        assert_eq!(
112            config.persistence_path.as_deref(),
113            Some(std::path::Path::new("/tmp"))
114        );
115        let cluster = config
116            .cluster
117            .as_ref()
118            .ok_or("cluster section should be present")?;
119        assert_eq!(cluster.node_name, "node-a");
120        assert_eq!(cluster.listen_address.to_string(), "127.0.0.1:9000");
121        assert_eq!(cluster.seed_nodes.len(), 1);
122        // The cookie is omitted from the fixture, so it must fall back to the
123        // shared default rather than parse-failing.
124        assert_eq!(cluster.cookie, crate::config::types::DEFAULT_COOKIE);
125
126        Ok(())
127    }
128
129    #[test]
130    fn missing_file_returns_config_load() {
131        let path = temp_config_path("missing");
132        let result = load_from_file(&path);
133
134        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
135    }
136
137    #[test]
138    fn malformed_toml_returns_config_load_with_parse_details()
139    -> Result<(), Box<dyn std::error::Error>> {
140        let path = write_temp_config("malformed", "listen_address =")?;
141        let result = load_from_file(&path);
142        remove_temp_file(&path)?;
143
144        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
145        let Err(ServerError::ConfigLoad { message }) = result else {
146            return Ok(());
147        };
148        assert!(message.contains("parse"));
149
150        Ok(())
151    }
152
153    #[test]
154    fn unknown_fields_return_config_load() -> Result<(), Box<dyn std::error::Error>> {
155        let toml = format!("{}\nunknown_field = true\n", valid_toml());
156        let path = write_temp_config("unknown", &toml)?;
157        let result = load_from_file(&path);
158        remove_temp_file(&path)?;
159
160        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
161        let Err(ServerError::ConfigLoad { message }) = result else {
162            return Ok(());
163        };
164        assert!(message.contains("unknown") || message.contains("unexpected"));
165
166        Ok(())
167    }
168}