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//!
6//! ## How to use this
7//!
8//! ### With `scuffle_bootstrap`
9//!
10//! ```rust
11//! // Define a config struct like this
12//! // You can use all of the serde attributes to customize the deserialization
13//! #[derive(serde_derive::Deserialize)]
14//! struct MyConfig {
15//!     some_setting: String,
16//!     #[serde(default)]
17//!     some_other_setting: i32,
18//! }
19//!
20//! // Implement scuffle_boostrap::ConfigParser for the config struct like this
21//! scuffle_settings::bootstrap!(MyConfig);
22//!
23//! # use std::sync::Arc;
24//! /// Our global state
25//! struct Global;
26//!
27//! impl scuffle_bootstrap::global::Global for Global {
28//!     type Config = MyConfig;
29//!
30//!     async fn init(config: MyConfig) -> anyhow::Result<Arc<Self>> {
31//!         // Here you now have access to the config
32//!         Ok(Arc::new(Self))
33//!     }
34//! }
35//! ```
36//!
37//! ### Without `scuffle_bootstrap`
38//!
39//! ```rust
40//! # fn test() -> Result<(), scuffle_settings::SettingsError> {
41//! // Define a config struct like this
42//! // You can use all of the serde attributes to customize the deserialization
43//! #[derive(serde_derive::Deserialize)]
44//! struct MyConfig {
45//!     some_setting: String,
46//!     #[serde(default)]
47//!     some_other_setting: i32,
48//! }
49//!
50//! // Parsing options
51//! let options = scuffle_settings::Options {
52//!     env_prefix: Some("MY_APP"),
53//!     ..Default::default()
54//! };
55//! // Parse the settings
56//! let settings: MyConfig = scuffle_settings::parse_settings(options)?;
57//! # Ok(())
58//! # }
59//! # unsafe { std::env::set_var("MY_APP_SOME_SETTING", "value"); }
60//! # test().unwrap();
61//! ```
62//!
63//! See [`Options`] for more information on how to customize parsing.
64//!
65//! ## Templates
66//!
67//! If the `templates` feature is enabled, the parser will attempt to render
68//! the configuration file as a jinja template before processing it.
69//!
70//! All environment variables set during execution will be available under
71//! the `env` variable inside the file.
72//!
73//! Example TOML file:
74//!
75//! ```toml
76//! some_setting = "${{ env.MY_APP_SECRET }}"
77//! ```
78//!
79//! Use `${{` and `}}` for variables, `{%` and `%}` for blocks and `{#` and `#}` for comments.
80//!
81//! ## Command Line Interface
82//!
83//! The following options are available for the CLI:
84//!
85//! - `--config` or `-c`
86//!
87//!   Path to a configuration file. This option can be used multiple times to load multiple files.
88//! - `--override` or `-o`
89//!
90//!   Provide an override for a configuration value, in the format `KEY=VALUE`.
91//!
92//! ## Feature Flags
93//!
94//! - `full`: Enables all of the following features
95//! - `templates`: Enables template support
96//!
97//!   See [Templates](#templates) above.
98//! - `bootstrap`: Enables the `bootstrap!` macro
99//!
100//!   See [`bootstrap!`] and [With `scuffle_bootstrap`](#with-scuffle_bootstrap) above.
101//! - `cli`: Enables the CLI
102//!
103//!   See [Command Line Interface](#command-line-interface) above.
104//! - `all-formats`: Enables all of the following formats
105//!
106//! ### Format Feature Flags
107//!
108//! - `toml`: Enables TOML support
109//! - `yaml`: Enables YAML support
110//! - `json`: Enables JSON support
111//! - `json5`: Enables JSON5 support
112//! - `ron`: Enables RON support
113//! - `ini`: Enables INI support
114//!
115//! ## Status
116//!
117//! This crate is currently under development and is not yet stable.
118//!
119//! Unit tests are not yet fully implemented. Use at your own risk.
120//!
121//! ## License
122//!
123//! This project is licensed under the [MIT](./LICENSE.MIT) or [Apache-2.0](./LICENSE.Apache-2.0) license.
124//! You can choose between one of them if you use this work.
125//!
126//! `SPDX-License-Identifier: MIT OR Apache-2.0`
127#![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/// An error that can occur when parsing settings.
237#[derive(Debug, thiserror::Error)]
238pub enum SettingsError {
239    /// An error occurred while parsing the settings.
240    #[error(transparent)]
241    Config(#[from] config::ConfigError),
242    /// An error occurred while parsing the CLI arguments.
243    #[cfg(feature = "cli")]
244    #[error(transparent)]
245    Clap(#[from] clap::Error),
246}
247
248/// Parse settings using the given options.
249///
250/// Refer to the [`Options`] struct for more information on how to customize parsing.
251pub 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/// This macro can be used to integrate with the [`scuffle_bootstrap`] ecosystem.
324///
325/// This macro will implement the [`scuffle_bootstrap::config::ConfigParser`] trait for the given type.
326/// The generated implementation uses the [`parse_settings`] function to parse the settings.
327///
328/// ## Example
329///
330/// ```rust
331/// #[derive(serde_derive::Deserialize)]
332/// struct MySettings {
333///     key: String,
334/// }
335/// ```
336#[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        // Safety: This is a test and we do not have multiple threads.
481        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        // Safety: This is a test and we do not have multiple threads.
504        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        // Safety: This is a test and we do not have multiple threads.
526        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}