Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "termcfg"
version = "0.1.0"
version = "0.2.0"
authors = ["ynqa <un.pensiero.vano@gmail.com>"]
edition = "2024"
description = "Terminal shortcut and style string conversions for configuration files"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/crossterm_config.rs
Original file line number Diff line number Diff line change
@@ -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;
70 changes: 70 additions & 0 deletions src/crossterm_config/attribute_serde.rs
Original file line number Diff line number Diff line change
@@ -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<S>(attribute: &Attribute, serializer: S) -> Result<S::Ok, S::Error>
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<Attribute, D::Error>
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\""));
}
}
}
1 change: 1 addition & 0 deletions src/crossterm_config/convert.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
mod content_style;
pub(crate) use content_style::{attribute_to_token, parse_attribute_token};
mod event;
4 changes: 2 additions & 2 deletions src/crossterm_config/convert/content_style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ fn color_to_def(color: Color) -> Result<ColorDef, StyleError> {
Ok(color_def)
}

fn parse_attribute_token(token: &str) -> Result<Attribute, StyleError> {
pub fn parse_attribute_token(token: &str) -> Result<Attribute, StyleError> {
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_attribute_token is re-exported as pub(crate) from crossterm_config::convert. Consider making this function pub(crate) as well (instead of pub) to keep the visibility as narrow as possible and avoid accidentally expanding the API surface later.

Suggested change
pub fn parse_attribute_token(token: &str) -> Result<Attribute, StyleError> {
pub(crate) fn parse_attribute_token(token: &str) -> Result<Attribute, StyleError> {

Copilot uses AI. Check for mistakes.
let token = token.to_ascii_lowercase();

let attribute = match token.as_str() {
Expand Down Expand Up @@ -167,7 +167,7 @@ fn parse_attribute_token(token: &str) -> Result<Attribute, StyleError> {
Ok(attribute)
}

fn attribute_to_token(attribute: Attribute) -> Option<&'static str> {
pub fn attribute_to_token(attribute: Attribute) -> Option<&'static str> {
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attribute_to_token is only used internally and re-exported as pub(crate) from crossterm_config::convert. Consider changing this function’s visibility from pub to pub(crate) (or similarly minimal) to avoid unnecessarily widening visibility.

Suggested change
pub fn attribute_to_token(attribute: Attribute) -> Option<&'static str> {
pub(crate) fn attribute_to_token(attribute: Attribute) -> Option<&'static str> {

Copilot uses AI. Check for mistakes.
let token = match attribute {
Attribute::Reset => "reset",
Attribute::Bold => "bold",
Expand Down
102 changes: 102 additions & 0 deletions src/crossterm_config/option_content_style_serde.rs
Original file line number Diff line number Diff line change
@@ -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<S>(style: &Option<ContentStyle>, serializer: S) -> Result<S::Ok, S::Error>
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<Option<ContentStyle>, D::Error>
where
D: Deserializer<'de>,
{
let encoded = Option::<String>::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<ContentStyle>,
}

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"));
}
}
}
1 change: 1 addition & 0 deletions src/termion_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
97 changes: 97 additions & 0 deletions src/termion_config/option_content_style_serde.rs
Original file line number Diff line number Diff line change
@@ -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<S>(style: &Option<ContentStyle>, serializer: S) -> Result<S::Ok, S::Error>
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<Option<ContentStyle>, D::Error>
where
D: Deserializer<'de>,
{
let encoded = Option::<String>::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<ContentStyle>,
}

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"));
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The option wrapper’s serialization behavior for None isn’t covered here. Adding a test that Config { style: None } serializes without emitting a style key (matching the crossterm variant) would help prevent regressions in how None is represented in TOML.

Suggested change
}
}
#[test]
fn serializes_none_without_style_key() {
let config = Config { style: None };
let serialized = toml::to_string(&config).unwrap();
// Ensure that when style is None, the field is omitted entirely
assert!(!serialized.contains("style"));
}

Copilot uses AI. Check for mistakes.
}
}