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::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::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::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    #[cfg(feature = "cli")]
358    use crate::Cli;
359    use crate::{Options, parse_settings};
360
361    #[derive(Debug, serde::Deserialize)]
362    struct TestSettings {
363        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
364        key: String,
365    }
366
367    #[test]
368    fn parse_empty() {
369        let err = parse_settings::<TestSettings>(Options::default()).expect_err("expected error");
370        assert!(matches!(err, crate::SettingsError::Config(config::ConfigError::Message(_))));
371        assert_eq!(err.to_string(), "missing field `key`");
372    }
373
374    #[test]
375    #[cfg(feature = "cli")]
376    fn parse_cli() {
377        let options = Options {
378            cli: Some(Cli {
379                name: "test",
380                version: "0.1.0",
381                about: "test",
382                author: "test",
383                argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
384            }),
385            ..Default::default()
386        };
387        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
388
389        assert_eq!(settings.key, "value");
390    }
391
392    #[test]
393    #[cfg(feature = "cli")]
394    fn cli_error() {
395        let options = Options {
396            cli: Some(Cli {
397                name: "test",
398                version: "0.1.0",
399                about: "test",
400                author: "test",
401                argv: vec!["test".to_string(), "-o".to_string(), "error".to_string()],
402            }),
403            ..Default::default()
404        };
405        let err = parse_settings::<TestSettings>(options).expect_err("expected error");
406
407        if let crate::SettingsError::Clap(err) = err {
408            assert_eq!(err.to_string(), "error: Override must be in the format KEY=VALUE");
409        } else {
410            panic!("unexpected error: {err}");
411        }
412    }
413
414    #[test]
415    #[cfg(all(feature = "cli", feature = "toml"))]
416    fn parse_file() {
417        use std::path::Path;
418
419        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets").join("test.toml");
420        let options = Options {
421            cli: Some(Cli {
422                name: "test",
423                version: "0.1.0",
424                about: "test",
425                author: "test",
426                argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
427            }),
428            ..Default::default()
429        };
430        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
431
432        assert_eq!(settings.key, "filevalue");
433    }
434
435    #[test]
436    #[cfg(feature = "cli")]
437    fn file_error() {
438        use std::path::Path;
439
440        let path = Path::new("assets").join("invalid.txt");
441        let options = Options {
442            cli: Some(Cli {
443                name: "test",
444                version: "0.1.0",
445                about: "test",
446                author: "test",
447                argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
448            }),
449            ..Default::default()
450        };
451        let err = parse_settings::<TestSettings>(options).expect_err("expected error");
452
453        if let crate::SettingsError::Config(config::ConfigError::FileParse { uri: Some(uri), cause }) = err {
454            assert_eq!(uri, path.display().to_string());
455            assert_eq!(
456                cause.to_string(),
457                format!("No supported format found for file: {:?}", path.to_str())
458            );
459        } else {
460            panic!("unexpected error: {err:?}");
461        }
462    }
463
464    #[test]
465    #[cfg(feature = "cli")]
466    fn parse_env() {
467        let options = Options {
468            cli: Some(Cli {
469                name: "test",
470                version: "0.1.0",
471                about: "test",
472                author: "test",
473                argv: vec![],
474            }),
475            env_prefix: Some("SETTINGS_PARSE_ENV_TEST"),
476            ..Default::default()
477        };
478        // Safety: This is a test and we do not have multiple threads.
479        unsafe {
480            std::env::set_var("SETTINGS_PARSE_ENV_TEST_KEY", "envvalue");
481        }
482        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
483
484        assert_eq!(settings.key, "envvalue");
485    }
486
487    #[test]
488    #[cfg(feature = "cli")]
489    fn overrides() {
490        let options = Options {
491            cli: Some(Cli {
492                name: "test",
493                version: "0.1.0",
494                about: "test",
495                author: "test",
496                argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
497            }),
498            env_prefix: Some("SETTINGS_OVERRIDES_TEST"),
499            ..Default::default()
500        };
501        // Safety: This is a test and we do not have multiple threads.
502        unsafe {
503            std::env::set_var("SETTINGS_OVERRIDES_TEST_KEY", "envvalue");
504        }
505        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
506
507        assert_eq!(settings.key, "value");
508    }
509
510    #[test]
511    #[cfg(all(feature = "templates", feature = "cli"))]
512    fn templates() {
513        let options = Options {
514            cli: Some(Cli {
515                name: "test",
516                version: "0.1.0",
517                about: "test",
518                author: "test",
519                argv: vec!["test".to_string(), "-c".to_string(), "assets/templates.toml".to_string()],
520            }),
521            ..Default::default()
522        };
523        // Safety: This is a test and we do not have multiple threads.
524        unsafe {
525            std::env::set_var("SETTINGS_TEMPLATES_TEST", "templatevalue");
526        }
527        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
528
529        assert_eq!(settings.key, "templatevalue");
530    }
531}