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 && let Some(default_config_file) = options.default_config_file {
278 config = config.add_source(config::File::new(default_config_file, FormatWrapper).required(false));
279 }
280
281 if let Some(env_prefix) = options.env_prefix {
282 config = config.add_source(config::Environment::with_prefix(env_prefix));
283 }
284
285 Ok(config.build()?.try_deserialize()?)
286}
287
288#[doc(hidden)]
289#[cfg(feature = "bootstrap")]
290pub mod macros {
291 pub use {anyhow, scuffle_bootstrap};
292}
293
294#[cfg(feature = "bootstrap")]
308#[macro_export]
309macro_rules! bootstrap {
310 ($ty:ty) => {
311 impl $crate::macros::scuffle_bootstrap::config::ConfigParser for $ty {
312 async fn parse() -> $crate::macros::anyhow::Result<Self> {
313 $crate::macros::anyhow::Context::context(
314 $crate::parse_settings($crate::Options {
315 cli: Some($crate::cli!()),
316 ..::std::default::Default::default()
317 }),
318 "config",
319 )
320 }
321 }
322 };
323}
324
325#[cfg(feature = "docs")]
327#[scuffle_changelog::changelog]
328pub mod changelog {}
329
330#[cfg(test)]
331#[cfg_attr(all(test, coverage_nightly), coverage(off))]
332mod tests {
333 use std::path::PathBuf;
334
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 #[allow(unused)]
348 fn file_path(item: &str) -> PathBuf {
349 if let Some(env) = std::env::var_os("ASSETS_DIR") {
350 PathBuf::from(env).join(item)
351 } else {
352 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!("../../assets/{item}"))
353 }
354 }
355
356 #[test]
357 fn parse_empty() {
358 let err = parse_settings::<TestSettings>(Options::default()).expect_err("expected error");
359 assert!(matches!(err, crate::SettingsError::Config(config::ConfigError::Message(_))));
360 assert_eq!(err.to_string(), "missing field `key`");
361 }
362
363 #[test]
364 #[cfg(feature = "cli")]
365 fn parse_cli() {
366 let options = Options {
367 cli: Some(Cli {
368 name: "test",
369 version: "0.1.0",
370 about: "test",
371 author: "test",
372 argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
373 }),
374 ..Default::default()
375 };
376 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
377
378 assert_eq!(settings.key, "value");
379 }
380
381 #[test]
382 #[cfg(feature = "cli")]
383 fn cli_error() {
384 let options = Options {
385 cli: Some(Cli {
386 name: "test",
387 version: "0.1.0",
388 about: "test",
389 author: "test",
390 argv: vec!["test".to_string(), "-o".to_string(), "error".to_string()],
391 }),
392 ..Default::default()
393 };
394 let err = parse_settings::<TestSettings>(options).expect_err("expected error");
395
396 if let crate::SettingsError::Clap(err) = err {
397 assert_eq!(err.to_string(), "error: Override must be in the format KEY=VALUE");
398 } else {
399 panic!("unexpected error: {err}");
400 }
401 }
402
403 #[test]
404 #[cfg(all(feature = "cli", feature = "toml"))]
405 fn parse_file() {
406 let path = file_path("test.toml");
407 let options = Options {
408 cli: Some(Cli {
409 name: "test",
410 version: "0.1.0",
411 about: "test",
412 author: "test",
413 argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
414 }),
415 ..Default::default()
416 };
417 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
418
419 assert_eq!(settings.key, "filevalue");
420 }
421
422 #[test]
423 #[cfg(feature = "cli")]
424 fn file_error() {
425 let path = file_path("invalid.txt");
426 let options = Options {
427 cli: Some(Cli {
428 name: "test",
429 version: "0.1.0",
430 about: "test",
431 author: "test",
432 argv: vec!["test".to_string(), "-c".to_string(), path.display().to_string()],
433 }),
434 ..Default::default()
435 };
436 let err = parse_settings::<TestSettings>(options).expect_err("expected error");
437
438 if let crate::SettingsError::Config(config::ConfigError::FileParse { uri: Some(uri), cause }) = err {
439 assert!(
440 path.display().to_string().ends_with(&uri),
441 "path ({}) ends with {uri}",
442 path.display()
443 );
444 assert_eq!(
445 cause.to_string(),
446 format!("No supported format found for file: {:?}", Some(uri))
447 );
448 } else {
449 panic!("unexpected error: {err:?}");
450 }
451 }
452
453 #[test]
454 #[cfg(feature = "cli")]
455 fn parse_env() {
456 let options = Options {
457 cli: Some(Cli {
458 name: "test",
459 version: "0.1.0",
460 about: "test",
461 author: "test",
462 argv: vec![],
463 }),
464 env_prefix: Some("SETTINGS_PARSE_ENV_TEST"),
465 ..Default::default()
466 };
467 #[allow(unsafe_code)]
469 unsafe {
470 std::env::set_var("SETTINGS_PARSE_ENV_TEST_KEY", "envvalue");
471 }
472 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
473
474 assert_eq!(settings.key, "envvalue");
475 }
476
477 #[test]
478 #[cfg(feature = "cli")]
479 fn overrides() {
480 let options = Options {
481 cli: Some(Cli {
482 name: "test",
483 version: "0.1.0",
484 about: "test",
485 author: "test",
486 argv: vec!["test".to_string(), "-o".to_string(), "key=value".to_string()],
487 }),
488 env_prefix: Some("SETTINGS_OVERRIDES_TEST"),
489 ..Default::default()
490 };
491 #[allow(unsafe_code)]
493 unsafe {
494 std::env::set_var("SETTINGS_OVERRIDES_TEST_KEY", "envvalue");
495 }
496 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
497
498 assert_eq!(settings.key, "value");
499 }
500
501 #[test]
502 #[cfg(all(feature = "templates", feature = "cli"))]
503 fn templates() {
504 let options = Options {
505 cli: Some(Cli {
506 name: "test",
507 version: "0.1.0",
508 about: "test",
509 author: "test",
510 argv: vec![
511 "test".to_string(),
512 "-c".to_string(),
513 file_path("templates.toml").to_string_lossy().to_string(),
514 ],
515 }),
516 ..Default::default()
517 };
518 #[allow(unsafe_code)]
520 unsafe {
521 std::env::set_var("SETTINGS_TEMPLATES_TEST", "templatevalue");
522 }
523 let settings: TestSettings = parse_settings(options).expect("failed to parse settings");
524
525 assert_eq!(settings.key, "templatevalue");
526 }
527}