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#![deny(clippy::mod_module_files)]
106
107use std::borrow::Cow;
108use std::path::Path;
109
110use config::FileStoredFormat;
111
112mod options;
113
114pub use options::*;
115
116#[derive(Debug, Clone, Copy)]
117struct FormatWrapper;
118
119#[cfg(not(feature = "templates"))]
120fn template_text<'a>(
121    text: &'a str,
122    _: &config::FileFormat,
123) -> Result<Cow<'a, str>, Box<dyn std::error::Error + Send + Sync>> {
124    Ok(Cow::Borrowed(text))
125}
126
127#[cfg(feature = "templates")]
128fn template_text<'a>(
129    text: &'a str,
130    _: &config::FileFormat,
131) -> Result<Cow<'a, str>, Box<dyn std::error::Error + Send + Sync>> {
132    use minijinja::syntax::SyntaxConfig;
133
134    let mut env = minijinja::Environment::new();
135
136    env.add_global("env", std::env::vars().collect::<std::collections::HashMap<_, _>>());
137    env.set_syntax(
138        SyntaxConfig::builder()
139            .block_delimiters("{%", "%}")
140            .variable_delimiters("${{", "}}")
141            .comment_delimiters("{#", "#}")
142            .build()
143            .unwrap(),
144    );
145
146    Ok(Cow::Owned(env.template_from_str(text).unwrap().render(())?))
147}
148
149impl config::Format for FormatWrapper {
150    fn parse(
151        &self,
152        uri: Option<&String>,
153        text: &str,
154    ) -> Result<config::Map<String, config::Value>, Box<dyn std::error::Error + Send + Sync>> {
155        let uri_ext = uri.and_then(|s| Path::new(s.as_str()).extension()).and_then(|s| s.to_str());
156
157        let mut formats: Vec<config::FileFormat> = vec![
158            #[cfg(feature = "toml")]
159            config::FileFormat::Toml,
160            #[cfg(feature = "json")]
161            config::FileFormat::Json,
162            #[cfg(feature = "yaml")]
163            config::FileFormat::Yaml,
164            #[cfg(feature = "json5")]
165            config::FileFormat::Json5,
166            #[cfg(feature = "ini")]
167            config::FileFormat::Ini,
168            #[cfg(feature = "ron")]
169            config::FileFormat::Ron,
170        ];
171
172        if let Some(uri_ext) = uri_ext {
173            formats.sort_by_key(|f| if f.file_extensions().contains(&uri_ext) { 0 } else { 1 });
174        }
175
176        for format in formats {
177            if let Ok(map) = format.parse(uri, template_text(text, &format)?.as_ref()) {
178                return Ok(map);
179            }
180        }
181
182        Err(Box::new(std::io::Error::new(
183            std::io::ErrorKind::InvalidData,
184            format!("No supported format found for file: {uri:?}"),
185        )))
186    }
187}
188
189impl config::FileStoredFormat for FormatWrapper {
190    fn file_extensions(&self) -> &'static [&'static str] {
191        &[
192            #[cfg(feature = "toml")]
193            "toml",
194            #[cfg(feature = "json")]
195            "json",
196            #[cfg(feature = "yaml")]
197            "yaml",
198            #[cfg(feature = "yaml")]
199            "yml",
200            #[cfg(feature = "json5")]
201            "json5",
202            #[cfg(feature = "ini")]
203            "ini",
204            #[cfg(feature = "ron")]
205            "ron",
206        ]
207    }
208}
209
210/// An error that can occur when parsing settings.
211#[derive(Debug, thiserror::Error)]
212pub enum SettingsError {
213    /// An error occurred while parsing the settings.
214    #[error(transparent)]
215    Config(#[from] config::ConfigError),
216    /// An error occurred while parsing the CLI arguments.
217    #[cfg(feature = "cli")]
218    #[error(transparent)]
219    Clap(#[from] clap::Error),
220}
221
222/// Parse settings using the given options.
223///
224/// Refer to the [`Options`] struct for more information on how to customize parsing.
225pub fn parse_settings<T: serde::de::DeserializeOwned>(options: Options) -> Result<T, SettingsError> {
226    let mut config = config::Config::builder();
227
228    #[allow(unused_mut)]
229    let mut added_files = false;
230
231    #[cfg(feature = "cli")]
232    if let Some(cli) = options.cli {
233        let command = clap::Command::new(cli.name)
234            .version(cli.version)
235            .about(cli.about)
236            .author(cli.author)
237            .bin_name(cli.name)
238            .arg(
239                clap::Arg::new("config")
240                    .short('c')
241                    .long("config")
242                    .value_name("FILE")
243                    .help("Path to configuration file(s)")
244                    .action(clap::ArgAction::Append),
245            )
246            .arg(
247                clap::Arg::new("overrides")
248                    .long("override")
249                    .short('o')
250                    .alias("set")
251                    .help("Provide an override for a configuration value, in the format KEY=VALUE")
252                    .action(clap::ArgAction::Append),
253            );
254
255        let matches = command.get_matches_from(cli.argv);
256
257        if let Some(config_files) = matches.get_many::<String>("config") {
258            for path in config_files {
259                config = config.add_source(config::File::new(path, FormatWrapper));
260                added_files = true;
261            }
262        }
263
264        if let Some(overrides) = matches.get_many::<String>("overrides") {
265            for ov in overrides {
266                let (key, value) = ov.split_once('=').ok_or_else(|| {
267                    clap::Error::raw(
268                        clap::error::ErrorKind::InvalidValue,
269                        "Override must be in the format KEY=VALUE",
270                    )
271                })?;
272
273                config = config.set_override(key, value)?;
274            }
275        }
276    }
277
278    if !added_files && 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    if let Some(env_prefix) = options.env_prefix {
283        config = config.add_source(config::Environment::with_prefix(env_prefix));
284    }
285
286    Ok(config.build()?.try_deserialize()?)
287}
288
289#[doc(hidden)]
290#[cfg(feature = "bootstrap")]
291pub mod macros {
292    pub use {anyhow, scuffle_bootstrap};
293}
294
295/// This macro can be used to integrate with the [`scuffle_bootstrap`] ecosystem.
296///
297/// This macro will implement the [`scuffle_bootstrap::config::ConfigParser`] trait for the given type.
298/// The generated implementation uses the [`parse_settings`] function to parse the settings.
299///
300/// ## Example
301///
302/// ```rust
303/// #[derive(serde_derive::Deserialize)]
304/// struct MySettings {
305///     key: String,
306/// }
307/// ```
308#[cfg(feature = "bootstrap")]
309#[macro_export]
310macro_rules! bootstrap {
311    ($ty:ty) => {
312        impl $crate::macros::scuffle_bootstrap::config::ConfigParser for $ty {
313            async fn parse() -> $crate::macros::anyhow::Result<Self> {
314                $crate::macros::anyhow::Context::context(
315                    $crate::parse_settings($crate::Options {
316                        cli: Some($crate::cli!()),
317                        ..::std::default::Default::default()
318                    }),
319                    "config",
320                )
321            }
322        }
323    };
324}
325
326/// Changelogs generated by [scuffle_changelog]
327#[cfg(feature = "docs")]
328#[scuffle_changelog::changelog]
329pub mod changelog {}
330
331#[cfg(test)]
332#[cfg_attr(all(test, coverage_nightly), coverage(off))]
333mod tests {
334    use std::path::PathBuf;
335
336    use serde_derive::Deserialize;
337
338    #[cfg(feature = "cli")]
339    use crate::Cli;
340    use crate::{Options, parse_settings};
341
342    #[derive(Debug, Deserialize)]
343    struct TestSettings {
344        #[cfg_attr(not(feature = "cli"), allow(dead_code))]
345        key: String,
346    }
347
348    #[allow(unused)]
349    fn file_path(item: &str) -> PathBuf {
350        if let Some(env) = std::env::var_os("ASSETS_DIR") {
351            PathBuf::from(env).join(item)
352        } else {
353            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!("../../assets/{item}"))
354        }
355    }
356
357    #[test]
358    fn parse_empty() {
359        let err = parse_settings::<TestSettings>(Options::default()).expect_err("expected error");
360        assert!(matches!(err, crate::SettingsError::Config(config::ConfigError::Message(_))));
361        assert_eq!(err.to_string(), "missing field `key`");
362    }
363
364    #[test]
365    #[cfg(feature = "cli")]
366    fn parse_cli() {
367        let options = Options {
368            cli: Some(Cli {
369                name: "test",
370                version: "0.1.0",
371                about: "test",
372                author: "test",
373                argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
374            }),
375            ..Default::default()
376        };
377        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
378
379        assert_eq!(settings.key, "value");
380    }
381
382    #[test]
383    #[cfg(feature = "cli")]
384    fn cli_error() {
385        let options = Options {
386            cli: Some(Cli {
387                name: "test",
388                version: "0.1.0",
389                about: "test",
390                author: "test",
391                argv: vec!["test".to_string(), "-o".to_string(), "error".to_string()],
392            }),
393            ..Default::default()
394        };
395        let err = parse_settings::<TestSettings>(options).expect_err("expected error");
396
397        if let crate::SettingsError::Clap(err) = err {
398            assert_eq!(err.to_string(), "error: Override must be in the format KEY=VALUE");
399        } else {
400            panic!("unexpected error: {err}");
401        }
402    }
403
404    #[test]
405    #[cfg(all(feature = "cli", feature = "toml"))]
406    fn parse_file() {
407        let path = file_path("test.toml");
408        let options = Options {
409            cli: Some(Cli {
410                name: "test",
411                version: "0.1.0",
412                about: "test",
413                author: "test",
414                argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
415            }),
416            ..Default::default()
417        };
418        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
419
420        assert_eq!(settings.key, "filevalue");
421    }
422
423    #[test]
424    #[cfg(feature = "cli")]
425    fn file_error() {
426        let path = file_path("invalid.txt");
427        let options = Options {
428            cli: Some(Cli {
429                name: "test",
430                version: "0.1.0",
431                about: "test",
432                author: "test",
433                argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
434            }),
435            ..Default::default()
436        };
437        let err = parse_settings::<TestSettings>(options).expect_err("expected error");
438
439        if let crate::SettingsError::Config(config::ConfigError::FileParse { uri: Some(uri), cause }) = err {
440            assert!(
441                path.display().to_string().ends_with(&uri),
442                "path ({}) ends with {uri}",
443                path.display()
444            );
445            assert_eq!(
446                cause.to_string(),
447                format!("No supported format found for file: {:?}", Some(uri))
448            );
449        } else {
450            panic!("unexpected error: {err:?}");
451        }
452    }
453
454    #[test]
455    #[cfg(feature = "cli")]
456    fn parse_env() {
457        let options = Options {
458            cli: Some(Cli {
459                name: "test",
460                version: "0.1.0",
461                about: "test",
462                author: "test",
463                argv: vec![],
464            }),
465            env_prefix: Some("SETTINGS_PARSE_ENV_TEST"),
466            ..Default::default()
467        };
468        // Safety: This is a test and we do not have multiple threads.
469        #[allow(unsafe_code)]
470        unsafe {
471            std::env::set_var("SETTINGS_PARSE_ENV_TEST_KEY", "envvalue");
472        }
473        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
474
475        assert_eq!(settings.key, "envvalue");
476    }
477
478    #[test]
479    #[cfg(feature = "cli")]
480    fn overrides() {
481        let options = Options {
482            cli: Some(Cli {
483                name: "test",
484                version: "0.1.0",
485                about: "test",
486                author: "test",
487                argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
488            }),
489            env_prefix: Some("SETTINGS_OVERRIDES_TEST"),
490            ..Default::default()
491        };
492        // Safety: This is a test and we do not have multiple threads.
493        #[allow(unsafe_code)]
494        unsafe {
495            std::env::set_var("SETTINGS_OVERRIDES_TEST_KEY", "envvalue");
496        }
497        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
498
499        assert_eq!(settings.key, "value");
500    }
501
502    #[test]
503    #[cfg(all(feature = "templates", feature = "cli"))]
504    fn templates() {
505        let options = Options {
506            cli: Some(Cli {
507                name: "test",
508                version: "0.1.0",
509                about: "test",
510                author: "test",
511                argv: vec![
512                    "test".to_string(),
513                    "-c".to_string(),
514                    file_path("templates.toml").to_string_lossy().to_string(),
515                ],
516            }),
517            ..Default::default()
518        };
519        // Safety: This is a test and we do not have multiple threads.
520        #[allow(unsafe_code)]
521        unsafe {
522            std::env::set_var("SETTINGS_TEMPLATES_TEST", "templatevalue");
523        }
524        let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
525
526        assert_eq!(settings.key, "templatevalue");
527    }
528}