use super::registry::LoadThemeError;
use crate::markdown::text_style::{Color, Colors, UndefinedPaletteColorError};
use hex::{FromHex, FromHexError};
use serde::{Deserialize, Serialize, de::Visitor};
use std::{
collections::BTreeMap,
fmt, fs,
path::{Path, PathBuf},
str::FromStr,
};
pub(crate) type RawColors = Colors<RawColor>;
#[derive(Default, Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PresentationTheme {
#[serde(default)]
pub(crate) extends: Option<String>,
#[serde(default)]
pub(crate) slide_title: SlideTitleStyle,
#[serde(default)]
pub(crate) code: CodeBlockStyle,
#[serde(default)]
pub(crate) execution_output: ExecutionOutputBlockStyle,
#[serde(default)]
pub(crate) pty_output: PtyOutputBlockStyle,
#[serde(default)]
pub(crate) inline_code: ModifierStyle,
#[serde(default)]
pub(crate) bold: ModifierStyle,
#[serde(default, alias = "italic")]
pub(crate) italics: ModifierStyle,
#[serde(default)]
pub(crate) table: Option<Alignment>,
#[serde(default)]
pub(crate) block_quote: BlockQuoteStyle,
#[serde(default)]
pub(crate) alert: AlertStyle,
#[serde(rename = "default", default)]
pub(crate) default_style: DefaultStyle,
#[serde(default)]
pub(crate) headings: HeadingStyles,
#[serde(default)]
pub(crate) intro_slide: IntroSlideStyle,
#[serde(default)]
pub(crate) footer: Option<FooterStyle>,
#[serde(default)]
pub(crate) typst: TypstStyle,
#[serde(default)]
pub(crate) mermaid: MermaidStyle,
#[serde(default)]
pub(crate) d2: D2Style,
#[serde(default)]
pub(crate) modals: ModalStyle,
#[serde(default)]
pub(crate) layout_grid: LayoutGridStyle,
#[serde(default)]
pub(crate) palette: ColorPalette,
}
impl PresentationTheme {
pub(crate) fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, LoadThemeError> {
let contents = fs::read_to_string(&path).map_err(|e| LoadThemeError::Reading(path.as_ref().into(), e))?;
let theme = serde_yaml::from_str(&contents)
.map_err(|e| LoadThemeError::Corrupted(path.as_ref().display().to_string(), e.into()))?;
Ok(theme)
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct SlideTitleStyle {
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
#[serde(default)]
pub(crate) separator: bool,
#[serde(default)]
pub(crate) padding_top: Option<u8>,
#[serde(default)]
pub(crate) padding_bottom: Option<u8>,
#[serde(default)]
pub(crate) colors: RawColors,
#[serde(default)]
pub(crate) prefix: Option<String>,
#[serde(default)]
pub(crate) bold: Option<bool>,
#[serde(default, alias = "italic")]
pub(crate) italics: Option<bool>,
#[serde(default)]
pub(crate) underlined: Option<bool>,
#[serde(default)]
pub(crate) font_size: Option<u8>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct HeadingStyles {
#[serde(default)]
pub(crate) h1: HeadingStyle,
#[serde(default)]
pub(crate) h2: HeadingStyle,
#[serde(default)]
pub(crate) h3: HeadingStyle,
#[serde(default)]
pub(crate) h4: HeadingStyle,
#[serde(default)]
pub(crate) h5: HeadingStyle,
#[serde(default)]
pub(crate) h6: HeadingStyle,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct HeadingStyle {
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
#[serde(default)]
pub(crate) prefix: Option<String>,
#[serde(default)]
pub(crate) colors: RawColors,
#[serde(default)]
pub(crate) font_size: Option<u8>,
#[serde(default)]
pub(crate) bold: Option<bool>,
#[serde(default)]
pub(crate) underlined: Option<bool>,
#[serde(default, alias = "italic")]
pub(crate) italics: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct BlockQuoteStyle {
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
#[serde(default)]
pub(crate) prefix: Option<String>,
#[serde(default)]
pub(crate) colors: BlockQuoteColors,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct BlockQuoteColors {
#[serde(flatten)]
pub(crate) base: RawColors,
#[serde(default)]
pub(crate) prefix: Option<RawColor>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct AlertStyle {
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
#[serde(default)]
pub(crate) base_colors: RawColors,
#[serde(default)]
pub(crate) prefix: Option<String>,
#[serde(default)]
pub(crate) styles: AlertTypeStyles,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct AlertTypeStyles {
#[serde(default)]
pub(crate) note: AlertTypeStyle,
#[serde(default)]
pub(crate) tip: AlertTypeStyle,
#[serde(default)]
pub(crate) important: AlertTypeStyle,
#[serde(default)]
pub(crate) warning: AlertTypeStyle,
#[serde(default)]
pub(crate) caution: AlertTypeStyle,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct AlertTypeStyle {
#[serde(default)]
pub(crate) color: Option<RawColor>,
#[serde(default)]
pub(crate) title: Option<String>,
#[serde(default)]
pub(crate) icon: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct IntroSlideStyle {
#[serde(default)]
pub(crate) title: IntroSlideTitleStyle,
#[serde(default)]
pub(crate) subtitle: BasicStyle,
#[serde(default)]
pub(crate) event: BasicStyle,
#[serde(default)]
pub(crate) location: BasicStyle,
#[serde(default)]
pub(crate) date: BasicStyle,
#[serde(default)]
pub(crate) author: AuthorStyle,
#[serde(default)]
pub(crate) footer: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct DefaultStyle {
#[serde(default, with = "serde_yaml::with::singleton_map")]
pub(crate) margin: Option<Margin>,
#[serde(default)]
pub(crate) colors: RawColors,
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct BasicStyle {
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
#[serde(default)]
pub(crate) colors: RawColors,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct IntroSlideTitleStyle {
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
#[serde(default)]
pub(crate) colors: RawColors,
#[serde(default)]
pub(crate) font_size: Option<u8>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(tag = "alignment", rename_all = "snake_case")]
pub(crate) enum Alignment {
Left {
#[serde(default)]
margin: Margin,
},
Right {
#[serde(default)]
margin: Margin,
},
Center {
#[serde(default)]
minimum_margin: Margin,
#[serde(default)]
minimum_size: u16,
},
}
impl Default for Alignment {
fn default() -> Self {
Self::Left { margin: Margin::Fixed(0) }
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct AuthorStyle {
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
#[serde(default)]
pub(crate) colors: RawColors,
#[serde(default)]
pub(crate) positioning: AuthorPositioning,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "style", rename_all = "snake_case")]
pub(crate) enum FooterStyle {
Template {
left: Option<FooterContent>,
center: Option<FooterContent>,
right: Option<FooterContent>,
#[serde(default)]
colors: RawColors,
height: Option<u16>,
},
ProgressBar {
character: Option<char>,
#[serde(default)]
colors: RawColors,
},
Empty,
}
impl Default for FooterStyle {
fn default() -> Self {
Self::Template { left: None, center: None, right: None, colors: RawColors::default(), height: None }
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
pub(crate) enum FooterTemplateChunk {
Literal(String),
OpenBrace,
ClosedBrace,
CurrentSlide,
TotalSlides,
Author,
Title,
SubTitle,
Event,
Location,
Date,
}
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum FooterContent {
Template(FooterTemplate),
Image {
#[serde(rename = "image")]
path: PathBuf,
},
}
struct FooterContentVisitor;
impl<'de> Visitor<'de> for FooterContentVisitor {
type Value = FooterContent;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid footer")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let template = FooterTemplate::from_str(v).map_err(|e| E::custom(e.to_string()))?;
Ok(FooterContent::Template(template))
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let Some((key, value)): Option<(String, PathBuf)> = map.next_entry()? else {
return Err(serde::de::Error::custom("invalid footer"));
};
match key.as_str() {
"image" => Ok(FooterContent::Image { path: value }),
_ => Err(serde::de::Error::invalid_value(serde::de::Unexpected::Str(&key), &self)),
}
}
}
impl<'de> Deserialize<'de> for FooterContent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(FooterContentVisitor)
}
}
#[derive(Clone, Debug)]
pub(crate) struct FooterTemplate(pub(crate) Vec<FooterTemplateChunk>);
crate::utils::impl_deserialize_from_str!(FooterTemplate);
crate::utils::impl_serialize_from_display!(FooterTemplate);
impl FromStr for FooterTemplate {
type Err = ParseFooterTemplateError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut chunks = Vec::new();
let mut chunk_start = 0;
let mut in_variable = false;
let mut iter = s.char_indices().peekable();
while let Some((index, c)) = iter.next() {
if c == '{' {
if in_variable {
return Err(ParseFooterTemplateError::NestedOpenBrace);
}
let double_brace = iter.peek() == Some(&(index + 1, '{'));
if double_brace {
iter.next();
if chunk_start != index {
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
}
chunks.push(FooterTemplateChunk::OpenBrace);
chunk_start = index + 2;
} else {
in_variable = true;
if chunk_start != index {
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
}
chunk_start = index + 1;
}
} else if c == '}' {
if !in_variable {
let double_brace = iter.peek() == Some(&(index + 1, '}'));
if double_brace {
iter.next();
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..index].to_string()));
chunks.push(FooterTemplateChunk::ClosedBrace);
in_variable = false;
chunk_start = index + 2;
continue;
}
return Err(ParseFooterTemplateError::ClosedBraceWithoutOpen);
}
let variable = &s[chunk_start..index];
let chunk = match variable {
"current_slide" => FooterTemplateChunk::CurrentSlide,
"total_slides" => FooterTemplateChunk::TotalSlides,
"author" => FooterTemplateChunk::Author,
"title" => FooterTemplateChunk::Title,
"sub_title" => FooterTemplateChunk::SubTitle,
"event" => FooterTemplateChunk::Event,
"location" => FooterTemplateChunk::Location,
"date" => FooterTemplateChunk::Date,
_ => return Err(ParseFooterTemplateError::UnsupportedVariable(variable.to_string())),
};
chunks.push(chunk);
in_variable = false;
chunk_start = index + 1;
}
}
if in_variable {
return Err(ParseFooterTemplateError::TrailingBrace);
} else if chunk_start != s.len() {
chunks.push(FooterTemplateChunk::Literal(s[chunk_start..].to_string()));
}
Ok(Self(chunks))
}
}
impl fmt::Display for FooterTemplate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use FooterTemplateChunk::*;
for c in &self.0 {
match c {
Literal(l) => write!(f, "{l}"),
OpenBrace => write!(f, "{{{{"),
ClosedBrace => write!(f, "}}}}"),
CurrentSlide => write!(f, "{{current_slide}}"),
TotalSlides => write!(f, "{{total_slides}}"),
Author => write!(f, "{{author}}"),
Title => write!(f, "{{title}}"),
SubTitle => write!(f, "{{sub_title}}"),
Event => write!(f, "{{event}}"),
Location => write!(f, "{{location}}"),
Date => write!(f, "{{date}}"),
}?;
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseFooterTemplateError {
#[error("found '{{' while already inside '{{' scope")]
NestedOpenBrace,
#[error("open '{{' was not closed")]
TrailingBrace,
#[error("found '}}' but no '{{' was found")]
ClosedBraceWithoutOpen,
#[error("unsupported variable: '{0}'")]
UnsupportedVariable(String),
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct CodeBlockStyle {
#[serde(flatten)]
pub(crate) alignment: Option<Alignment>,
#[serde(default)]
pub(crate) padding: PaddingRect,
#[serde(default)]
pub(crate) theme_name: Option<String>,
pub(crate) background: Option<bool>,
#[serde(default)]
pub(crate) line_numbers: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct ExecutionOutputBlockStyle {
#[serde(default)]
pub(crate) colors: RawColors,
#[serde(default)]
pub(crate) status: ExecutionStatusBlockStyle,
#[serde(default)]
pub(crate) padding: PaddingRect,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct PtyOutputBlockStyle {
#[serde(default)]
pub(crate) colors: RawColors,
#[serde(default)]
pub(crate) standby: Option<PtyStandbyStyle>,
#[serde(default)]
pub(crate) cursor: PtyCursorStyle,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct PtyCursorStyle {
#[serde(default)]
pub(crate) symbol: Option<char>,
#[serde(default)]
pub(crate) highlight_colors: RawColors,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) enum PtyStandbyStyle {
LargePlay,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct ExecutionStatusBlockStyle {
#[serde(default)]
pub(crate) running: RawColors,
#[serde(default)]
pub(crate) success: RawColors,
#[serde(default)]
pub(crate) failure: RawColors,
#[serde(default)]
pub(crate) not_started: RawColors,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct ModifierStyle {
#[serde(default)]
pub(crate) colors: RawColors,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct PaddingRect {
#[serde(default)]
pub(crate) horizontal: Option<u8>,
#[serde(default)]
pub(crate) vertical: Option<u8>,
}
#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Margin {
Fixed(u16),
Percent(u16),
}
impl Margin {
pub(crate) fn as_characters(&self, screen_size: u16) -> u16 {
match *self {
Self::Fixed(value) => value,
Self::Percent(percent) => {
let ratio = percent as f64 / 100.0;
(screen_size as f64 * ratio).ceil() as u16
}
}
}
pub(crate) fn is_empty(&self) -> bool {
matches!(self, Self::Fixed(0) | Self::Percent(0))
}
}
impl Default for Margin {
fn default() -> Self {
Self::Fixed(0)
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AuthorPositioning {
BelowTitle,
#[default]
PageBottom,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct TypstStyle {
pub(crate) horizontal_margin: Option<u16>,
pub(crate) vertical_margin: Option<u16>,
#[serde(default)]
pub(crate) colors: RawColors,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct MermaidStyle {
pub(crate) theme: Option<String>,
pub(crate) background: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct D2Style {
pub(crate) theme: Option<u32>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct ModalStyle {
#[serde(default)]
pub(crate) colors: RawColors,
#[serde(default)]
pub(crate) selection_colors: RawColors,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct LayoutGridStyle {
#[serde(default)]
pub(crate) color: Option<RawColor>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct ColorPalette {
#[serde(default)]
pub(crate) colors: BTreeMap<String, RawColor>,
#[serde(default)]
pub(crate) classes: BTreeMap<String, RawColors>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RawColor {
Color(Color),
Palette(String),
ForegroundClass(String),
BackgroundClass(String),
}
crate::utils::impl_deserialize_from_str!(RawColor);
crate::utils::impl_serialize_from_display!(RawColor);
impl RawColor {
fn new_palette(name: &str) -> Result<Self, ParseColorError> {
if name.is_empty() { Err(ParseColorError::PaletteColorEmpty) } else { Ok(Self::Palette(name.into())) }
}
pub(crate) fn resolve(
&self,
palette: &crate::theme::clean::ColorPalette,
) -> Result<Option<Color>, UndefinedPaletteColorError> {
let color = match self {
Self::Color(c) => Some(*c),
Self::Palette(name) => {
Some(palette.colors.get(name).copied().ok_or(UndefinedPaletteColorError(name.clone()))?)
}
Self::ForegroundClass(name) => {
palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.foreground
}
Self::BackgroundClass(name) => {
palette.classes.get(name).ok_or(UndefinedPaletteColorError(name.clone()))?.background
}
};
Ok(color)
}
}
impl From<Color> for RawColor {
fn from(color: Color) -> Self {
Self::Color(color)
}
}
impl FromStr for RawColor {
type Err = ParseColorError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let output = match input {
"black" => Color::Black.into(),
"white" => Color::White.into(),
"grey" => Color::Grey.into(),
"dark_grey" => Color::DarkGrey.into(),
"red" => Color::Red.into(),
"dark_red" => Color::DarkRed.into(),
"green" => Color::Green.into(),
"dark_green" => Color::DarkGreen.into(),
"blue" => Color::Blue.into(),
"dark_blue" => Color::DarkBlue.into(),
"yellow" => Color::Yellow.into(),
"dark_yellow" => Color::DarkYellow.into(),
"magenta" => Color::Magenta.into(),
"dark_magenta" => Color::DarkMagenta.into(),
"cyan" => Color::Cyan.into(),
"dark_cyan" => Color::DarkCyan.into(),
other if other.starts_with("palette:") => Self::new_palette(other.trim_start_matches("palette:"))?,
other if other.starts_with("p:") => Self::new_palette(other.trim_start_matches("p:"))?,
_ => {
let hex = match input.len() {
6 => input.to_string(),
3 => input.chars().flat_map(|c| [c, c]).collect::<String>(),
len => return Err(ParseColorError::InvalidHexLength(len)),
};
let values = <[u8; 3]>::from_hex(hex)?;
Color::Rgb { r: values[0], g: values[1], b: values[2] }.into()
}
};
Ok(output)
}
}
impl fmt::Display for RawColor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use Color::*;
match self {
Self::Color(Rgb { r, g, b }) => write!(f, "{}", hex::encode([*r, *g, *b])),
Self::Color(Black) => write!(f, "black"),
Self::Color(White) => write!(f, "white"),
Self::Color(Grey) => write!(f, "grey"),
Self::Color(DarkGrey) => write!(f, "dark_grey"),
Self::Color(Red) => write!(f, "red"),
Self::Color(DarkRed) => write!(f, "dark_red"),
Self::Color(Green) => write!(f, "green"),
Self::Color(DarkGreen) => write!(f, "dark_green"),
Self::Color(Blue) => write!(f, "blue"),
Self::Color(DarkBlue) => write!(f, "dark_blue"),
Self::Color(Yellow) => write!(f, "yellow"),
Self::Color(DarkYellow) => write!(f, "dark_yellow"),
Self::Color(Magenta) => write!(f, "magenta"),
Self::Color(DarkMagenta) => write!(f, "dark_magenta"),
Self::Color(Cyan) => write!(f, "cyan"),
Self::Color(DarkCyan) => write!(f, "dark_cyan"),
Self::Palette(name) => write!(f, "palette:{name}"),
Self::ForegroundClass(_) => Err(fmt::Error),
Self::BackgroundClass(_) => Err(fmt::Error),
}
}
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum ParseColorError {
#[error("invalid hex color: {0}")]
Hex(#[from] FromHexError),
#[error("hex color should only be 3 or 6 long, got hex string of length {0}")]
InvalidHexLength(usize),
#[error("palette color name is empty")]
PaletteColorEmpty,
}
#[cfg(test)]
mod test {
use super::*;
use rstest::rstest;
#[test]
fn parse_all_footer_template_variables() {
use FooterTemplateChunk::*;
let raw = "hi {current_slide} {total_slides} {author} {title} {sub_title} {event} {location} {event}";
let t: FooterTemplate = raw.parse().expect("invalid input");
let expected = vec![
Literal("hi ".into()),
CurrentSlide,
Literal(" ".into()),
TotalSlides,
Literal(" ".into()),
Author,
Literal(" ".into()),
Title,
Literal(" ".into()),
SubTitle,
Literal(" ".into()),
Event,
Literal(" ".into()),
Location,
Literal(" ".into()),
Event,
];
assert_eq!(t.0, expected);
assert_eq!(t.to_string(), raw);
}
#[test]
fn parse_double_braces() {
use FooterTemplateChunk::*;
let raw = "hi {{beep}} {{author}} {{{{}}}}";
let t: FooterTemplate = raw.parse().expect("invalid input");
let merged: String =
t.0.into_iter()
.map(|l| match l {
Literal(s) => s,
OpenBrace => "{".to_string(),
ClosedBrace => "}".to_string(),
_ => panic!("not a literal"),
})
.collect();
assert_eq!(merged, "hi {beep} {author} {{}}");
}
#[rstest]
#[case::trailing("{author")]
#[case::close_without_open2("author}")]
fn invalid_footer_templates(#[case] input: &str) {
FooterTemplate::from_str(input).expect_err("parse succeeded");
}
#[test]
fn color_serde() {
let color: RawColor = "beef42".parse().unwrap();
assert_eq!(color.to_string(), "beef42");
let short_color: RawColor = "ded".parse().unwrap();
assert_eq!(short_color.to_string(), "ddeedd");
}
#[rstest]
#[case::empty1("p:")]
#[case::empty2("palette:")]
fn invalid_palette_color_names(#[case] input: &str) {
RawColor::from_str(input).expect_err("not an error");
}
#[rstest]
#[case::short("p:hi", "hi")]
#[case::long("palette:bye", "bye")]
fn valid_palette_color_names(#[case] input: &str, #[case] expected: &str) {
let color = RawColor::from_str(input).expect("failed to parse");
let RawColor::Palette(name) = color else { panic!("not a palette color") };
assert_eq!(name, expected);
}
}