From f5da5c62727f73138d09336ff50a7f65d41a77e6 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 28 Nov 2024 17:20:32 -0800 Subject: [PATCH] feat: Implement Serialization and Deserialization Verbosity is serialized and deserialized using the title case of the VerbosityFilter (e.g. "Debug") The `serde` dependency is gated behind an optional feature flag. Added conversion methods between Verbosity and VerbosityFilter to simplify the implementation and derived PartialEq, Eq impls on types where this was necesary for testing. Fixes: https://github.com/clap-rs/clap-verbosity-flag/issues/88 --- Cargo.lock | 106 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 ++ src/lib.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 242 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d198cb3..1cd5602 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.41" @@ -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..497315f 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,85 @@ 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")] +#[cfg(test)] +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); + } + } }