diff --git a/Cargo.lock b/Cargo.lock index ece7313..7e53d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,6 +244,7 @@ dependencies = [ "rust_decimal", "secrecy", "serde", + "serde_ini", "serde_json", "serde_with", "signal-hook", @@ -1101,6 +1102,12 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "result" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194d8e591e405d1eecf28819740abed6d719d1a2db87fc0bcdedee9a26d55560" + [[package]] name = "rkyv" version = "0.7.46" @@ -1282,6 +1289,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_ini" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb236687e2bb073a7521c021949be944641e671b8505a94069ca37b656c81139" +dependencies = [ + "result", + "serde", + "void", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -1709,6 +1727,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/confik/Cargo.toml b/confik/Cargo.toml index ebf2d2e..6d079fd 100644 --- a/confik/Cargo.toml +++ b/confik/Cargo.toml @@ -27,6 +27,7 @@ default = ["env", "toml"] env = ["dep:envious"] json = ["dep:serde_json"] ron-0_12 = ["dep:ron-0_12"] +serde_ini-0_2 = ["dep:serde_ini-0_2"] toml = ["dep:toml"] yaml_serde-0_10 = ["dep:yaml_serde-0_10"] @@ -63,6 +64,7 @@ thiserror = "2" # Source types envious = { version = "0.2", optional = true } ron-0_12 = { package = "ron", version = "0.12", optional = true } +serde_ini-0_2 = { package = "serde_ini", version = "0.2", optional = true } serde_json = { version = "1", optional = true } toml = { version = "1", optional = true, default-features = false, features = ["parse", "serde"] } yaml_serde-0_10 = { package = "yaml_serde", version = "0.10", optional = true } diff --git a/confik/src/lib.md b/confik/src/lib.md index 8bc24cc..8f1e8f6 100644 --- a/confik/src/lib.md +++ b/confik/src/lib.md @@ -123,11 +123,12 @@ When the `tracing` feature is enabled, reload errors in the signal handler will A [`Source`] is any type that can create [`ConfigurationBuilder`]s. This crate implements the following sources: - [`EnvSource`]: Loads configuration from environment variables using the [`envious`] crate. Requires the `env` feature. (Enabled by default.) -- [`FileSource`]: Loads configuration from a file, detecting `.toml`, `.json`, `.ron`, `.yaml`, or `.yml` files based on the file extension. Requires the matching `toml`, `json`, `ron-0_12`, or `yaml_serde-0_10` feature. (`toml` is enabled by default.) +- [`FileSource`]: Loads configuration from a file, detecting `.toml`, `.json`, `.ron`, `.yaml`, `.yml`, or `.ini` files based on the file extension. Requires the matching `toml`, `json`, `ron-0_12`, `yaml_serde-0_10`, or `serde_ini-0_2` feature. (`toml` is enabled by default.) - [`TomlSource`]: Loads configuration from a TOML string literal. Requires the `toml` feature. (Enabled by default.) - [`JsonSource`]: Loads configuration from a JSON string literal. Requires the `json` feature. - [`RonSource`]: Loads configuration from a RON string literal. Requires the `ron-0_12` feature. - [`YamlSource`]: Loads configuration from a YAML string literal. Requires the `yaml_serde-0_10` feature. +- [`IniSource`]: Loads configuration from an INI string literal. Requires the `serde_ini-0_2` feature. - [`OffsetSource`]: Loads configuration from an inner source that is provided to it, but applied to a particular offset of the root configuration builder. ## Secrets diff --git a/confik/src/lib.rs b/confik/src/lib.rs index f2ef195..333be92 100644 --- a/confik/src/lib.rs +++ b/confik/src/lib.rs @@ -42,6 +42,8 @@ use self::path::Path; pub use self::reloading::{ReloadCallback, ReloadableConfig, ReloadingConfig}; #[cfg(feature = "env")] pub use self::sources::env_source::EnvSource; +#[cfg(feature = "serde_ini-0_2")] +pub use self::sources::ini_source::IniSource; #[cfg(feature = "json")] pub use self::sources::json_source::JsonSource; #[cfg(feature = "ron-0_12")] diff --git a/confik/src/sources/file_source.rs b/confik/src/sources/file_source.rs index 2354be4..baedf32 100644 --- a/confik/src/sources/file_source.rs +++ b/confik/src/sources/file_source.rs @@ -35,6 +35,10 @@ enum FileErrorKind { #[error(transparent)] Json(#[from] serde_json::Error), + #[cfg(feature = "serde_ini-0_2")] + #[error(transparent)] + Ini(#[from] serde_ini_0_2::error::Error), + #[cfg(feature = "ron-0_12")] #[error(transparent)] Ron(#[from] ron_0_12::error::SpannedError), @@ -57,6 +61,7 @@ impl FileSource { /// The deserialization method will be determined by the file extension. /// /// Supported extensions: + /// - `ini` /// - `toml` /// - `json` /// - `ron` @@ -100,6 +105,16 @@ impl FileSource { } } + Some("ini") => { + cfg_if! { + if #[cfg(feature = "serde_ini-0_2")] { + Ok(serde_ini_0_2::from_str(&contents).map_err(serde_ini_0_2::error::Error::from)?) + } else { + Err(FileErrorKind::MissingFeatureForExtension("ini")) + } + } + } + Some("ron") => { cfg_if! { if #[cfg(feature = "ron-0_12")] { @@ -207,6 +222,29 @@ mod tests { dir.close().unwrap(); } + #[cfg(feature = "serde_ini-0_2")] + #[test] + fn ini() { + let dir = tempfile::TempDir::new().unwrap(); + + let ini_path = dir.path().join("config.ini"); + + fs::write(&ini_path, "").unwrap(); + let source = FileSource::new(&ini_path); + let err = source.deserialize::>().unwrap_err(); + assert!( + err.to_string().contains("missing field"), + "unexpected error message: {err}", + ); + + fs::write(&ini_path, "foo = 42\n").unwrap(); + let source = FileSource::new(&ini_path); + let config = source.deserialize::>().unwrap(); + assert_eq!(config.unwrap().foo, 42); + + dir.close().unwrap(); + } + #[cfg(feature = "toml")] #[test] fn toml() { diff --git a/confik/src/sources/ini_source.rs b/confik/src/sources/ini_source.rs new file mode 100644 index 0000000..1930fa0 --- /dev/null +++ b/confik/src/sources/ini_source.rs @@ -0,0 +1,95 @@ +use std::{borrow::Cow, error::Error, fmt}; + +use crate::{ConfigurationBuilder, Source}; + +/// A [`Source`] containing raw INI data. +#[derive(Clone)] +pub struct IniSource<'a> { + contents: Cow<'a, str>, + allow_secrets: bool, +} + +impl<'a> IniSource<'a> { + /// Creates a [`Source`] containing raw INI data. + pub fn new(contents: impl Into>) -> Self { + Self { + contents: contents.into(), + allow_secrets: false, + } + } + + /// Allows this source to contain secrets. + pub fn allow_secrets(mut self) -> Self { + self.allow_secrets = true; + self + } +} + +impl Source for IniSource<'_> { + fn allows_secrets(&self) -> bool { + self.allow_secrets + } + + fn provide(&self) -> Result> { + Ok(serde_ini_0_2::from_str(&self.contents)?) + } +} + +impl fmt::Debug for IniSource<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IniSource") + .field("allow_secrets", &self.allow_secrets) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use confik_macros::Configuration; + + use super::*; + + #[derive(Debug, PartialEq, Eq, serde::Deserialize, Configuration)] + struct TestConfig { + value: usize, + } + + #[test] + fn provides_ini_data() { + let source = IniSource::new("value = 42\n"); + + let config = + as Source<::Builder>>::provide( + &source, + ) + .unwrap() + .try_build() + .unwrap(); + + assert_eq!(config, TestConfig { value: 42 }); + } + + #[test] + fn propagates_parse_errors() { + let source = IniSource::new("value\n"); + + let err = + match as Source<::Builder>>::provide( + &source, + ) { + Ok(_) => panic!("INI parsing should fail"), + Err(err) => err, + }; + + assert!(!err.to_string().is_empty()); + } + + #[test] + fn allow_secrets_enables_secret_loading() { + let source = IniSource::new("value = 42\n").allow_secrets(); + + assert!( as Source< + ::Builder, + >>::allows_secrets(&source)); + } +} diff --git a/confik/src/sources/mod.rs b/confik/src/sources/mod.rs index ccc2509..a2ade77 100644 --- a/confik/src/sources/mod.rs +++ b/confik/src/sources/mod.rs @@ -43,6 +43,9 @@ pub(crate) mod json_source; #[cfg(feature = "ron-0_12")] pub(crate) mod ron_source; +#[cfg(feature = "serde_ini-0_2")] +pub(crate) mod ini_source; + #[cfg(feature = "env")] pub(crate) mod env_source; diff --git a/confik/tests/main.rs b/confik/tests/main.rs index 3522d50..90872f5 100644 --- a/confik/tests/main.rs +++ b/confik/tests/main.rs @@ -58,6 +58,27 @@ mod json { } } +#[cfg(feature = "serde_ini-0_2")] +mod ini { + use confik::{ConfigBuilder, IniSource}; + + use crate::{Target, TargetEnum}; + + #[test] + fn check_ini() { + assert_eq!( + ConfigBuilder::::default() + .override_with(IniSource::new("a = 5\nb = Second\n")) + .try_build() + .expect("INI deserialization should succeed"), + Target { + a: 5, + b: TargetEnum::Second, + } + ); + } +} + #[cfg(feature = "ron-0_12")] mod ron { use confik::{ConfigBuilder, RonSource}; diff --git a/confik/tests/secret/mod.rs b/confik/tests/secret/mod.rs index 374581d..e6a9823 100644 --- a/confik/tests/secret/mod.rs +++ b/confik/tests/secret/mod.rs @@ -67,6 +67,27 @@ mod json { } } +#[cfg(feature = "serde_ini-0_2")] +mod ini { + use assert_matches::assert_matches; + use confik::{ConfigBuilder, Error, IniSource}; + + use super::NotSecret; + + #[test] + fn check_ini_is_not_secret() { + let target = ConfigBuilder::::default() + .override_with(IniSource::new("[public]\npublic = 1\nsecret = 2\n")) + .try_build() + .expect_err("INI deserialization is not a secret source"); + + assert_matches!( + &target, + Error::UnexpectedSecret(path, _) if path.to_string().contains("public.secret") + ); + } +} + #[cfg(feature = "ron-0_12")] mod ron { use assert_matches::assert_matches;