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 {
278        if let Some(default_config_file) = options.default_config_file {
279            config = config.add_source(config::File::new(default_config_file, FormatWrapper).required(false));
280        }
281    }
282
283    if let Some(env_prefix) = options.env_prefix {
284        config = config.add_source(config::Environment::with_prefix(env_prefix));
285    }
286
287    Ok(config.build()?.try_deserialize()?)
288}
289
290#[doc(hidden)]
291#[cfg(feature = "bootstrap")]
292pub mod macros {
293    pub use {anyhow, scuffle_bootstrap};
294}
295
296/// This macro can be used to integrate with the [`scuffle_bootstrap`] ecosystem.
297///
298/// This macro will implement the [`scuffle_bootstrap::config::ConfigParser`] trait for the given type.
299/// The generated implementation uses the [`parse_settings`] function to parse the settings.
300///
301/// ## Example
302///
303/// ```rust
304/// #[derive(serde_derive::Deserialize)]
305/// struct MySettings {
306///     key: String,
307/// }
308/// ```
309#[cfg(feature = "bootstrap")]
310#[macro_export]
311macro_rules! bootstrap {
312    ($ty:ty) => {
313        impl $crate::macros::scuffle_bootstrap::config::ConfigParser for $ty {
314            async fn parse() -> $crate::macros::anyhow::Result<Self> {
315                $crate::macros::anyhow::Context::context(
316                    $crate::parse_settings($crate::Options {
317                        cli: Some($crate::cli!()),
318                        ..::std::default::Default::default()
319                    }),
320                    "config",
321                )
322            }
323        }
324    };
325}
326
327/// Changelogs generated by [scuffle_changelog]
328#[cfg(feature = "docs")]
329#[scuffle_changelog::changelog]
330pub mod changelog {}
331
332#[cfg(test)]
333#[cfg_attr(all(test, coverage_nightly), coverage(off))]
334mod tests {
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    #[test]
348    fn parse_empty() {
349        let err = parse_settings::<TestSettings>(Options::default()).expect_err("expected error");
350        assert!(matches!(err, crate::SettingsError::Config(config::ConfigError::Message(_))));
351        assert_eq!(err.to_string(), "missing field `key`");
352    }
353
354    #[test]
355    #[cfg(feature = "cli")]
356    fn parse_cli() {
357        let options = Options {
358            cli: Some(Cli {
359                name: "test",
360                version: "0.1.0",
361                about: "test",
362                author: "test",
363                argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
364            }),
365            ..Default::default()
366        };
367        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
368
369        assert_eq!(settings.key, "value");
370    }
371
372    #[test]
373    #[cfg(feature = "cli")]
374    fn cli_error() {
375        let options = Options {
376            cli: Some(Cli {
377                name: "test",
378                version: "0.1.0",
379                about: "test",
380                author: "test",
381                argv: vec!["test".to_string(), "-o".to_string(), "error".to_string()],
382            }),
383            ..Default::default()
384        };
385        let err = parse_settings::<TestSettings>(options).expect_err("expected error");
386
387        if let crate::SettingsError::Clap(err) = err {
388            assert_eq!(err.to_string(), "error: Override must be in the format KEY=VALUE");
389        } else {
390            panic!("unexpected error: {err}");
391        }
392    }
393
394    #[test]
395    #[cfg(all(feature = "cli", feature = "toml"))]
396    fn parse_file() {
397        use std::path::Path;
398
399        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets").join("test.toml");
400        let options = Options {
401            cli: Some(Cli {
402                name: "test",
403                version: "0.1.0",
404                about: "test",
405                author: "test",
406                argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
407            }),
408            ..Default::default()
409        };
410        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
411
412        assert_eq!(settings.key, "filevalue");
413    }
414
415    #[test]
416    #[cfg(feature = "cli")]
417    fn file_error() {
418        use std::path::Path;
419
420        let path = Path::new("assets").join("invalid.txt");
421        let options = Options {
422            cli: Some(Cli {
423                name: "test",
424                version: "0.1.0",
425                about: "test",
426                author: "test",
427                argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
428            }),
429            ..Default::default()
430        };
431        let err = parse_settings::<TestSettings>(options).expect_err("expected error");
432
433        if let crate::SettingsError::Config(config::ConfigError::FileParse { uri: Some(uri), cause }) = err {
434            assert_eq!(uri, path.display().to_string());
435            assert_eq!(
436                cause.to_string(),
437                format!("No supported format found for file: {:?}", path.to_str())
438            );
439        } else {
440            panic!("unexpected error: {err:?}");
441        }
442    }
443
444    #[test]
445    #[cfg(feature = "cli")]
446    fn parse_env() {
447        let options = Options {
448            cli: Some(Cli {
449                name: "test",
450                version: "0.1.0",
451                about: "test",
452                author: "test",
453                argv: vec![],
454            }),
455            env_prefix: Some("SETTINGS_PARSE_ENV_TEST"),
456            ..Default::default()
457        };
458        // Safety: This is a test and we do not have multiple threads.
459        #[allow(unsafe_code)]
460        unsafe {
461            std::env::set_var("SETTINGS_PARSE_ENV_TEST_KEY", "envvalue");
462        }
463        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
464
465        assert_eq!(settings.key, "envvalue");
466    }
467
468    #[test]
469    #[cfg(feature = "cli")]
470    fn overrides() {
471        let options = Options {
472            cli: Some(Cli {
473                name: "test",
474                version: "0.1.0",
475                about: "test",
476                author: "test",
477                argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
478            }),
479            env_prefix: Some("SETTINGS_OVERRIDES_TEST"),
480            ..Default::default()
481        };
482        // Safety: This is a test and we do not have multiple threads.
483        #[allow(unsafe_code)]
484        unsafe {
485            std::env::set_var("SETTINGS_OVERRIDES_TEST_KEY", "envvalue");
486        }
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        // Safety: This is a test and we do not have multiple threads.
506        #[allow(unsafe_code)]
507        unsafe {
508            std::env::set_var("SETTINGS_TEMPLATES_TEST", "templatevalue");
509        }
510        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
511
512        assert_eq!(settings.key, "templatevalue");
513    }
514}