1#![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))]
128
129use std::borrow::Cow;
130use std::path::Path;
131
132use config::FileStoredFormat;
133
134mod options;
135
136pub use options::*;
137
138#[derive(Debug, Clone, Copy)]
139struct FormatWrapper;
140
141#[cfg(not(feature = "templates"))]
142fn template_text<'a>(
143 text: &'a str,
144 _: &config::FileFormat,
145) -> Result<Cow<'a, str>, Box<dyn std::error::Error + Send + Sync>> {
146 Ok(Cow::Borrowed(text))
147}
148
149#[cfg(feature = "templates")]
150fn template_text<'a>(
151 text: &'a str,
152 _: &config::FileFormat,
153) -> Result<Cow<'a, str>, Box<dyn std::error::Error + Send + Sync>> {
154 use minijinja::syntax::SyntaxConfig;
155
156 let mut env = minijinja::Environment::new();
157
158 env.add_global("env", std::env::vars().collect::<std::collections::HashMap<_, _>>());
159 env.set_syntax(
160 SyntaxConfig::builder()
161 .block_delimiters("{%", "%}")
162 .variable_delimiters("${{", "}}")
163 .comment_delimiters("{#", "#}")
164 .build()
165 .unwrap(),
166 );
167
168 Ok(Cow::Owned(env.template_from_str(text).unwrap().render(())?))
169}
170
171impl config::Format for FormatWrapper {
172 fn parse(
173 &self,
174 uri: Option<&String>,
175 text: &str,
176 ) -> Result<config::Map<String, config::Value>, Box<dyn std::error::Error + Send + Sync>> {
177 let uri_ext = uri.and_then(|s| Path::new(s.as_str()).extension()).and_then(|s| s.to_str());
178
179 let mut formats: Vec<config::FileFormat> = vec![
180 #[cfg(feature = "toml")]
181 config::FileFormat::Toml,
182 #[cfg(feature = "json")]
183 config::FileFormat::Json,
184 #[cfg(feature = "yaml")]
185 config::FileFormat::Yaml,
186 #[cfg(feature = "json5")]
187 config::FileFormat::Json5,
188 #[cfg(feature = "ini")]
189 config::FileFormat::Ini,
190 #[cfg(feature = "ron")]
191 config::FileFormat::Ron,
192 ];
193
194 if let Some(uri_ext) = uri_ext {
195 formats.sort_by_key(|f| if f.file_extensions().contains(&uri_ext) { 0 } else { 1 });
196 }
197
198 for format in formats {
199 if let Ok(map) = format.parse(uri, template_text(text, &format)?.as_ref()) {
200 return Ok(map);
201 }
202 }
203
204 Err(Box::new(std::io::Error::new(
205 std::io::ErrorKind::InvalidData,
206 format!("No supported format found for file: {:?}", uri),
207 )))
208 }
209}
210
211impl config::FileStoredFormat for FormatWrapper {
212 fn file_extensions(&self) -> &'static [&'static str] {
213 &[
214 #[cfg(feature = "toml")]
215 "toml",
216 #[cfg(feature = "json")]
217 "json",
218 #[cfg(feature = "yaml")]
219 "yaml",
220 #[cfg(feature = "yaml")]
221 "yml",
222 #[cfg(feature = "json5")]
223 "json5",
224 #[cfg(feature = "ini")]
225 "ini",
226 #[cfg(feature = "ron")]
227 "ron",
228 ]
229 }
230}
231
232#[derive(Debug, thiserror::Error)]
234pub enum SettingsError {
235 #[error(transparent)]
236 Config(#[from] config::ConfigError),
237 #[cfg(feature = "cli")]
238 #[error(transparent)]
239 Clap(#[from] clap::Error),
240}
241
242pub fn parse_settings<T: serde::de::DeserializeOwned>(options: Options) -> Result<T, SettingsError> {
246 let mut config = config::Config::builder();
247
248 #[allow(unused_mut)]
249 let mut added_files = false;
250
251 #[cfg(feature = "cli")]
252 if let Some(cli) = options.cli {
253 let command = clap::Command::new(cli.name)
254 .version(cli.version)
255 .about(cli.about)
256 .author(cli.author)
257 .bin_name(cli.name)
258 .arg(
259 clap::Arg::new("config")
260 .short('c')
261 .long("config")
262 .value_name("FILE")
263 .help("Path to configuration file(s)")
264 .action(clap::ArgAction::Append),
265 )
266 .arg(
267 clap::Arg::new("overrides")
268 .long("override")
269 .short('o')
270 .alias("set")
271 .help("Provide an override for a configuration value, in the format KEY=VALUE")
272 .action(clap::ArgAction::Append),
273 );
274
275 let matches = command.get_matches_from(cli.argv);
276
277 if let Some(config_files) = matches.get_many::<String>("config") {
278 for path in config_files {
279 config = config.add_source(config::File::new(path, FormatWrapper));
280 added_files = true;
281 }
282 }
283
284 if let Some(overrides) = matches.get_many::<String>("overrides") {
285 for ov in overrides {
286 let (key, value) = ov.split_once('=').ok_or_else(|| {
287 clap::Error::raw(
288 clap::error::ErrorKind::InvalidValue,
289 "Override must be in the format KEY=VALUE",
290 )
291 })?;
292
293 config = config.set_override(key, value)?;
294 }
295 }
296 }
297
298 if !added_files {
299 if let Some(default_config_file) = options.default_config_file {
300 config = config.add_source(config::File::new(default_config_file, FormatWrapper).required(false));
301 }
302 }
303
304 if let Some(env_prefix) = options.env_prefix {
305 config = config.add_source(config::Environment::with_prefix(env_prefix));
306 }
307
308 Ok(config.build()?.try_deserialize()?)
309}
310
311#[doc(hidden)]
312#[cfg(feature = "bootstrap")]
313pub mod macros {
314 pub use {anyhow, scuffle_bootstrap};
315}
316
317#[cfg(feature = "bootstrap")]
331#[macro_export]
332macro_rules! bootstrap {
333 ($ty:ty) => {
334 impl $crate::macros::scuffle_bootstrap::config::ConfigParser for $ty {
335 async fn parse() -> $crate::macros::anyhow::Result<Self> {
336 $crate::macros::anyhow::Context::context(
337 $crate::parse_settings($crate::Options {
338 cli: Some($crate::cli!()),
339 ..::std::default::Default::default()
340 }),
341 "config",
342 )
343 }
344 }
345 };
346}
347
348#[cfg(test)]
349#[cfg_attr(all(test, coverage_nightly), coverage(off))]
350mod tests {
351 #[cfg(feature = "cli")]
352 use crate::Cli;
353 use crate::{parse_settings, Options};
354
355 #[derive(Debug, serde::Deserialize)]
356 struct TestSettings {
357 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
358 key: String,
359 }
360
361 #[test]
362 fn parse_empty() {
363 let err = parse_settings::<TestSettings>(Options::default()).expect_err("expected error");
364 assert!(matches!(err, crate::SettingsError::Config(config::ConfigError::Message(_))));
365 assert_eq!(err.to_string(), "missing field `key`");
366 }
367
368 #[test]
369 #[cfg(feature = "cli")]
370 fn parse_cli() {
371 let options = Options {
372 cli: Some(Cli {
373 name: "test",
374 version: "0.1.0",
375 about: "test",
376 author: "test",
377 argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
378 }),
379 ..Default::default()
380 };
381 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
382
383 assert_eq!(settings.key, "value");
384 }
385
386 #[test]
387 #[cfg(feature = "cli")]
388 fn cli_error() {
389 let options = Options {
390 cli: Some(Cli {
391 name: "test",
392 version: "0.1.0",
393 about: "test",
394 author: "test",
395 argv: vec!["test".to_string(), "-o".to_string(), "error".to_string()],
396 }),
397 ..Default::default()
398 };
399 let err = parse_settings::<TestSettings>(options).expect_err("expected error");
400
401 if let crate::SettingsError::Clap(err) = err {
402 assert_eq!(err.to_string(), "error: Override must be in the format KEY=VALUE");
403 } else {
404 panic!("unexpected error: {}", err);
405 }
406 }
407
408 #[test]
409 #[cfg(feature = "cli")]
410 fn parse_file() {
411 let options = Options {
412 cli: Some(Cli {
413 name: "test",
414 version: "0.1.0",
415 about: "test",
416 author: "test",
417 argv: vec!["test".to_string(), "-c".to_string(), "assets/test.toml".to_string()],
418 }),
419 ..Default::default()
420 };
421 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
422
423 assert_eq!(settings.key, "filevalue");
424 }
425
426 #[test]
427 #[cfg(feature = "cli")]
428 fn file_error() {
429 let options = Options {
430 cli: Some(Cli {
431 name: "test",
432 version: "0.1.0",
433 about: "test",
434 author: "test",
435 argv: vec!["test".to_string(), "-c".to_string(), "assets/invalid.txt".to_string()],
436 }),
437 ..Default::default()
438 };
439 let err = parse_settings::<TestSettings>(options).expect_err("expected error");
440
441 if let crate::SettingsError::Config(config::ConfigError::FileParse { uri: Some(uri), cause }) = err {
442 assert_eq!(uri, "assets/invalid.txt");
443 assert_eq!(
444 cause.to_string(),
445 "No supported format found for file: Some(\"assets/invalid.txt\")"
446 );
447 } else {
448 panic!("unexpected error: {}", err);
449 }
450 }
451
452 #[test]
453 #[cfg(feature = "cli")]
454 fn parse_env() {
455 let options = Options {
456 cli: Some(Cli {
457 name: "test",
458 version: "0.1.0",
459 about: "test",
460 author: "test",
461 argv: vec![],
462 }),
463 env_prefix: Some("SETTINGS_PARSE_ENV_TEST"),
464 ..Default::default()
465 };
466 std::env::set_var("SETTINGS_PARSE_ENV_TEST_KEY", "envvalue");
467 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
468
469 assert_eq!(settings.key, "envvalue");
470 }
471
472 #[test]
473 #[cfg(feature = "cli")]
474 fn overrides() {
475 let options = Options {
476 cli: Some(Cli {
477 name: "test",
478 version: "0.1.0",
479 about: "test",
480 author: "test",
481 argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
482 }),
483 env_prefix: Some("SETTINGS_OVERRIDES_TEST"),
484 ..Default::default()
485 };
486 std::env::set_var("SETTINGS_OVERRIDES_TEST_KEY", "envvalue");
487 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
488
489 assert_eq!(settings.key, "value");
490 }
491
492 #[test]
493 #[cfg(all(feature = "templates", feature = "cli"))]
494 fn templates() {
495 let options = Options {
496 cli: Some(Cli {
497 name: "test",
498 version: "0.1.0",
499 about: "test",
500 author: "test",
501 argv: vec!["test".to_string(), "-c".to_string(), "assets/templates.toml".to_string()],
502 }),
503 ..Default::default()
504 };
505 std::env::set_var("SETTINGS_TEMPLATES_TEST", "templatevalue");
506 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
507
508 assert_eq!(settings.key, "templatevalue");
509 }
510}