scuffle_settings/
lib.rs

1//! A crate designed to provide a simple interface to load and manage settings.
2//!
3//! This crate is a wrapper around the `config` crate and `clap` crate
4//! to provide a simple interface to load and manage settings.
5#![cfg_attr(feature = "docs", doc = "\n\nSee the [changelog][changelog] for a full release history.")]
6#![cfg_attr(feature = "docs", doc = "## Feature flags")]
7#![cfg_attr(feature = "docs", doc = document_features::document_features!())]
8//! ## Examples
9//!
10//! ### With [`scuffle_bootstrap`](scuffle_bootstrap)
11//!
12//! ```rust
13//! // Define a config struct like this
14//! // You can use all of the serde attributes to customize the deserialization
15//! #[derive(serde_derive::Deserialize)]
16//! struct MyConfig {
17//!     some_setting: String,
18//!     #[serde(default)]
19//!     some_other_setting: i32,
20//! }
21//!
22//! // Implement scuffle_boostrap::ConfigParser for the config struct like this
23//! scuffle_settings::bootstrap!(MyConfig);
24//!
25//! # use std::sync::Arc;
26//! /// Our global state
27//! struct Global;
28//!
29//! impl scuffle_bootstrap::global::Global for Global {
30//!     type Config = MyConfig;
31//!
32//!     async fn init(config: MyConfig) -> anyhow::Result<Arc<Self>> {
33//!         // Here you now have access to the config
34//!         Ok(Arc::new(Self))
35//!     }
36//! }
37//! ```
38//!
39//! ### Without `scuffle_bootstrap`
40//!
41//! ```rust
42//! # fn test() -> Result<(), scuffle_settings::SettingsError> {
43//! // Define a config struct like this
44//! // You can use all of the serde attributes to customize the deserialization
45//! #[derive(serde_derive::Deserialize)]
46//! struct MyConfig {
47//!     some_setting: String,
48//!     #[serde(default)]
49//!     some_other_setting: i32,
50//! }
51//!
52//! // Parsing options
53//! let options = scuffle_settings::Options {
54//!     env_prefix: Some("MY_APP"),
55//!     ..Default::default()
56//! };
57//! // Parse the settings
58//! let settings: MyConfig = scuffle_settings::parse_settings(options)?;
59//! # Ok(())
60//! # }
61//! # unsafe { std::env::set_var("MY_APP_SOME_SETTING", "value"); }
62//! # test().unwrap();
63//! ```
64//!
65//! See [`Options`] for more information on how to customize parsing.
66//!
67//! ## Templates
68//!
69//! If the `templates` feature is enabled, the parser will attempt to render
70//! the configuration file as a jinja template before processing it.
71//!
72//! All environment variables set during execution will be available under
73//! the `env` variable inside the file.
74//!
75//! Example TOML file:
76//!
77//! ```toml
78//! some_setting = "${{ env.MY_APP_SECRET }}"
79//! ```
80//!
81//! Use `${{` and `}}` for variables, `{%` and `%}` for blocks and `{#` and `#}` for comments.
82//!
83//! ## Command Line Interface
84//!
85//! The following options are available for the CLI:
86//!
87//! - `--config` or `-c`
88//!
89//!   Path to a configuration file. This option can be used multiple times to load multiple files.
90//! - `--override` or `-o`
91//!
92//!   Provide an override for a configuration value, in the format `KEY=VALUE`.
93//!
94//! ## License
95//!
96//! This project is licensed under the MIT or Apache-2.0 license.
97//! You can choose between one of them if you use this work.
98//!
99//! `SPDX-License-Identifier: MIT OR Apache-2.0`
100#![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))]
101#![cfg_attr(docsrs, feature(doc_auto_cfg))]
102#![deny(missing_docs)]
103#![deny(unsafe_code)]
104#![deny(unreachable_pub)]
105
106use std::borrow::Cow;
107use std::path::Path;
108
109use config::FileStoredFormat;
110
111mod options;
112
113pub use options::*;
114
115#[derive(Debug, Clone, Copy)]
116struct FormatWrapper;
117
118#[cfg(not(feature = "templates"))]
119fn template_text<'a>(
120    text: &'a str,
121    _: &config::FileFormat,
122) -> Result<Cow<'a, str>, Box<dyn std::error::Error + Send + Sync>> {
123    Ok(Cow::Borrowed(text))
124}
125
126#[cfg(feature = "templates")]
127fn template_text<'a>(
128    text: &'a str,
129    _: &config::FileFormat,
130) -> Result<Cow<'a, str>, Box<dyn std::error::Error + Send + Sync>> {
131    use minijinja::syntax::SyntaxConfig;
132
133    let mut env = minijinja::Environment::new();
134
135    env.add_global("env", std::env::vars().collect::<std::collections::HashMap<_, _>>());
136    env.set_syntax(
137        SyntaxConfig::builder()
138            .block_delimiters("{%", "%}")
139            .variable_delimiters("${{", "}}")
140            .comment_delimiters("{#", "#}")
141            .build()
142            .unwrap(),
143    );
144
145    Ok(Cow::Owned(env.template_from_str(text).unwrap().render(())?))
146}
147
148impl config::Format for FormatWrapper {
149    fn parse(
150        &self,
151        uri: Option<&String>,
152        text: &str,
153    ) -> Result<config::Map<String, config::Value>, Box<dyn std::error::Error + Send + Sync>> {
154        let uri_ext = uri.and_then(|s| Path::new(s.as_str()).extension()).and_then(|s| s.to_str());
155
156        let mut formats: Vec<config::FileFormat> = vec![
157            #[cfg(feature = "toml")]
158            config::FileFormat::Toml,
159            #[cfg(feature = "json")]
160            config::FileFormat::Json,
161            #[cfg(feature = "yaml")]
162            config::FileFormat::Yaml,
163            #[cfg(feature = "json5")]
164            config::FileFormat::Json5,
165            #[cfg(feature = "ini")]
166            config::FileFormat::Ini,
167            #[cfg(feature = "ron")]
168            config::FileFormat::Ron,
169        ];
170
171        if let Some(uri_ext) = uri_ext {
172            formats.sort_by_key(|f| if f.file_extensions().contains(&uri_ext) { 0 } else { 1 });
173        }
174
175        for format in formats {
176            if let Ok(map) = format.parse(uri, template_text(text, &format)?.as_ref()) {
177                return Ok(map);
178            }
179        }
180
181        Err(Box::new(std::io::Error::new(
182            std::io::ErrorKind::InvalidData,
183            format!("No supported format found for file: {uri:?}"),
184        )))
185    }
186}
187
188impl config::FileStoredFormat for FormatWrapper {
189    fn file_extensions(&self) -> &'static [&'static str] {
190        &[
191            #[cfg(feature = "toml")]
192            "toml",
193            #[cfg(feature = "json")]
194            "json",
195            #[cfg(feature = "yaml")]
196            "yaml",
197            #[cfg(feature = "yaml")]
198            "yml",
199            #[cfg(feature = "json5")]
200            "json5",
201            #[cfg(feature = "ini")]
202            "ini",
203            #[cfg(feature = "ron")]
204            "ron",
205        ]
206    }
207}
208
209/// An error that can occur when parsing settings.
210#[derive(Debug, thiserror::Error)]
211pub enum SettingsError {
212    /// An error occurred while parsing the settings.
213    #[error(transparent)]
214    Config(#[from] config::ConfigError),
215    /// An error occurred while parsing the CLI arguments.
216    #[cfg(feature = "cli")]
217    #[error(transparent)]
218    Clap(#[from] clap::Error),
219}
220
221/// Parse settings using the given options.
222///
223/// Refer to the [`Options`] struct for more information on how to customize parsing.
224pub fn parse_settings<T: serde::de::DeserializeOwned>(options: Options) -> Result<T, SettingsError> {
225    let mut config = config::Config::builder();
226
227    #[allow(unused_mut)]
228    let mut added_files = false;
229
230    #[cfg(feature = "cli")]
231    if let Some(cli) = options.cli {
232        let command = clap::Command::new(cli.name)
233            .version(cli.version)
234            .about(cli.about)
235            .author(cli.author)
236            .bin_name(cli.name)
237            .arg(
238                clap::Arg::new("config")
239                    .short('c')
240                    .long("config")
241                    .value_name("FILE")
242                    .help("Path to configuration file(s)")
243                    .action(clap::ArgAction::Append),
244            )
245            .arg(
246                clap::Arg::new("overrides")
247                    .long("override")
248                    .short('o')
249                    .alias("set")
250                    .help("Provide an override for a configuration value, in the format KEY=VALUE")
251                    .action(clap::ArgAction::Append),
252            );
253
254        let matches = command.get_matches_from(cli.argv);
255
256        if let Some(config_files) = matches.get_many::<String>("config") {
257            for path in config_files {
258                config = config.add_source(config::File::new(path, FormatWrapper));
259                added_files = true;
260            }
261        }
262
263        if let Some(overrides) = matches.get_many::<String>("overrides") {
264            for ov in overrides {
265                let (key, value) = ov.split_once('=').ok_or_else(|| {
266                    clap::Error::raw(
267                        clap::error::ErrorKind::InvalidValue,
268                        "Override must be in the format KEY=VALUE",
269                    )
270                })?;
271
272                config = config.set_override(key, value)?;
273            }
274        }
275    }
276
277    if !added_files && let Some(default_config_file) = options.default_config_file {
278        config = config.add_source(config::File::new(default_config_file, FormatWrapper).required(false));
279    }
280
281    if let Some(env_prefix) = options.env_prefix {
282        config = config.add_source(config::Environment::with_prefix(env_prefix));
283    }
284
285    Ok(config.build()?.try_deserialize()?)
286}
287
288#[doc(hidden)]
289#[cfg(feature = "bootstrap")]
290pub mod macros {
291    pub use {anyhow, scuffle_bootstrap};
292}
293
294/// This macro can be used to integrate with the [`scuffle_bootstrap`] ecosystem.
295///
296/// This macro will implement the [`scuffle_bootstrap::config::ConfigParser`] trait for the given type.
297/// The generated implementation uses the [`parse_settings`] function to parse the settings.
298///
299/// ## Example
300///
301/// ```rust
302/// #[derive(serde_derive::Deserialize)]
303/// struct MySettings {
304///     key: String,
305/// }
306/// ```
307#[cfg(feature = "bootstrap")]
308#[macro_export]
309macro_rules! bootstrap {
310    ($ty:ty) => {
311        impl $crate::macros::scuffle_bootstrap::config::ConfigParser for $ty {
312            async fn parse() -> $crate::macros::anyhow::Result<Self> {
313                $crate::macros::anyhow::Context::context(
314                    $crate::parse_settings($crate::Options {
315                        cli: Some($crate::cli!()),
316                        ..::std::default::Default::default()
317                    }),
318                    "config",
319                )
320            }
321        }
322    };
323}
324
325/// Changelogs generated by [scuffle_changelog]
326#[cfg(feature = "docs")]
327#[scuffle_changelog::changelog]
328pub mod changelog {}
329
330#[cfg(test)]
331#[cfg_attr(all(test, coverage_nightly), coverage(off))]
332mod tests {
333    use std::path::PathBuf;
334
335    use serde_derive::Deserialize;
336
337    #[cfg(feature = "cli")]
338    use crate::Cli;
339    use crate::{Options, parse_settings};
340
341    #[derive(Debug, Deserialize)]
342    struct TestSettings {
343        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
344        key: String,
345    }
346
347    #[allow(unused)]
348    fn file_path(item: &str) -> PathBuf {
349        if let Some(env) = std::env::var_os("ASSETS_DIR") {
350            PathBuf::from(env).join(item)
351        } else {
352            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!("../../assets/{item}"))
353        }
354    }
355
356    #[test]
357    fn parse_empty() {
358        let err = parse_settings::<TestSettings>(Options::default()).expect_err("expected error");
359        assert!(matches!(err, crate::SettingsError::Config(config::ConfigError::Message(_))));
360        assert_eq!(err.to_string(), "missing field `key`");
361    }
362
363    #[test]
364    #[cfg(feature = "cli")]
365    fn parse_cli() {
366        let options = Options {
367            cli: Some(Cli {
368                name: "test",
369                version: "0.1.0",
370                about: "test",
371                author: "test",
372                argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
373            }),
374            ..Default::default()
375        };
376        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
377
378        assert_eq!(settings.key, "value");
379    }
380
381    #[test]
382    #[cfg(feature = "cli")]
383    fn cli_error() {
384        let options = Options {
385            cli: Some(Cli {
386                name: "test",
387                version: "0.1.0",
388                about: "test",
389                author: "test",
390                argv: vec!["test".to_string(), "-o".to_string(), "error".to_string()],
391            }),
392            ..Default::default()
393        };
394        let err = parse_settings::<TestSettings>(options).expect_err("expected error");
395
396        if let crate::SettingsError::Clap(err) = err {
397            assert_eq!(err.to_string(), "error: Override must be in the format KEY=VALUE");
398        } else {
399            panic!("unexpected error: {err}");
400        }
401    }
402
403    #[test]
404    #[cfg(all(feature = "cli", feature = "toml"))]
405    fn parse_file() {
406        let path = file_path("test.toml");
407        let options = Options {
408            cli: Some(Cli {
409                name: "test",
410                version: "0.1.0",
411                about: "test",
412                author: "test",
413                argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
414            }),
415            ..Default::default()
416        };
417        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
418
419        assert_eq!(settings.key, "filevalue");
420    }
421
422    #[test]
423    #[cfg(feature = "cli")]
424    fn file_error() {
425        let path = file_path("invalid.txt");
426        let options = Options {
427            cli: Some(Cli {
428                name: "test",
429                version: "0.1.0",
430                about: "test",
431                author: "test",
432                argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
433            }),
434            ..Default::default()
435        };
436        let err = parse_settings::<TestSettings>(options).expect_err("expected error");
437
438        if let crate::SettingsError::Config(config::ConfigError::FileParse { uri: Some(uri), cause }) = err {
439            assert!(
440                path.display().to_string().ends_with(&uri),
441                "path ({}) ends with {uri}",
442                path.display()
443            );
444            assert_eq!(
445                cause.to_string(),
446                format!("No supported format found for file: {:?}", Some(uri))
447            );
448        } else {
449            panic!("unexpected error: {err:?}");
450        }
451    }
452
453    #[test]
454    #[cfg(feature = "cli")]
455    fn parse_env() {
456        let options = Options {
457            cli: Some(Cli {
458                name: "test",
459                version: "0.1.0",
460                about: "test",
461                author: "test",
462                argv: vec![],
463            }),
464            env_prefix: Some("SETTINGS_PARSE_ENV_TEST"),
465            ..Default::default()
466        };
467        // Safety: This is a test and we do not have multiple threads.
468        #[allow(unsafe_code)]
469        unsafe {
470            std::env::set_var("SETTINGS_PARSE_ENV_TEST_KEY", "envvalue");
471        }
472        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
473
474        assert_eq!(settings.key, "envvalue");
475    }
476
477    #[test]
478    #[cfg(feature = "cli")]
479    fn overrides() {
480        let options = Options {
481            cli: Some(Cli {
482                name: "test",
483                version: "0.1.0",
484                about: "test",
485                author: "test",
486                argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
487            }),
488            env_prefix: Some("SETTINGS_OVERRIDES_TEST"),
489            ..Default::default()
490        };
491        // Safety: This is a test and we do not have multiple threads.
492        #[allow(unsafe_code)]
493        unsafe {
494            std::env::set_var("SETTINGS_OVERRIDES_TEST_KEY", "envvalue");
495        }
496        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
497
498        assert_eq!(settings.key, "value");
499    }
500
501    #[test]
502    #[cfg(all(feature = "templates", feature = "cli"))]
503    fn templates() {
504        let options = Options {
505            cli: Some(Cli {
506                name: "test",
507                version: "0.1.0",
508                about: "test",
509                author: "test",
510                argv: vec![
511                    "test".to_string(),
512                    "-c".to_string(),
513                    file_path("templates.toml").to_string_lossy().to_string(),
514                ],
515            }),
516            ..Default::default()
517        };
518        // Safety: This is a test and we do not have multiple threads.
519        #[allow(unsafe_code)]
520        unsafe {
521            std::env::set_var("SETTINGS_TEMPLATES_TEST", "templatevalue");
522        }
523        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
524
525        assert_eq!(settings.key, "templatevalue");
526    }
527}