diff --git a/Cargo.lock b/Cargo.lock index 53cf274..4d4d3c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,9 @@ dependencies = [ "clap", "env_logger", "log", + "serde", + "serde_test", + "toml", "tracing", "tracing-core", "tracing-subscriber", @@ -138,6 +141,18 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + [[package]] name = "heck" version = "0.5.0" @@ -150,6 +165,16 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -231,6 +256,44 @@ version = "0.6.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" +[[package]] +name = "serde" +version = "1.0.193" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.193" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_test" +version = "1.0.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f901ee573cab6b3060453d2d5f0bae4e6d628c23c0a962ff9b5f1d7c8d4f1ed" +dependencies = [ + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.4" @@ -266,6 +329,40 @@ dependencies = [ "once_cell", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tracing" version = "0.1.40" @@ -428,3 +525,12 @@ name = "windows_x86_64_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml index 0799653..9f974c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,15 +117,19 @@ codecov = { repository = "clap-rs/clap-verbosity-flag" } default = ["log"] log = ["dep:log"] tracing = ["dep:tracing-core"] +serde = ["dep:serde"] [dependencies] clap = { version = "4.0.0", default-features = false, features = ["std", "derive"] } log = { version = "0.4.1", optional = true } +serde = { version = "1", features = ["derive"], optional = true } tracing-core = { version = "0.1", optional = true } [dev-dependencies] clap = { version = "4.5.4", default-features = false, features = ["help", "usage"] } env_logger = "0.11.3" +serde_test = { version = "1.0.177" } +toml = { version = "0.8.19" } tracing = "0.1" tracing-subscriber = "0.3" diff --git a/src/lib.rs b/src/lib.rs index 9a5b5f4..f7a23a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,8 +70,21 @@ pub mod log; pub mod tracing; /// Logging flags to `#[command(flatten)]` into your CLI -#[derive(clap::Args, Debug, Clone, Default)] +#[derive(clap::Args, Debug, Clone, Default, PartialEq, Eq)] #[command(about = None, long_about = None)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde( + from = "VerbosityFilter", + into = "VerbosityFilter", + bound(serialize = "L: Clone") + ) +)] +#[cfg_attr( + feature = "serde", + doc = r#"This type serializes to a string representation of the log level, e.g. `"Debug"`"# +)] pub struct Verbosity { #[arg( long, @@ -162,6 +175,21 @@ impl fmt::Display for Verbosity { } } +impl From> for VerbosityFilter { + fn from(verbosity: Verbosity) -> Self { + verbosity.filter() + } +} + +impl From for Verbosity { + fn from(filter: VerbosityFilter) -> Self { + let default = L::default_filter(); + let verbose = filter.value().saturating_sub(default.value()); + let quiet = default.value().saturating_sub(filter.value()); + Verbosity::new(verbose, quiet) + } +} + /// Customize the default log-level and associated help pub trait LogLevel { /// Baseline level before applying `--verbose` and `--quiet` @@ -192,6 +220,7 @@ pub trait LogLevel { /// /// Used to calculate the log level and filter. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum VerbosityFilter { Off, Error, @@ -206,15 +235,7 @@ impl VerbosityFilter { /// /// Negative values will decrease the verbosity, while positive values will increase it. fn with_offset(&self, offset: i16) -> VerbosityFilter { - let value = match self { - Self::Off => 0_i16, - Self::Error => 1, - Self::Warn => 2, - Self::Info => 3, - Self::Debug => 4, - Self::Trace => 5, - }; - match value.saturating_add(offset) { + match i16::from(self.value()).saturating_add(offset) { i16::MIN..=0 => Self::Off, 1 => Self::Error, 2 => Self::Warn, @@ -223,6 +244,20 @@ impl VerbosityFilter { 5..=i16::MAX => Self::Trace, } } + + /// Get the numeric value of the filter. + /// + /// This is an internal representation of the filter level used only for conversion / offset. + fn value(&self) -> u8 { + match self { + Self::Off => 0, + Self::Error => 1, + Self::Warn => 2, + Self::Info => 3, + Self::Debug => 4, + Self::Trace => 5, + } + } } impl fmt::Display for VerbosityFilter { @@ -239,7 +274,7 @@ impl fmt::Display for VerbosityFilter { } /// Default to [`VerbosityFilter::Error`] -#[derive(Copy, Clone, Debug, Default)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct ErrorLevel; impl LogLevel for ErrorLevel { @@ -249,7 +284,7 @@ impl LogLevel for ErrorLevel { } /// Default to [`VerbosityFilter::Warn`] -#[derive(Copy, Clone, Debug, Default)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct WarnLevel; impl LogLevel for WarnLevel { @@ -259,7 +294,7 @@ impl LogLevel for WarnLevel { } /// Default to [`VerbosityFilter::Info`] -#[derive(Copy, Clone, Debug, Default)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct InfoLevel; impl LogLevel for InfoLevel { @@ -269,7 +304,7 @@ impl LogLevel for InfoLevel { } /// Default to [`VerbosityFilter::Debug`] -#[derive(Copy, Clone, Debug, Default)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct DebugLevel; impl LogLevel for DebugLevel { @@ -279,7 +314,7 @@ impl LogLevel for DebugLevel { } /// Default to [`VerbosityFilter::Trace`] -#[derive(Copy, Clone, Debug, Default)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct TraceLevel; impl LogLevel for TraceLevel { @@ -289,7 +324,7 @@ impl LogLevel for TraceLevel { } /// Default to [`VerbosityFilter::Off`] (no logging) -#[derive(Copy, Clone, Debug, Default)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct OffLevel; impl LogLevel for OffLevel { @@ -453,4 +488,84 @@ mod test { assert_filter::(verbose, quiet, expected_filter); } } + + #[test] + fn from_verbosity_filter() { + for &filter in &[ + VerbosityFilter::Off, + VerbosityFilter::Error, + VerbosityFilter::Warn, + VerbosityFilter::Info, + VerbosityFilter::Debug, + VerbosityFilter::Trace, + ] { + assert_eq!(Verbosity::::from(filter).filter(), filter); + assert_eq!(Verbosity::::from(filter).filter(), filter); + assert_eq!(Verbosity::::from(filter).filter(), filter); + assert_eq!(Verbosity::::from(filter).filter(), filter); + assert_eq!(Verbosity::::from(filter).filter(), filter); + assert_eq!(Verbosity::::from(filter).filter(), filter); + } + } +} + +#[cfg(feature = "serde")] +mod serde_tests { + use super::*; + + use clap::Parser; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Parser, Serialize, Deserialize)] + struct Cli { + meaning_of_life: u8, + #[command(flatten)] + verbosity: Verbosity, + } + + #[test] + fn serialize_toml() { + let cli = Cli { + meaning_of_life: 42, + verbosity: Verbosity::new(2, 1), + }; + let toml = toml::to_string(&cli).unwrap(); + assert_eq!(toml, "meaning_of_life = 42\nverbosity = \"Debug\"\n"); + } + + #[test] + fn deserialize_toml() { + let toml = "meaning_of_life = 42\nverbosity = \"Debug\"\n"; + let cli: Cli = toml::from_str(toml).unwrap(); + assert_eq!(cli.meaning_of_life, 42); + assert_eq!(cli.verbosity.filter(), VerbosityFilter::Debug); + } + + /// Tests that the `Verbosity` can be serialized and deserialized correctly from an a token. + #[test] + fn serde_round_trips() { + use serde_test::{assert_tokens, Token}; + + for (filter, variant) in [ + (VerbosityFilter::Off, "Off"), + (VerbosityFilter::Error, "Error"), + (VerbosityFilter::Warn, "Warn"), + (VerbosityFilter::Info, "Info"), + (VerbosityFilter::Debug, "Debug"), + (VerbosityFilter::Trace, "Trace"), + ] { + let tokens = &[Token::UnitVariant { + name: "VerbosityFilter", + variant, + }]; + + // `assert_tokens` checks both serialization and deserialization. + assert_tokens(&Verbosity::::from(filter), tokens); + assert_tokens(&Verbosity::::from(filter), tokens); + assert_tokens(&Verbosity::::from(filter), tokens); + assert_tokens(&Verbosity::::from(filter), tokens); + assert_tokens(&Verbosity::::from(filter), tokens); + assert_tokens(&Verbosity::::from(filter), tokens); + } + } }