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//! # 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
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/// An error that can occur when parsing settings.
233#[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
242/// Parse settings using the given options.
243///
244/// Refer to the [`Options`] struct for more information on how to customize parsing.
245pub 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/// This macro can be used to integrate with the [`scuffle_bootstrap`] ecosystem.
318///
319/// This macro will implement the [`scuffle_bootstrap::config::ConfigParser`] trait for the given type.
320/// The generated implementation uses the [`parse_settings`] function to parse the settings.
321///
322/// ## Example
323///
324/// ```rust
325/// #[derive(serde::Deserialize)]
326/// struct MySettings {
327///     key: String,
328/// }
329/// ```
330#[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}