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