Skip to main content

rust_zero_core/
config.rs

1use serde::{de::DeserializeOwned, Deserialize, Serialize};
2use std::{
3    env, fmt, fs, io,
4    path::{Path, PathBuf},
5};
6
7/// The supported serialized configuration formats.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ConfigFormat {
10    Json,
11    Json5,
12    Toml,
13    Yaml,
14}
15
16impl ConfigFormat {
17    fn from_path(path: &Path) -> Result<Self, ConfigError> {
18        match path.extension().and_then(|extension| extension.to_str()) {
19            Some("json") => Ok(Self::Json),
20            Some("json5") => Ok(Self::Json5),
21            Some("toml") => Ok(Self::Toml),
22            Some("yaml" | "yml") => Ok(Self::Yaml),
23            _ => Err(ConfigError::UnsupportedFormat(path.to_path_buf())),
24        }
25    }
26}
27
28/// Loads a typed configuration value based on the file extension.
29pub fn load_config<T: DeserializeOwned>(path: impl AsRef<Path>) -> Result<T, ConfigError> {
30    let path = path.as_ref();
31    let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io {
32        path: path.to_path_buf(),
33        source,
34    })?;
35    parse_config(&contents, ConfigFormat::from_path(path)?)
36}
37
38/// Parses a typed configuration value from JSON, JSON5, TOML, or YAML.
39///
40/// `${VAR}` and `$VAR` references are expanded from the process environment before parsing.
41pub fn parse_config<T: DeserializeOwned>(
42    contents: &str,
43    format: ConfigFormat,
44) -> Result<T, ConfigError> {
45    let contents = expand_environment(contents)?;
46    match format {
47        ConfigFormat::Json => {
48            serde_json::from_str(&contents).map_err(|error| ConfigError::Parse(error.to_string()))
49        }
50        ConfigFormat::Json5 => {
51            json5::from_str(&contents).map_err(|error| ConfigError::Parse(error.to_string()))
52        }
53        ConfigFormat::Toml => {
54            toml::from_str(&contents).map_err(|error| ConfigError::Parse(error.to_string()))
55        }
56        ConfigFormat::Yaml => {
57            serde_yaml::from_str(&contents).map_err(|error| ConfigError::Parse(error.to_string()))
58        }
59    }
60}
61
62fn expand_environment(input: &str) -> Result<String, ConfigError> {
63    let mut expanded = String::with_capacity(input.len());
64    let mut characters = input.chars().peekable();
65
66    while let Some(character) = characters.next() {
67        if character != '$' {
68            expanded.push(character);
69            continue;
70        }
71
72        let name = if characters.peek() == Some(&'{') {
73            characters.next();
74            let mut name = String::new();
75            loop {
76                match characters.next() {
77                    Some('}') => break name,
78                    Some(character) => name.push(character),
79                    None => return Err(ConfigError::InvalidEnvironmentReference),
80                }
81            }
82        } else {
83            let mut name = String::new();
84            while matches!(characters.peek(), Some(character) if character.is_ascii_alphanumeric() || *character == '_')
85            {
86                name.push(characters.next().expect("peeked character must exist"));
87            }
88            name
89        };
90
91        if name.is_empty() {
92            expanded.push('$');
93            continue;
94        }
95
96        let value = env::var(&name).map_err(|_| ConfigError::MissingEnvironmentVariable(name))?;
97        expanded.push_str(&value);
98    }
99
100    Ok(expanded)
101}
102
103/// Common service metadata used by REST, RPC, gateway, and background services.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ServiceConfig {
106    pub name: String,
107    #[serde(default = "default_host")]
108    pub host: String,
109    pub port: u16,
110    #[serde(default)]
111    pub mode: ServiceMode,
112}
113
114impl ServiceConfig {
115    pub fn address(&self) -> String {
116        format!("{}:{}", self.host, self.port)
117    }
118}
119
120fn default_host() -> String {
121    "0.0.0.0".to_owned()
122}
123
124/// Service operating mode.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "lowercase")]
127pub enum ServiceMode {
128    Development,
129    #[default]
130    Production,
131    Test,
132}
133
134/// Errors produced while loading or parsing configuration.
135#[derive(Debug)]
136pub enum ConfigError {
137    Io { path: PathBuf, source: io::Error },
138    UnsupportedFormat(PathBuf),
139    InvalidEnvironmentReference,
140    MissingEnvironmentVariable(String),
141    Parse(String),
142}
143
144impl fmt::Display for ConfigError {
145    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            Self::Io { path, source } => {
148                write!(
149                    formatter,
150                    "failed to read configuration {}: {source}",
151                    path.display()
152                )
153            }
154            Self::UnsupportedFormat(path) => write!(
155                formatter,
156                "unsupported configuration format for {}; use .json, .json5, .toml, .yaml, or .yml",
157                path.display()
158            ),
159            Self::InvalidEnvironmentReference => {
160                formatter.write_str("unterminated ${VAR} configuration reference")
161            }
162            Self::MissingEnvironmentVariable(name) => {
163                write!(
164                    formatter,
165                    "configuration references missing environment variable {name}"
166                )
167            }
168            Self::Parse(error) => write!(formatter, "failed to parse configuration: {error}"),
169        }
170    }
171}
172
173impl std::error::Error for ConfigError {
174    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
175        match self {
176            Self::Io { source, .. } => Some(source),
177            _ => None,
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde::Deserialize;
186    use std::error::Error as _;
187
188    #[derive(Debug, Deserialize, PartialEq)]
189    struct Credentials {
190        username: String,
191        password: String,
192    }
193
194    #[test]
195    fn parses_toml_with_environment_expansion() {
196        unsafe {
197            env::set_var("RUST_ZERO_CONFIG_PASSWORD", "correct-horse-battery-staple");
198        }
199
200        let credentials: Credentials = parse_config(
201            "username = \"service\"\npassword = \"${RUST_ZERO_CONFIG_PASSWORD}\"",
202            ConfigFormat::Toml,
203        )
204        .unwrap();
205
206        assert_eq!(
207            credentials,
208            Credentials {
209                username: "service".to_owned(),
210                password: "correct-horse-battery-staple".to_owned(),
211            }
212        );
213
214        unsafe {
215            env::remove_var("RUST_ZERO_CONFIG_PASSWORD");
216        }
217    }
218
219    #[test]
220    fn reports_missing_environment_values() {
221        let error = parse_config::<Credentials>(
222            "username: service\npassword: ${RUST_ZERO_MISSING_VALUE}",
223            ConfigFormat::Yaml,
224        )
225        .unwrap_err();
226
227        assert!(matches!(
228            error,
229            ConfigError::MissingEnvironmentVariable(name) if name == "RUST_ZERO_MISSING_VALUE"
230        ));
231    }
232
233    #[test]
234    fn service_config_defaults_to_production_on_all_interfaces() {
235        let config: ServiceConfig =
236            parse_config("name = \"users\"\nport = 8080", ConfigFormat::Toml).unwrap();
237
238        assert_eq!(config.host, "0.0.0.0");
239        assert_eq!(config.mode, ServiceMode::Production);
240        assert_eq!(config.address(), "0.0.0.0:8080");
241    }
242
243    #[test]
244    fn parses_json_yaml_and_unbraced_environment_references() {
245        unsafe {
246            env::set_var("RUST_ZERO_CONFIG_USER", "worker");
247        }
248
249        let json: Credentials = parse_config(
250            r#"{"username":"$RUST_ZERO_CONFIG_USER","password":"secret"}"#,
251            ConfigFormat::Json,
252        )
253        .unwrap();
254        let yaml: Credentials =
255            parse_config("username: worker\npassword: secret", ConfigFormat::Yaml).unwrap();
256
257        assert_eq!(json, yaml);
258        assert_eq!(expand_environment("$ ${}").unwrap(), "$ $");
259
260        unsafe {
261            env::remove_var("RUST_ZERO_CONFIG_USER");
262        }
263    }
264
265    #[test]
266    fn parses_json5_comments_trailing_commas_and_unquoted_keys() {
267        let credentials: Credentials = parse_config(
268            r#"{
269                // JSON5 configuration can remain friendly to humans.
270                username: 'service',
271                password: 'secret',
272            }"#,
273            ConfigFormat::Json5,
274        )
275        .unwrap();
276
277        assert_eq!(
278            credentials,
279            Credentials {
280                username: "service".to_owned(),
281                password: "secret".to_owned(),
282            }
283        );
284    }
285
286    #[test]
287    fn loads_supported_file_extensions() {
288        let directory = env::temp_dir();
289        let process = std::process::id();
290        let fixtures = [
291            ("json", r#"{"username":"service","password":"secret"}"#),
292            ("json5", "{username: 'service', password: 'secret',}"),
293            ("toml", "username = \"service\"\npassword = \"secret\""),
294            ("yaml", "username: service\npassword: secret"),
295            ("yml", "username: service\npassword: secret"),
296        ];
297
298        for (extension, contents) in fixtures {
299            let path = directory.join(format!("rust-zero-config-{process}.{extension}"));
300            fs::write(&path, contents).unwrap();
301            let credentials: Credentials = load_config(&path).unwrap();
302            assert_eq!(credentials.username, "service");
303            fs::remove_file(path).unwrap();
304        }
305    }
306
307    #[test]
308    fn reports_io_format_reference_and_parse_errors() {
309        let missing = env::temp_dir().join(format!(
310            "rust-zero-missing-config-{}.json",
311            std::process::id()
312        ));
313        let io_error = load_config::<Credentials>(&missing).unwrap_err();
314        assert!(matches!(io_error, ConfigError::Io { .. }));
315        assert!(io_error.source().is_some());
316        assert!(io_error
317            .to_string()
318            .contains("failed to read configuration"));
319
320        let unsupported =
321            env::temp_dir().join(format!("rust-zero-config-{}.txt", std::process::id()));
322        fs::write(&unsupported, "{}").unwrap();
323        let format_error = load_config::<Credentials>(&unsupported).unwrap_err();
324        fs::remove_file(unsupported).unwrap();
325        assert!(matches!(format_error, ConfigError::UnsupportedFormat(_)));
326        assert!(format_error
327            .to_string()
328            .contains("use .json, .json5, .toml, .yaml"));
329        assert!(format_error.source().is_none());
330
331        let reference_error =
332            parse_config::<Credentials>("${UNCLOSED", ConfigFormat::Json).unwrap_err();
333        assert!(matches!(
334            reference_error,
335            ConfigError::InvalidEnvironmentReference
336        ));
337        assert_eq!(
338            reference_error.to_string(),
339            "unterminated ${VAR} configuration reference"
340        );
341
342        let parse_error = parse_config::<Credentials>("not json", ConfigFormat::Json).unwrap_err();
343        assert!(matches!(parse_error, ConfigError::Parse(_)));
344        assert!(parse_error
345            .to_string()
346            .starts_with("failed to parse configuration:"));
347    }
348}