1use serde::{de::DeserializeOwned, Deserialize, Serialize};
2use std::{
3 env, fmt, fs, io,
4 path::{Path, PathBuf},
5};
6
7#[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
28pub 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
38pub 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#[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#[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#[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}