diff --git a/Cargo.toml b/Cargo.toml index d577996..afa78d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,16 +5,21 @@ authors = ["Lukas Kalbertodt "] edition = "2018" -[dependencies] -confique-macro = { path = "macro" } -serde = { version = "1", features = ["derive"] } -log = { version = "0.4", features = ["serde"], optional = true } - -[dev-dependencies] -log = { version = "0.4", features = ["serde"] } - - [features] +default = ["toml", "yaml"] +yaml = ["serde_yaml"] + # This is used to enable the `example` module. This is only useful to generate # the docs for this library! doc-example = ["log"] + +[dependencies] +confique-macro = { path = "macro" } +log = { version = "0.4", features = ["serde"], optional = true } +serde = { version = "1", features = ["derive"] } +serde_yaml = { version = "0.8", optional = true } +toml = { version = "0.5", optional = true } + +[dev-dependencies] +log = { version = "0.4", features = ["serde"] } +anyhow = "1" diff --git a/examples/files/etc/simple.yaml b/examples/files/etc/simple.yaml new file mode 100644 index 0000000..c3e5474 --- /dev/null +++ b/examples/files/etc/simple.yaml @@ -0,0 +1,2 @@ +http: + port: 4321 diff --git a/examples/files/simple.toml b/examples/files/simple.toml new file mode 100644 index 0000000..a3e5e4f --- /dev/null +++ b/examples/files/simple.toml @@ -0,0 +1,2 @@ +[cat] +foo = "yes" diff --git a/examples/simple.rs b/examples/simple.rs index 89e2d3e..15a1eba 100644 --- a/examples/simple.rs +++ b/examples/simple.rs @@ -1,5 +1,5 @@ -use std::net::IpAddr; -use confique::{Config, Partial}; +use std::{net::IpAddr, path::Path}; +use confique::Config; #[derive(Debug, Config)] struct Conf { @@ -26,6 +26,13 @@ struct Cat { } -fn main() { - println!("{:#?}", Conf::from_partial(::Partial::default_values())); +fn main() -> Result<(), anyhow::Error> { + let r = Conf::from_sources(&[ + &Path::new("examples/files/simple.toml"), + &Path::new("examples/files/etc/simple.yaml"), + ])?; + + println!("{:#?}", r); + + Ok(()) } diff --git a/macro/src/gen.rs b/macro/src/gen.rs index 12af628..7e710ca 100644 --- a/macro/src/gen.rs +++ b/macro/src/gen.rs @@ -26,17 +26,12 @@ fn gen_config_impl(input: &ir::Input) -> TokenStream { if !f.is_leaf() { quote! { confique::Config::from_partial(partial.#field_name).map_err(|e| { - match e { - confique::Error::MissingValue(path) => { - confique::Error::MissingValue(format!("{}.{}", #path, path)) - } - e => e, - } + confique::internal::prepend_missing_value_error(e, #path) })? } } else if unwrap_option(&f.ty).is_none() { quote! { - partial.#field_name.ok_or(confique::Error::MissingValue(#path.into()))? + partial.#field_name.ok_or(confique::internal::missing_value_error(#path.into()))? } } else { quote! { partial.#field_name } diff --git a/src/internal.rs b/src/internal.rs index 175523d..734810e 100644 --- a/src/internal.rs +++ b/src/internal.rs @@ -2,6 +2,8 @@ //! intended to be used directly. None of this is covered by semver! Do not use //! any of this directly. +use crate::{Error, ErrorInner}; + pub fn deserialize_default(src: I) -> Result where I: for<'de> serde::de::IntoDeserializer<'de>, @@ -9,3 +11,16 @@ where { O::deserialize(src.into_deserializer()) } + +pub fn missing_value_error(path: String) -> Error { + ErrorInner::MissingValue(path).into() +} + +pub fn prepend_missing_value_error(e: Error, prefix: &str) -> Error { + match *e.inner { + ErrorInner::MissingValue(path) => { + ErrorInner::MissingValue(format!("{}.{}", prefix, path)).into() + } + e => e.into(), + } +} diff --git a/src/lib.rs b/src/lib.rs index eab0ce2..6684c94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::{ffi::OsString, fmt, path::PathBuf}; use serde::Deserialize; @@ -13,11 +13,23 @@ pub use confique_macro::Config; #[doc(hidden)] pub mod internal; +pub mod source; + pub trait Config: Sized { type Partial: Partial; fn from_partial(partial: Self::Partial) -> Result; + + fn from_sources(sources: &[&dyn Source]) -> Result { + let mut partial = Self::Partial::default_values(); + for src in sources.iter().rev() { + let layer = src.load()?; + partial = layer.with_fallback(partial); + } + + Self::from_partial(partial) + } } pub trait Partial: for<'de> Deserialize<'de> { @@ -26,22 +38,99 @@ pub trait Partial: for<'de> Deserialize<'de> { fn with_fallback(self, fallback: Self) -> Self; } +/// A source of configuration values for the configuration `T`, e.g. a file or +/// environment variables. +pub trait Source { + fn load(&self) -> Result; +} -pub enum Error { +pub struct Error { + inner: Box, +} + +enum ErrorInner { /// Returned by `Config::from_partial` when the partial does not contain /// values for all required configuration values. The string is a /// human-readable path to the value, e.g. `http.port`. MissingValue(String), + + /// An IO error occured, e.g. when reading a file. + Io { + path: Option, + err: std::io::Error, + }, + + /// Returned by `Source::load` implementations when deserialization fails. + Deserialization { + /// A human readable description for the error message, describing from + /// what source it was attempted to deserialize. Completes the sentence + /// "failed to deserialize configuration from ". E.g. "file 'foo.toml'" + /// or "environment variable 'FOO_PORT'". + source: Option, + err: Box, + }, + + /// Returned by the [`Source`] impls for `Path` and `PathBuf` if the file + /// extension is not supported by confique or if the corresponding Cargo + /// feature of confique was not enabled. + UnsupportedFileFormat { + path: PathBuf, + extension: OsString, + }, + + /// Returned by the [`Source`] impls for `Path` and `PathBuf` if the path + /// does not contain a file extension. + MissingFileExtension { + path: PathBuf, + } } -impl std::error::Error for Error {} +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &*self.inner { + ErrorInner::Io { err, .. } => Some(err), + ErrorInner::Deserialization { err, .. } => Some(&**err), + ErrorInner::MissingValue(_) + | ErrorInner::UnsupportedFileFormat { .. } + | ErrorInner::MissingFileExtension { .. } => None, + } + } +} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Self::MissingValue(path) => { + match &*self.inner { + ErrorInner::MissingValue(path) => { std::write!(f, "required configuration value is missing: '{}'", path) } + ErrorInner::Io { path: Some(path), .. } => { + std::write!(f, + "IO error occured while reading configuration file '{}'", + path.display(), + ) + } + ErrorInner::Io { path: None, .. } => { + std::write!(f, "IO error occured while loading configuration") + } + ErrorInner::Deserialization { source: Some(source), .. } => { + std::write!(f, "failed to deserialize configuration from {}", source) + } + ErrorInner::Deserialization { source: None, .. } => { + std::write!(f, "failed to deserialize configuration") + } + ErrorInner::UnsupportedFileFormat { path, extension } => { + std::write!(f, + "unknown configuration file format '{}' of '{}'", + extension.to_string_lossy(), + path.display(), + ) + } + ErrorInner::MissingFileExtension { path } => { + std::write!(f, + "cannot guess configuration file format due to missing file extension in '{}'", + path.display(), + ) + } } } } @@ -51,3 +140,9 @@ impl fmt::Debug for Error { fmt::Display::fmt(self, f) } } + +impl From for Error { + fn from(inner: ErrorInner) -> Self { + Self { inner: Box::new(inner) } + } +} diff --git a/src/source.rs b/src/source.rs new file mode 100644 index 0000000..348b8de --- /dev/null +++ b/src/source.rs @@ -0,0 +1,122 @@ +//! Types implementing [`Source`], thus representing some source of +//! configuration values. + +use std::{ffi::OsStr, fs, io, path::{Path, PathBuf}}; + +use crate::{Config, Error, ErrorInner, Partial, Source}; + + + +impl Source for &Path { + fn load(&self) -> Result { + let ext = self.extension().ok_or_else(|| { + ErrorInner::MissingFileExtension { path: self.into() } + })?; + let format = FileFormat::from_extension(ext).ok_or_else(|| { + ErrorInner::UnsupportedFileFormat { extension: ext.into(), path: self.into() } + })?; + + >::load(&File::new(self, format)) + } +} + +impl Source for PathBuf { + fn load(&self) -> Result { + <&Path as Source>::load(&&**self) + } +} + +pub struct File { + path: PathBuf, + format: FileFormat, + required: bool, +} + +impl File { + pub fn new(path: impl Into, format: FileFormat) -> Self { + Self { + path: path.into(), + format, + required: false, + } + } + + #[cfg(feature = "toml")] + pub fn toml(path: impl Into) -> Self { + Self::new(path, FileFormat::Toml) + } + + #[cfg(feature = "yaml")] + pub fn yaml(path: impl Into) -> Self { + Self::new(path, FileFormat::Yaml) + } + + /// Marks this file as required, meaning that `>::load` + /// will return an error if the file does not exist. Otherwise, an empty + /// layer (all values are `None`) is returned. + pub fn required(mut self) -> Self { + self.required = true; + self + } +} + +impl Source for File { + // Unfortunately, if no file format is enabled, this emits unused variable + // warnings. This should not happen as `self`, a type containing an empty + // enum, is in scope, meaning that the code cannot be reached. + #[cfg_attr( + not(any(feature = "toml", feature = "yaml")), + allow(unused_variables), + )] + fn load(&self) -> Result { + // Load file contents. If the file does not exist and was not marked as + // required, we just return an empty layer. + let file_content = match fs::read(&self.path) { + Ok(v) => v, + Err(e) if e.kind() == io::ErrorKind::NotFound && !self.required => { + return Ok(C::Partial::empty()); + } + Err(e) => { + return Err(ErrorInner::Io { + path: Some(self.path.clone()), + err: e, + }.into()); + } + }; + + // Helper closure to create an error. + let error = |err| Error::from(ErrorInner::Deserialization { + err, + source: Some(format!("file '{}'", self.path.display())), + }); + + match self.format { + #[cfg(feature = "toml")] + FileFormat::Toml => toml::from_slice(&file_content) + .map_err(|e| error(Box::new(e))), + + #[cfg(feature = "yaml")] + FileFormat::Yaml => serde_yaml::from_slice(&file_content) + .map_err(|e| error(Box::new(e))), + } + } +} + +pub enum FileFormat { + #[cfg(feature = "toml")] Toml, + #[cfg(feature = "yaml")] Yaml, +} + +impl FileFormat { + pub fn from_extension(ext: impl AsRef) -> Option { + match ext.as_ref().to_str()? { + #[cfg(feature = "toml")] + "toml" => Some(Self::Toml), + + #[cfg(feature = "yaml")] + "yaml" | "yml" => Some(Self::Yaml), + + _ => None, + } + } +}