1#![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#![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#[derive(Debug, thiserror::Error)]
211pub enum SettingsError {
212 #[error(transparent)]
214 Config(#[from] config::ConfigError),
215 #[cfg(feature = "cli")]
217 #[error(transparent)]
218 Clap(#[from] clap::Error),
219}
220
221pub 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#[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#[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 #[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 #[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 #[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}