diff --git a/Cargo.lock b/Cargo.lock index 9bbdadb..de4e455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,7 +402,7 @@ dependencies = [ [[package]] name = "termcfg" -version = "0.1.0" +version = "0.2.0" dependencies = [ "crossterm 0.28.1", "crossterm 0.29.0", diff --git a/Cargo.toml b/Cargo.toml index 6964032..f41ef8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "termcfg" -version = "0.1.0" +version = "0.2.0" authors = ["ynqa "] edition = "2024" description = "Terminal shortcut and style string conversions for configuration files" diff --git a/README.md b/README.md index d9cb1d8..9f339b0 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ If you want to use `crossterm` v0.29.0: ```toml [dependencies] -termcfg = { version = "0.1.0", features = ["crossterm_0_29_0"] } +termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } ``` else if you want to use `termion` v4.0.6: ```toml [dependencies] -termcfg = { version = "0.1.0", features = ["termion_4_0_6"] } +termcfg = { version = "0.2.0", features = ["termion_4_0_6"] } ``` ## Notation diff --git a/src/crossterm_config.rs b/src/crossterm_config.rs index 84c98a9..a232876 100644 --- a/src/crossterm_config.rs +++ b/src/crossterm_config.rs @@ -1,4 +1,6 @@ +pub mod attribute_serde; pub mod content_style_serde; mod convert; pub mod event_serde; pub mod event_set_serde; +pub mod option_content_style_serde; diff --git a/src/crossterm_config/attribute_serde.rs b/src/crossterm_config/attribute_serde.rs new file mode 100644 index 0000000..f06db22 --- /dev/null +++ b/src/crossterm_config/attribute_serde.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Deserializer, Serializer}; + +use crate::{ + crossterm::style::Attribute, + crossterm_config::convert::{attribute_to_token, parse_attribute_token}, + error::StyleError, +}; + +pub fn serialize(attribute: &Attribute, serializer: S) -> Result +where + S: Serializer, +{ + let token = attribute_to_token(*attribute).ok_or_else(|| { + serde::ser::Error::custom(StyleError::UnsupportedAttributeToken { + token: format!("{attribute:?}"), + }) + })?; + + serializer.serialize_str(token) +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let encoded = String::deserialize(deserializer)?; + parse_attribute_token(&encoded).map_err(serde::de::Error::custom) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize, serde::Serialize)] + struct Config { + #[serde(with = "crate::crossterm_config::attribute_serde")] + attr: Attribute, + } + + mod deserialize { + use super::*; + + #[test] + fn deserializes_lowercase_attribute() { + let content = r#"attr = "underlined""#; + let config: Config = toml::from_str(content).unwrap(); + assert_eq!(config.attr, Attribute::Underlined); + } + + #[test] + fn deserializes_case_insensitive_attribute() { + let content = r#"attr = "NoBold""#; + let config: Config = toml::from_str(content).unwrap(); + assert_eq!(config.attr, Attribute::NoBold); + } + } + + mod serialize { + use super::*; + + #[test] + fn serializes_attribute_to_lowercase_token() { + let config = Config { + attr: Attribute::NoBold, + }; + let serialized = toml::to_string(&config).unwrap(); + assert!(serialized.contains("attr = \"nobold\"")); + } + } +} diff --git a/src/crossterm_config/convert.rs b/src/crossterm_config/convert.rs index d9dd096..c7c82f7 100644 --- a/src/crossterm_config/convert.rs +++ b/src/crossterm_config/convert.rs @@ -1,2 +1,3 @@ mod content_style; +pub(crate) use content_style::{attribute_to_token, parse_attribute_token}; mod event; diff --git a/src/crossterm_config/convert/content_style.rs b/src/crossterm_config/convert/content_style.rs index 8f5370e..e75bad6 100644 --- a/src/crossterm_config/convert/content_style.rs +++ b/src/crossterm_config/convert/content_style.rs @@ -125,7 +125,7 @@ fn color_to_def(color: Color) -> Result { Ok(color_def) } -fn parse_attribute_token(token: &str) -> Result { +pub fn parse_attribute_token(token: &str) -> Result { let token = token.to_ascii_lowercase(); let attribute = match token.as_str() { @@ -167,7 +167,7 @@ fn parse_attribute_token(token: &str) -> Result { Ok(attribute) } -fn attribute_to_token(attribute: Attribute) -> Option<&'static str> { +pub fn attribute_to_token(attribute: Attribute) -> Option<&'static str> { let token = match attribute { Attribute::Reset => "reset", Attribute::Bold => "bold", diff --git a/src/crossterm_config/option_content_style_serde.rs b/src/crossterm_config/option_content_style_serde.rs new file mode 100644 index 0000000..836250a --- /dev/null +++ b/src/crossterm_config/option_content_style_serde.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::{ + crossterm::style::ContentStyle, + style::{ + format::content_style_to_string, parse::parse_content_style, style_def::ContentStyleDef, + }, +}; + +pub fn serialize(style: &Option, serializer: S) -> Result +where + S: Serializer, +{ + let encoded = style + .as_ref() + .map(|style| { + let style_def = ContentStyleDef::try_from(style).map_err(serde::ser::Error::custom)?; + Ok::<_, S::Error>(content_style_to_string(&style_def)) + }) + .transpose()?; + + encoded.serialize(serializer) +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let encoded = Option::::deserialize(deserializer)?; + + encoded + .map(|encoded| { + let style_def = parse_content_style(&encoded).map_err(serde::de::Error::custom)?; + ContentStyle::try_from(style_def).map_err(serde::de::Error::custom) + }) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crossterm::style::Color; + + #[derive(Deserialize, serde::Serialize)] + struct Config { + #[serde(default)] + #[serde(with = "crate::crossterm_config::option_content_style_serde")] + style: Option, + } + + mod deserialize { + use super::*; + + #[test] + fn deserializes_some_style_string() { + let content = r#"style = "fg=red,bg=#102030,attr=bold""#; + let config: Config = toml::from_str(content).unwrap(); + + let style = config.style.expect("style should be Some"); + assert_eq!(style.foreground_color, Some(Color::Red)); + assert_eq!( + style.background_color, + Some(Color::Rgb { + r: 0x10, + g: 0x20, + b: 0x30, + }) + ); + } + + #[test] + fn deserializes_none_when_field_is_missing() { + let content = r#""#; + let config: Config = toml::from_str(content).unwrap(); + assert!(config.style.is_none()); + } + } + + mod serialize { + use super::*; + + #[test] + fn serializes_some_style_string() { + let style = ContentStyle { + foreground_color: Some(Color::Blue), + ..ContentStyle::default() + }; + + let config = Config { style: Some(style) }; + let serialized = toml::to_string(&config).unwrap(); + + assert!(serialized.contains("fg=blue")); + } + + #[test] + fn serializes_none_as_absent_value() { + let config = Config { style: None }; + let serialized = toml::to_string(&config).unwrap(); + assert!(!serialized.contains("style")); + } + } +} diff --git a/src/termion_config.rs b/src/termion_config.rs index 84c98a9..cb38768 100644 --- a/src/termion_config.rs +++ b/src/termion_config.rs @@ -2,3 +2,4 @@ pub mod content_style_serde; mod convert; pub mod event_serde; pub mod event_set_serde; +pub mod option_content_style_serde; diff --git a/src/termion_config/option_content_style_serde.rs b/src/termion_config/option_content_style_serde.rs new file mode 100644 index 0000000..0be868e --- /dev/null +++ b/src/termion_config/option_content_style_serde.rs @@ -0,0 +1,97 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::{ + style::{ + format::content_style_to_string, parse::parse_content_style, style_def::ContentStyleDef, + }, + termion_config::content_style_serde::ContentStyle, +}; + +pub fn serialize(style: &Option, serializer: S) -> Result +where + S: Serializer, +{ + let encoded = style + .as_ref() + .map(|style| { + let style_def = ContentStyleDef::try_from(style).map_err(serde::ser::Error::custom)?; + Ok::<_, S::Error>(content_style_to_string(&style_def)) + }) + .transpose()?; + + encoded.serialize(serializer) +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let encoded = Option::::deserialize(deserializer)?; + + encoded + .map(|encoded| { + let style_def = parse_content_style(&encoded).map_err(serde::de::Error::custom)?; + ContentStyle::try_from(style_def).map_err(serde::de::Error::custom) + }) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::termion_config::content_style_serde::{Color, Style}; + + #[derive(Deserialize, serde::Serialize)] + struct Config { + #[serde(default)] + #[serde(with = "crate::termion_config::option_content_style_serde")] + style: Option, + } + + mod deserialize { + use super::*; + + #[test] + fn deserializes_some_style_string() { + let content = r#"style = "fg=red,bg=#102030,attr=bold""#; + let config: Config = toml::from_str(content).unwrap(); + + let style = config.style.expect("style should be Some"); + assert!(matches!( + style.fg, + Some(crate::termion::color::Fg(Color::Red)) + )); + assert!(matches!( + style.bg, + Some(crate::termion::color::Bg(Color::Rgb(0x10, 0x20, 0x30))) + )); + assert!(style.attributes.iter().any(|s| matches!(s, Style::Bold))); + } + + #[test] + fn deserializes_none_when_field_is_missing() { + let content = r#""#; + let config: Config = toml::from_str(content).unwrap(); + assert!(config.style.is_none()); + } + } + + mod serialize { + use super::*; + + #[test] + fn serializes_some_style_string() { + let style = ContentStyle { + fg: Some(crate::termion::color::Fg(Color::LightBlue)), + bg: None, + attributes: vec![Style::Bold], + }; + + let config = Config { style: Some(style) }; + let serialized = toml::to_string(&config).unwrap(); + + assert!(serialized.contains("fg=lightblue")); + assert!(serialized.contains("attr=bold")); + } + } +}