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