catfx 0.1.0

Apply effects to a text stream
Documentation
//! Color utilities that provide interoperability with clap.
use crate::error::ColorFromStrErr;
use std::str::FromStr;

use owo_colors::{AnsiColors, DynColors};

/// A color from the CLI. Limited to only ANSI colors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Color {
    Black,
    Red,
    Green,
    Yellow,
    Blue,
    Magenta,
    Cyan,
    White,
    Rgb(u8, u8, u8),
}

impl Color {
    /// Gets the [`DynColors`] matching this color.
    pub fn into_owo(self) -> DynColors {
        if let Self::Rgb(r, g, b) = self {
            DynColors::Rgb(r, g, b)
        } else {
            DynColors::Ansi(self.into_owo_ansi())
        }
    }

    /// Gets the [`AnsiColor`] matching this color.
    ///
    /// # panics
    ///
    /// When the color is not an ANSI color.
    #[inline]
    fn into_owo_ansi(self) -> AnsiColors {
        use AnsiColors::*;
        match self {
            Self::Black => Black,
            Self::Red => Red,
            Self::Green => Green,
            Self::Yellow => Yellow,
            Self::Blue => Blue,
            Self::Magenta => Magenta,
            Self::Cyan => Cyan,
            Self::White => White,
            Self::Rgb(_, _, _) => unreachable!("not an ANSI color"),
        }
    }

    /// Converts a hex string in the format `#RRGGBB` to an RGB color.
    fn from_hex(hex: &str) -> Result<Self, ColorFromStrErr> {
        const RADIX: u32 = 16;
        const HEX_CODE_LEN: usize = 6 + 1;
        debug_assert!(hex.starts_with('#'));

        if hex.len() != HEX_CODE_LEN {
            return Err(ColorFromStrErr::invalid_hex(hex));
        }

        // NOTE u32 is 4 bytes, we need to parse 3 of those.
        let (r, g, b) = u32::from_str_radix(&hex[1..], RADIX)
            .map(Self::split_bytes)
            .map_err(|_| ColorFromStrErr::invalid_hex(hex))?;

        Ok(Self::Rgb(r, g, b))
    }

    /// Splits 3 bytes, stored in a `u32`, into `(u8, u8, u8)`. For example,
    /// `0x123456` would be converted to `(0x12, 0x34, 0x56)`.
    #[inline]
    fn split_bytes(bytes: u32) -> (u8, u8, u8) {
        debug_assert!(bytes <= 0xFFFFFF);

        // NOTE Indexed right-to-left by 8 bits at time.
        const POSITIONS: [u8; 3] = [2, 1, 0];
        POSITIONS
            .map(|position| Self::byte_at_shift(bytes, 8 * position))
            .into()
    }

    /// Gets the last byte in a number after shifting it `shift` bits.
    const fn byte_at_shift(bytes: u32, shift: u8) -> u8 {
        const BYTE_MASK: u32 = 0xFF;
        ((bytes >> shift) & BYTE_MASK) as u8
    }
}

impl FromStr for Color {
    type Err = ColorFromStrErr;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s.starts_with('#') {
            return Self::from_hex(s);
        }

        const NAMED_COLORS: [(&str, Color); 8] = [
            ("black", Color::Black),
            ("red", Color::Red),
            ("green", Color::Green),
            ("yellow", Color::Yellow),
            ("blue", Color::Blue),
            ("magenta", Color::Magenta),
            ("cyan", Color::Cyan),
            ("white", Color::White),
        ];
        NAMED_COLORS
            .into_iter()
            .find_map(|(name, color)| name.eq_ignore_ascii_case(s).then_some(color))
            .ok_or_else(|| ColorFromStrErr::UnknownColor(s.to_string()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_valid_hex() {
        assert_eq!(
            "#FF8800".parse::<Color>().unwrap(),
            Color::Rgb(0xFF, 0x88, 0x00)
        );
    }

    #[test]
    fn test_hex_too_long() {
        assert!("#AABBCCDD".parse::<Color>().is_err());
    }

    #[test]
    fn test_hex_too_short() {
        assert!("#AABB".parse::<Color>().is_err());
    }

    #[test]
    fn test_hex_not_valid() {
        assert!("#EEFFGG".parse::<Color>().is_err());
    }

    #[test]
    fn test_hex_no_hash() {
        assert!("EEFFGG".parse::<Color>().is_err());
    }
}