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