summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxxanqw <[email protected]>2025-11-26 13:52:24 +0200
committerxxanqw <[email protected]>2025-11-26 13:52:24 +0200
commit175a320d84197896c5b6e038c0d67bbaa84baff0 (patch)
tree2202964b0d063eea16a9db59c102cd12b74c78ad
parent5e2888348e28f8340a54153c618c5373bc194ec5 (diff)
downloadmikusays-c-175a320d84197896c5b6e038c0d67bbaa84baff0.tar.gz
mikusays-c-175a320d84197896c5b6e038c0d67bbaa84baff0.zip
Add color handling and CLI integration tests
- Implemented a comprehensive color module in `src/color.rs` that supports named colors, hex, RGB, HSL, ANSI, and truecolor formats. - Added parsing functions for different color formats with error handling. - Introduced conversion methods for colors to RGB and ANSI escape codes. - Created a `ColorMode` enum to detect terminal color capabilities. - Added linear interpolation for colors in HSV space. - Developed integration tests in `tests/integration_tests.rs` to verify CLI functionality, including listing styles and error handling for missing or conflicting arguments.
-rw-r--r--Cargo.lock18
-rw-r--r--Cargo.toml8
-rw-r--r--src/color.rs549
-rw-r--r--src/main.rs671
-rw-r--r--tests/integration_tests.rs55
5 files changed, 1202 insertions, 99 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 00b3d60..beaaa6b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -72,9 +72,9 @@ checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "clap"
-version = "4.5.41"
+version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
+checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
dependencies = [
"clap_builder",
"clap_derive",
@@ -82,9 +82,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.41"
+version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
+checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
dependencies = [
"anstream",
"anstyle",
@@ -94,9 +94,9 @@ dependencies = [
[[package]]
name = "clap_derive"
-version = "4.5.41"
+version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
@@ -252,7 +252,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "mikusays"
-version = "0.1.3"
+version = "0.1.4"
dependencies = [
"clap",
"crossterm",
@@ -458,9 +458,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
-version = "0.2.1"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8parse"
diff --git a/Cargo.toml b/Cargo.toml
index eab83a2..e61489c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mikusays"
-version = "0.1.3"
+version = "0.1.4"
edition = "2024"
description = "A \"cowsay\" clone with Hatsune Miku ASCII art and speech bubbles."
license = "MIT"
@@ -28,6 +28,6 @@ assets = [
[dependencies]
crossterm = "0.29"
-unicode-width = "0.2"
-rand = "0.9"
-clap = { version = "4.5.41", features = ["derive"] }
+unicode-width = "0.2.2"
+rand = "0.9.2"
+clap = { version = "4.5.53", features = ["derive"] }
diff --git a/src/color.rs b/src/color.rs
new file mode 100644
index 0000000..1b1b201
--- /dev/null
+++ b/src/color.rs
@@ -0,0 +1,549 @@
+use clap::ValueEnum;
+use std::error::Error;
+use std::fmt;
+use std::str::FromStr;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Color {
+ Named(NamedColor),
+ Hex(String),
+ Rgb(u8, u8, u8),
+ Hsl(u16, u8, u8), // h: 0-360, s: 0-100, l: 0-100
+ Ansi(u8), // ANSI color code (0-255)
+ Truecolor(u8, u8, u8), // 24-bit RGB
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
+pub enum NamedColor {
+ Black,
+ Red,
+ Green,
+ Yellow,
+ Blue,
+ Magenta,
+ Cyan,
+ White,
+ BrightBlack,
+ BrightRed,
+ BrightGreen,
+ BrightYellow,
+ BrightBlue,
+ BrightMagenta,
+ BrightCyan,
+ BrightWhite,
+}
+
+#[derive(Debug)]
+pub enum ColorParseError {
+ InvalidFormat,
+ InvalidHex,
+ InvalidRgb,
+ InvalidHsl,
+ UnknownNamedColor,
+}
+
+impl fmt::Display for ColorParseError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ColorParseError::InvalidFormat => write!(f, "Invalid color format"),
+ ColorParseError::InvalidHex => write!(f, "Invalid hex color code"),
+ ColorParseError::InvalidRgb => write!(f, "Invalid RGB format. Expected format: R,G,B"),
+ ColorParseError::InvalidHsl => {
+ write!(f, "Invalid HSL format. Expected format: hsl(H,S%,L%)")
+ }
+ ColorParseError::UnknownNamedColor => write!(f, "Unknown named color"),
+ }
+ }
+}
+
+impl Error for ColorParseError {}
+
+impl FromStr for Color {
+ type Err = ColorParseError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let s_lower = s.to_lowercase();
+
+ // Try named color first
+ if let Ok(named) = NamedColor::from_str(s, true) {
+ return Ok(Color::Named(named));
+ }
+
+ // Try hex color (#RRGGBB or #RGB)
+ if s.starts_with('#') {
+ return parse_hex_color(s);
+ }
+
+ // Try HSL (hsl(180,100%,50%))
+ if s_lower.starts_with("hsl(") && s.ends_with(')') {
+ return parse_hsl_color(s);
+ }
+
+ // Try RGB tuple (255,0,128)
+ if s.contains(',') {
+ return parse_rgb_color(s);
+ }
+
+ // Try ANSI code (number between 0-255)
+ if let Ok(code) = s.parse::<u8>() {
+ return Ok(Color::Ansi(code));
+ }
+
+ Err(ColorParseError::InvalidFormat)
+ }
+}
+
+impl FromStr for NamedColor {
+ type Err = ColorParseError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Self::from_str(s, false)
+ }
+}
+
+impl NamedColor {
+ fn from_str(s: &str, ignore_case: bool) -> Result<Self, ColorParseError> {
+ let s = if ignore_case {
+ s.to_lowercase()
+ } else {
+ s.to_string()
+ };
+
+ match s.as_str() {
+ "black" => Ok(NamedColor::Black),
+ "red" => Ok(NamedColor::Red),
+ "green" => Ok(NamedColor::Green),
+ "yellow" => Ok(NamedColor::Yellow),
+ "blue" => Ok(NamedColor::Blue),
+ "magenta" => Ok(NamedColor::Magenta),
+ "cyan" => Ok(NamedColor::Cyan),
+ "white" => Ok(NamedColor::White),
+ "brightblack" | "bright_black" => Ok(NamedColor::BrightBlack),
+ "brightred" | "bright_red" => Ok(NamedColor::BrightRed),
+ "brightgreen" | "bright_green" => Ok(NamedColor::BrightGreen),
+ "brightyellow" | "bright_yellow" => Ok(NamedColor::BrightYellow),
+ "brightblue" | "bright_blue" => Ok(NamedColor::BrightBlue),
+ "brightmagenta" | "bright_magenta" => Ok(NamedColor::BrightMagenta),
+ "brightcyan" | "bright_cyan" => Ok(NamedColor::BrightCyan),
+ "brightwhite" | "bright_white" => Ok(NamedColor::BrightWhite),
+ _ => Err(ColorParseError::UnknownNamedColor),
+ }
+ }
+}
+
+fn parse_hex_color(s: &str) -> Result<Color, ColorParseError> {
+ let hex = s.trim_start_matches('#');
+
+ match hex.len() {
+ 3 => {
+ // #RGB format
+ let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
+ .map_err(|_| ColorParseError::InvalidHex)?;
+ let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
+ .map_err(|_| ColorParseError::InvalidHex)?;
+ let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
+ .map_err(|_| ColorParseError::InvalidHex)?;
+ Ok(Color::Hex(format!("#{:02x}{:02x}{:02x}", r, g, b)))
+ }
+ 6 => {
+ // #RRGGBB format
+ let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| ColorParseError::InvalidHex)?;
+ let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| ColorParseError::InvalidHex)?;
+ let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| ColorParseError::InvalidHex)?;
+ Ok(Color::Hex(format!("#{:02x}{:02x}{:02x}", r, g, b)))
+ }
+ _ => Err(ColorParseError::InvalidHex),
+ }
+}
+
+fn parse_rgb_color(s: &str) -> Result<Color, ColorParseError> {
+ let parts: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
+ if parts.len() != 3 {
+ return Err(ColorParseError::InvalidRgb);
+ }
+
+ let r = parts[0]
+ .parse::<u8>()
+ .map_err(|_| ColorParseError::InvalidRgb)?;
+ let g = parts[1]
+ .parse::<u8>()
+ .map_err(|_| ColorParseError::InvalidRgb)?;
+ let b = parts[2]
+ .parse::<u8>()
+ .map_err(|_| ColorParseError::InvalidRgb)?;
+
+ Ok(Color::Rgb(r, g, b))
+}
+
+fn parse_hsl_color(s: &str) -> Result<Color, ColorParseError> {
+ let content = s.trim_start_matches("hsl(").trim_end_matches(')');
+ let parts: Vec<&str> = content.split(',').map(|p| p.trim()).collect();
+
+ if parts.len() != 3 {
+ return Err(ColorParseError::InvalidHsl);
+ }
+
+ let h = parts[0]
+ .trim_end_matches(|c: char| !c.is_ascii_digit())
+ .parse::<u16>()
+ .map_err(|_| ColorParseError::InvalidHsl)?;
+ let s = parts[1]
+ .trim_end_matches('%')
+ .parse::<u8>()
+ .map_err(|_| ColorParseError::InvalidHsl)?;
+ let l = parts[2]
+ .trim_end_matches('%')
+ .parse::<u8>()
+ .map_err(|_| ColorParseError::InvalidHsl)?;
+
+ if h > 360 || s > 100 || l > 100 {
+ return Err(ColorParseError::InvalidHsl);
+ }
+
+ Ok(Color::Hsl(h, s, l))
+}
+
+impl Color {
+ /// Convert color to RGB tuple
+ pub fn to_rgb(&self) -> (u8, u8, u8) {
+ match self {
+ Color::Named(named) => named.to_rgb(),
+ Color::Hex(hex) => {
+ let hex = hex.trim_start_matches('#');
+ match hex.len() {
+ 3 => {
+ // #RGB format
+ let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap_or(0);
+ let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap_or(0);
+ let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap_or(0);
+ (r, g, b)
+ }
+ 6 => {
+ // #RRGGBB format
+ let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
+ let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
+ let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
+ (r, g, b)
+ }
+ _ => (0, 0, 0),
+ }
+ }
+ Color::Rgb(r, g, b) => (*r, *g, *b),
+ Color::Hsl(h, s, l) => hsl_to_rgb(*h, *s, *l),
+ Color::Ansi(code) => ansi_to_rgb(*code),
+ Color::Truecolor(r, g, b) => (*r, *g, *b),
+ }
+ }
+
+ /// Convert color to ANSI escape code for foreground
+ pub fn to_ansi_fg(&self, color_mode: ColorMode) -> String {
+ match color_mode {
+ ColorMode::Truecolor => self.to_truecolor_fg(),
+ ColorMode::Ansi256 => self.to_ansi256_fg(),
+ ColorMode::Ansi16 => self.to_ansi16_fg(),
+ ColorMode::NoColor => "".to_string(),
+ }
+ }
+
+ fn to_truecolor_fg(&self) -> String {
+ let (r, g, b) = self.to_rgb();
+ format!("\x1b[38;2;{};{};{}m", r, g, b)
+ }
+
+ fn to_ansi256_fg(&self) -> String {
+ let (r, g, b) = self.to_rgb();
+ let code = rgb_to_ansi256(r, g, b);
+ format!("\x1b[38;5;{}m", code)
+ }
+
+ fn to_ansi16_fg(&self) -> String {
+ let (r, g, b) = self.to_rgb();
+ let code = rgb_to_ansi16(r, g, b);
+ format!("\x1b[{}m", code)
+ }
+}
+
+impl NamedColor {
+ pub fn to_rgb(self) -> (u8, u8, u8) {
+ match self {
+ NamedColor::Black => (0, 0, 0),
+ NamedColor::Red => (197, 15, 31),
+ NamedColor::Green => (19, 161, 14),
+ NamedColor::Yellow => (193, 156, 0),
+ NamedColor::Blue => (0, 55, 218),
+ NamedColor::Magenta => (136, 23, 152),
+ NamedColor::Cyan => (58, 150, 221),
+ NamedColor::White => (204, 204, 204),
+ NamedColor::BrightBlack => (118, 118, 118),
+ NamedColor::BrightRed => (231, 72, 86),
+ NamedColor::BrightGreen => (22, 198, 12),
+ NamedColor::BrightYellow => (249, 241, 165),
+ NamedColor::BrightBlue => (59, 120, 255),
+ NamedColor::BrightMagenta => (180, 0, 158),
+ NamedColor::BrightCyan => (97, 214, 214),
+ NamedColor::BrightWhite => (242, 242, 242),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum ColorMode {
+ Truecolor,
+ Ansi256,
+ Ansi16,
+ NoColor,
+}
+
+/// Convert HSL to RGB
+fn hsl_to_rgb(h: u16, s: u8, l: u8) -> (u8, u8, u8) {
+ let h = h as f32 / 360.0;
+ let s = s as f32 / 100.0;
+ let l = l as f32 / 100.0;
+
+ let r;
+ let g;
+ let b;
+
+ if s == 0.0 {
+ r = l;
+ g = l;
+ b = l;
+ } else {
+ let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 {
+ let mut t = t;
+ if t < 0.0 {
+ t += 1.0;
+ }
+ if t > 1.0 {
+ t -= 1.0;
+ }
+ if t < 1.0 / 6.0 {
+ return p + (q - p) * 6.0 * t;
+ }
+ if t < 1.0 / 2.0 {
+ return q;
+ }
+ if t < 2.0 / 3.0 {
+ return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
+ }
+ p
+ };
+
+ let q = if l < 0.5 {
+ l * (1.0 + s)
+ } else {
+ l + s - l * s
+ };
+ let p = 2.0 * l - q;
+
+ r = hue_to_rgb(p, q, h + 1.0 / 3.0);
+ g = hue_to_rgb(p, q, h);
+ b = hue_to_rgb(p, q, h - 1.0 / 3.0);
+ }
+
+ (
+ (r * 255.0).round() as u8,
+ (g * 255.0).round() as u8,
+ (b * 255.0).round() as u8,
+ )
+}
+
+/// Convert RGB to ANSI 256 color code
+fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
+ // Calculate grayscale value
+ let gray = (r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114).round() as u8;
+
+ // Check if it's a grayscale color
+ if r == g && g == b {
+ // Grayscale ramp (232-255)
+ if gray <= 8 {
+ 16
+ } else if gray >= 248 {
+ 231
+ } else {
+ ((gray as f32 - 8.0) / 247.0 * 24.0).round() as u8 + 232
+ }
+ } else {
+ // Color cube (16-231)
+ let r = (r as f32 / 255.0 * 5.0).round() as u8;
+ let g = (g as f32 / 255.0 * 5.0).round() as u8;
+ let b = (b as f32 / 255.0 * 5.0).round() as u8;
+
+ 16 + (r * 36) + (g * 6) + b
+ }
+}
+
+/// Convert RGB to ANSI 16 color code
+fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> u8 {
+ // Calculate brightness
+ let brightness = (r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114).round() as u8;
+
+ // Determine if it's bright or dark
+ let is_bright = brightness > 128;
+
+ // Determine base color
+ let base_color = if r > g && r > b {
+ // Red dominant
+ if r > 192 && g < 64 && b < 64 {
+ 1 // Red
+ } else if r > g && r > b {
+ 3 // Yellow
+ } else {
+ 7 // White
+ }
+ } else if g > r && g > b {
+ // Green dominant
+ if g > 192 && r < 64 && b < 64 {
+ 2 // Green
+ } else if g > b {
+ 3 // Yellow
+ } else {
+ 6 // Cyan
+ }
+ } else {
+ // Blue dominant
+ if b > 192 && r < 64 && g < 64 {
+ 4 // Blue
+ } else if b > r {
+ 6 // Cyan
+ } else {
+ 5 // Magenta
+ }
+ };
+
+ if is_bright {
+ base_color + 8 // Bright colors are 8-15
+ } else {
+ base_color // Normal colors are 0-7
+ }
+}
+
+/// Convert ANSI color code to RGB
+fn ansi_to_rgb(code: u8) -> (u8, u8, u8) {
+ match code {
+ // Standard 16 colors
+ 0 => (0, 0, 0), // Black
+ 1 => (128, 0, 0), // Red
+ 2 => (0, 128, 0), // Green
+ 3 => (128, 128, 0), // Yellow
+ 4 => (0, 0, 128), // Blue
+ 5 => (128, 0, 128), // Magenta
+ 6 => (0, 128, 128), // Cyan
+ 7 => (192, 192, 192), // White
+ 8 => (128, 128, 128), // Bright Black
+ 9 => (255, 0, 0), // Bright Red
+ 10 => (0, 255, 0), // Bright Green
+ 11 => (255, 255, 0), // Bright Yellow
+ 12 => (0, 0, 255), // Bright Blue
+ 13 => (255, 0, 255), // Bright Magenta
+ 14 => (0, 255, 255), // Bright Cyan
+ 15 => (255, 255, 255), // Bright White
+
+ // 256-color mode
+ 16..=231 => {
+ // Color cube
+ let code = code - 16;
+ let r = ((code / 36) % 6) * 51;
+ let g = ((code / 6) % 6) * 51;
+ let b = (code % 6) * 51;
+ (r, g, b)
+ }
+
+ 232..=255 => {
+ // Grayscale ramp
+ let gray = (code - 232) * 10 + 8;
+ (gray, gray, gray)
+ }
+ }
+}
+
+/// Detect the best available color mode for the terminal
+pub fn detect_color_mode() -> ColorMode {
+ if std::env::var("NO_COLOR").is_ok() {
+ return ColorMode::NoColor;
+ }
+
+ if let Ok(colorterm) = std::env::var("COLORTERM")
+ && (colorterm.contains("truecolor") || colorterm.contains("24bit"))
+ {
+ return ColorMode::Truecolor;
+ }
+
+ if let Ok(term) = std::env::var("TERM") {
+ if term.contains("256color") {
+ return ColorMode::Ansi256;
+ }
+ if term.contains("color") {
+ return ColorMode::Ansi16;
+ }
+ }
+
+ // Default fallback
+ ColorMode::Ansi16
+}
+
+/// Linear interpolation between two colors in HSV space
+pub fn lerp_color_hsv(start: &Color, end: &Color, t: f32) -> Color {
+ let (h1, s1, v1) = rgb_to_hsv(start.to_rgb());
+ let (h2, s2, v2) = rgb_to_hsv(end.to_rgb());
+
+ // Handle hue interpolation correctly (shortest path around the color wheel)
+ let h_diff = (h2 - h1 + 180.0) % 360.0 - 180.0;
+ let h = (h1 + h_diff * t + 360.0) % 360.0;
+
+ let s = s1 + (s2 - s1) * t;
+ let v = v1 + (v2 - v1) * t;
+
+ let (r, g, b) = hsv_to_rgb(h, s, v);
+ Color::Truecolor(r, g, b)
+}
+
+/// Convert RGB to HSV
+fn rgb_to_hsv((r, g, b): (u8, u8, u8)) -> (f32, f32, f32) {
+ let r = r as f32 / 255.0;
+ let g = g as f32 / 255.0;
+ let b = b as f32 / 255.0;
+
+ let max = r.max(g).max(b);
+ let min = r.min(g).min(b);
+ let delta = max - min;
+
+ let h = if delta == 0.0 {
+ 0.0
+ } else if max == r {
+ 60.0 * (((g - b) / delta) % 6.0)
+ } else if max == g {
+ 60.0 * (((b - r) / delta) + 2.0)
+ } else {
+ 60.0 * (((r - g) / delta) + 4.0)
+ };
+
+ let h = if h < 0.0 { h + 360.0 } else { h };
+
+ let s = if max == 0.0 { 0.0 } else { delta / max };
+ let v = max;
+
+ (h, s, v)
+}
+
+/// Convert HSV to RGB
+fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
+ let c = v * s;
+ let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
+ let m = v - c;
+
+ let (r, g, b) = match h {
+ h if (0.0..60.0).contains(&h) => (c, x, 0.0),
+ h if (60.0..120.0).contains(&h) => (x, c, 0.0),
+ h if (120.0..180.0).contains(&h) => (0.0, c, x),
+ h if (180.0..240.0).contains(&h) => (0.0, x, c),
+ h if (240.0..300.0).contains(&h) => (x, 0.0, c),
+ _ => (c, 0.0, x),
+ };
+
+ (
+ ((r + m) * 255.0).round() as u8,
+ ((g + m) * 255.0).round() as u8,
+ ((b + m) * 255.0).round() as u8,
+ )
+}
diff --git a/src/main.rs b/src/main.rs
index 39fc9fe..023f7f3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,41 +1,302 @@
-use clap::Parser;
+use clap::{Parser, ValueEnum};
use crossterm::terminal::size;
+use std::error::Error;
+use std::str::FromStr;
use unicode_width::UnicodeWidthStr;
+mod color;
mod mikuart;
+use color::{Color, ColorMode, detect_color_mode, lerp_color_hsv};
use mikuart::get_miku_art;
+#[derive(Debug, Clone, ValueEnum)]
+enum ColorFormat {
+ Hex,
+ Rgb,
+ Hsl,
+ Ansi,
+ Named,
+}
+
#[derive(Parser)]
#[command(version, about, long_about = None)]
-struct Args {
+pub struct Args {
/// Text to display in the speech bubble
- text: Option<String>,
+ pub text: Option<String>,
/// Style of the Miku art. A random one is chosen if not specified
#[arg(short, long)]
- style: Option<i32>,
+ pub style: Option<i32>,
/// List all available art styles with their indices
#[arg(short, long)]
- list: bool,
+ pub list: bool,
+
+ /// Apply a smooth rainbow gradient across the ASCII art
+ #[arg(long)]
+ pub rainbow: bool,
+
+ /// Saturation level for rainbow gradient (0-100)
+ #[arg(long, default_value_t = 100)]
+ pub saturation: u8,
+
+ /// Brightness level for rainbow gradient (0-100)
+ #[arg(long, default_value_t = 50)]
+ pub brightness: u8,
+
+ /// Override with a single solid color
+ #[arg(long, value_name = "COLOR")]
+ pub color: Option<String>,
+
+ /// Define a custom two-color gradient (e.g., --gradient red:blue)
+ #[arg(long, value_name = "START:END")]
+ pub gradient: Option<String>,
+
+ /// Disable all coloring (respects NO_COLOR environment variable)
+ #[arg(long)]
+ pub no_color: bool,
}
-fn main() -> Result<(), Box<dyn std::error::Error>> {
- let args = Args::parse();
+#[derive(Debug)]
+pub struct ColorConfig {
+ pub mode: ColorMode,
+ pub rainbow: bool,
+ pub saturation: u8,
+ pub brightness: u8,
+ pub single_color: Option<Color>,
+ pub gradient: Option<(Color, Color)>,
+ pub color_cache: Vec<Color>,
+}
- if args.list {
- list_all_art_styles();
- return Ok(());
+pub fn parse_color_args(args: &Args) -> Result<ColorConfig, Box<dyn Error>> {
+ // Detect color mode
+ let mut color_mode = detect_color_mode();
+
+ // Override with --no-color if specified
+ if args.no_color {
+ color_mode = ColorMode::NoColor;
}
- let text = args.text.unwrap_or_else(|| {
- eprintln!("Error: Text is required when not using --list");
+ // Parse single color if provided
+ let single_color = if let Some(color_str) = args.color.as_deref() {
+ match Color::from_str(color_str) {
+ Ok(color) => Some(color),
+ Err(e) => {
+ eprintln!("Error parsing color '{}': {}", color_str, e);
+ eprintln!("Valid color formats:");
+ eprintln!(" - Named colors: red, blue, green, etc.");
+ eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)");
+ eprintln!(" - RGB: R,G,B (e.g., 255,0,128)");
+ eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))");
+ eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)");
+ std::process::exit(1);
+ }
+ }
+ } else {
+ None
+ };
+
+ // Parse gradient if provided
+ let gradient = if let Some(gradient_str) = args.gradient.as_deref() {
+ let parts: Vec<&str> = gradient_str.split(':').collect();
+ if parts.len() != 2 {
+ eprintln!("Error: Gradient format should be START:END (e.g., --gradient red:blue)");
+ std::process::exit(1);
+ }
+
+ let start = match Color::from_str(parts[0]) {
+ Ok(color) => color,
+ Err(e) => {
+ eprintln!("Error parsing gradient start color '{}': {}", parts[0], e);
+ eprintln!("Valid color formats:");
+ eprintln!(" - Named colors: red, blue, green, etc.");
+ eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)");
+ eprintln!(" - RGB: R,G,B (e.g., 255,0,128)");
+ eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))");
+ eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)");
+ std::process::exit(1);
+ }
+ };
+
+ let end = match Color::from_str(parts[1]) {
+ Ok(color) => color,
+ Err(e) => {
+ eprintln!("Error parsing gradient end color '{}': {}", parts[1], e);
+ eprintln!("Valid color formats:");
+ eprintln!(" - Named colors: red, blue, green, etc.");
+ eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)");
+ eprintln!(" - RGB: R,G,B (e.g., 255,0,128)");
+ eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))");
+ eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)");
+ std::process::exit(1);
+ }
+ };
+
+ Some((start, end))
+ } else {
+ None
+ };
+
+ // Validate saturation and brightness
+ let saturation = args.saturation.clamp(0, 100);
+ let brightness = args.brightness.clamp(0, 100);
+
+ // Check for conflicting options
+ if args.rainbow && single_color.is_some() {
+ eprintln!("Error: Cannot use --rainbow and --color together");
std::process::exit(1);
- });
- let style = args.style.unwrap_or(-1); // Default to -1 to select a random style
- draw_miku_says(&text, style)?;
+ }
- Ok(())
+ if args.rainbow && gradient.is_some() {
+ eprintln!("Error: Cannot use --rainbow and --gradient together");
+ std::process::exit(1);
+ }
+
+ if single_color.is_some() && gradient.is_some() {
+ eprintln!("Error: Cannot use --color and --gradient together");
+ std::process::exit(1);
+ }
+
+ Ok(ColorConfig {
+ mode: color_mode,
+ rainbow: args.rainbow,
+ saturation,
+ brightness,
+ single_color,
+ gradient,
+ color_cache: Vec::new(),
+ })
+}
+
+pub fn get_speech_bubble_lines(text: &str) -> Vec<String> {
+ const MAX_BUBBLE_WIDTH: usize = 50;
+ const MIN_BUBBLE_WIDTH: usize = 20;
+
+ let temp_lines = wrap_text(text, MAX_BUBBLE_WIDTH - 4);
+
+ let content_width = temp_lines
+ .iter()
+ .map(|line| line.width())
+ .max()
+ .unwrap_or(0);
+
+ let bubble_width = (content_width + 4).clamp(MIN_BUBBLE_WIDTH, MAX_BUBBLE_WIDTH);
+
+ let lines = wrap_text(text, bubble_width - 4);
+ let mut bubble_lines = Vec::new();
+
+ let top_left = '╭';
+ let top_right = '╮';
+ let bottom_left = '╰';
+ let bottom_right = '╯';
+ let horizontal = '─';
+ let vertical = '│';
+
+ bubble_lines.push(format!(
+ "{}{}{}",
+ top_left,
+ horizontal.to_string().repeat(bubble_width),
+ top_right
+ ));
+
+ for line in &lines {
+ let line_display_width = line.width();
+ let padding_needed = (bubble_width - 2).saturating_sub(line_display_width);
+ let left_padding = padding_needed / 2;
+ let right_padding = padding_needed - left_padding;
+
+ bubble_lines.push(format!(
+ "{} {}{}{} {}",
+ vertical,
+ " ".repeat(left_padding),
+ line,
+ " ".repeat(right_padding),
+ vertical
+ ));
+ }
+
+ bubble_lines.push(format!(
+ "{}{}{}",
+ bottom_left,
+ horizontal.to_string().repeat(bubble_width),
+ bottom_right
+ ));
+
+ bubble_lines.push("\\".to_string());
+ bubble_lines.push(" \\".to_string());
+
+ bubble_lines
+}
+
+pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
+ let mut lines = Vec::new();
+ let words: Vec<&str> = text.split_whitespace().collect();
+ let mut current_line = String::new();
+
+ for word in words {
+ let word_width = word.width();
+ let current_width = current_line.width();
+
+ if current_width + word_width < max_width {
+ if !current_line.is_empty() {
+ current_line.push(' ');
+ }
+ current_line.push_str(word);
+ } else {
+ if !current_line.is_empty() {
+ lines.push(current_line);
+ }
+ current_line = word.to_string();
+ }
+ }
+
+ if !current_line.is_empty() {
+ lines.push(current_line);
+ }
+
+ lines
+}
+
+pub fn generate_rainbow_cache(color_config: &mut ColorConfig, total_chars: usize) {
+ color_config.color_cache.clear();
+
+ if total_chars == 0 {
+ return;
+ }
+
+ // Generate smooth rainbow gradient across all characters
+ for i in 0..total_chars {
+ let t = i as f32 / total_chars as f32;
+ let hue = t * 360.0; // Full hue spectrum from 0 to 360
+
+ // Create color with specified saturation and brightness
+ let color = Color::Hsl(
+ hue.round() as u16,
+ color_config.saturation,
+ color_config.brightness,
+ );
+ color_config.color_cache.push(color);
+ }
+}
+
+pub fn generate_gradient_cache(
+ color_config: &mut ColorConfig,
+ start: &Color,
+ end: &Color,
+ total_chars: usize,
+) {
+ color_config.color_cache.clear();
+
+ if total_chars == 0 {
+ return;
+ }
+
+ // Generate smooth gradient across all characters
+ for i in 0..total_chars {
+ let t = i as f32 / total_chars as f32;
+ let color = lerp_color_hsv(start, end, t);
+ color_config.color_cache.push(color);
+ }
}
fn list_all_art_styles() {
@@ -56,12 +317,42 @@ fn list_all_art_styles() {
}
println!("\nUse --style <number> to select a specific style, or omit for random selection.");
+ println!("\nColor Options:");
+ println!(" --rainbow Apply a smooth rainbow gradient");
+ println!(" --saturation <0-100> Set rainbow saturation level (default: 100)");
+ println!(" --brightness <0-100> Set rainbow brightness level (default: 50)");
+ println!(
+ " --color <COLOR> Set a single solid color (supports named colors, hex, rgb, hsl, ansi)"
+ );
+ println!(" --gradient <START:END> Define a custom two-color gradient");
+ println!(" --no-color Disable all coloring");
+ println!("\nColor Format Examples:");
+ println!(" --color red Named color");
+ println!(" --color #FF0000 Hex color");
+ println!(" --color 255,0,128 RGB tuple");
+ println!(" --color hsl(180,100%,50%) HSL value");
+ println!(" --color 93 ANSI color code");
+ println!(" --gradient red:blue Custom gradient");
}
-fn draw_miku_says(text: &str, style: i32) -> Result<(), Box<dyn std::error::Error>> {
+fn draw_miku_says(
+ text: &str,
+ style: i32,
+ color_config: &mut ColorConfig,
+) -> Result<(), Box<dyn std::error::Error>> {
let speech_bubble_lines = get_speech_bubble_lines(text);
let miku_art = get_miku_art(Some(style), None);
+ // Calculate total character count for color distribution
+ let total_chars: usize = miku_art.iter().map(|line| line.chars().count()).sum();
+
+ // Generate color cache if needed
+ if color_config.rainbow {
+ generate_rainbow_cache(color_config, total_chars);
+ } else if let Some((start, end)) = color_config.gradient.clone() {
+ generate_gradient_cache(color_config, &start, &end, total_chars);
+ }
+
let (window_width, window_height) = size()?;
let available_width = window_width as usize;
@@ -112,6 +403,9 @@ fn draw_miku_says(text: &str, style: i32) -> Result<(), Box<dyn std::error::Erro
println!();
}
+ // Track color index for non-whitespace characters
+ let mut color_index = 0;
+
for (i, line) in all_content_lines.iter().enumerate() {
let is_bubble_line = i < bubble_line_count;
let is_tail_line =
@@ -128,7 +422,37 @@ fn draw_miku_says(text: &str, style: i32) -> Result<(), Box<dyn std::error::Erro
}
} else {
let horizontal_padding = " ".repeat(overall_left_padding);
- println!("{horizontal_padding}{line}");
+
+ // Apply coloring to Miku art lines
+ if color_config.mode != ColorMode::NoColor && !color_config.color_cache.is_empty() {
+ let mut colored_line = String::new();
+ colored_line.push_str(&horizontal_padding);
+
+ for c in line.chars() {
+ if c.is_whitespace() {
+ colored_line.push(c);
+ } else if color_index < color_config.color_cache.len() {
+ let color = &color_config.color_cache[color_index];
+ let ansi_code = color.to_ansi_fg(color_config.mode);
+ colored_line.push_str(&ansi_code);
+ colored_line.push(c);
+ colored_line.push_str("\x1b[0m"); // Reset
+ color_index += 1;
+ } else {
+ colored_line.push(c);
+ }
+ }
+
+ println!("{}", colored_line);
+ } else if let Some(single_color) = &color_config.single_color {
+ // Apply single solid color
+ let ansi_code = single_color.to_ansi_fg(color_config.mode);
+ let reset_code = "\x1b[0m";
+ println!("{}{}{}{}", horizontal_padding, ansi_code, line, reset_code);
+ } else {
+ // No coloring
+ println!("{}{}", horizontal_padding, line);
+ }
}
}
@@ -139,91 +463,266 @@ fn draw_miku_says(text: &str, style: i32) -> Result<(), Box<dyn std::error::Erro
Ok(())
}
-fn get_speech_bubble_lines(text: &str) -> Vec<String> {
- const MAX_BUBBLE_WIDTH: usize = 50;
- const MIN_BUBBLE_WIDTH: usize = 20;
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+ let args = Args::parse();
- let temp_lines = wrap_text(text, MAX_BUBBLE_WIDTH - 4);
+ if args.list {
+ list_all_art_styles();
+ return Ok(());
+ }
- let content_width = temp_lines
- .iter()
- .map(|line| line.width())
- .max()
- .unwrap_or(0);
+ let text = args.text.clone().unwrap_or_else(|| {
+ eprintln!("Error: Text is required when not using --list");
+ std::process::exit(1);
+ });
- let bubble_width = (content_width + 4).clamp(MIN_BUBBLE_WIDTH, MAX_BUBBLE_WIDTH);
+ let style = args.style.unwrap_or(-1); // Default to -1 to select a random style
- let lines = wrap_text(text, bubble_width - 4);
- let mut bubble_lines = Vec::new();
+ // Parse color-related arguments
+ let mut color_config = parse_color_args(&args)?;
- let top_left = '╭';
- let top_right = '╮';
- let bottom_left = '╰';
- let bottom_right = '╯';
- let horizontal = '─';
- let vertical = '│';
+ draw_miku_says(&text, style, &mut color_config)?;
- bubble_lines.push(format!(
- "{}{}{}",
- top_left,
- horizontal.to_string().repeat(bubble_width),
- top_right
- ));
+ Ok(())
+}
- for line in &lines {
- let line_display_width = line.width();
- let padding_needed = (bubble_width - 2).saturating_sub(line_display_width);
- let left_padding = padding_needed / 2;
- let right_padding = padding_needed - left_padding;
+/// MARK: Tests
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::color::{Color, ColorMode};
- bubble_lines.push(format!(
- "{} {}{}{} {}",
- vertical,
- " ".repeat(left_padding),
- line,
- " ".repeat(right_padding),
- vertical
- ));
+ #[test]
+ fn test_wrap_text_basic() {
+ let text = "Hello world this is a test";
+ let wrapped = wrap_text(text, 15);
+ assert_eq!(wrapped, vec!["Hello world", "this is a test"]);
}
- bubble_lines.push(format!(
- "{}{}{}",
- bottom_left,
- horizontal.to_string().repeat(bubble_width),
- bottom_right
- ));
+ #[test]
+ fn test_wrap_text_empty() {
+ let text = "";
+ let wrapped = wrap_text(text, 10);
+ assert_eq!(wrapped, Vec::<String>::new());
+ }
- bubble_lines.push("\\".to_string());
- bubble_lines.push(" \\".to_string());
+ #[test]
+ fn test_wrap_text_long_word() {
+ let text = "ThisIsAVeryLongWordThatExceedsMaxWidth";
+ let wrapped = wrap_text(text, 10);
+ assert_eq!(wrapped, vec!["ThisIsAVeryLongWordThatExceedsMaxWidth"]);
+ }
- bubble_lines
-}
+ #[test]
+ fn test_wrap_text_exact_width() {
+ let text = "Hello world";
+ let wrapped = wrap_text(text, 11);
+ assert_eq!(wrapped, vec!["Hello world"]);
+ }
-fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
- let mut lines = Vec::new();
- let words: Vec<&str> = text.split_whitespace().collect();
- let mut current_line = String::new();
+ #[test]
+ fn test_get_speech_bubble_lines_basic() {
+ let text = "Hello";
+ let bubble = get_speech_bubble_lines(text);
- for word in words {
- let word_width = word.width();
- let current_width = current_line.width();
+ // Should have: top border, text line, bottom border, 2 tail lines
+ assert_eq!(bubble.len(), 5);
- if current_width + word_width < max_width {
- if !current_line.is_empty() {
- current_line.push(' ');
- }
- current_line.push_str(word);
- } else {
- if !current_line.is_empty() {
- lines.push(current_line);
- }
- current_line = word.to_string();
- }
+ // Check top border
+ assert!(bubble[0].starts_with('╭'));
+ assert!(bubble[0].ends_with('╮'));
+
+ // Check text line
+ assert!(bubble[1].contains("Hello"));
+
+ // Check bottom border
+ assert!(bubble[2].starts_with('╰'));
+ assert!(bubble[2].ends_with('╯'));
+
+ // Check tail
+ assert_eq!(bubble[3], "\\");
+ assert_eq!(bubble[4], " \\");
}
- if !current_line.is_empty() {
- lines.push(current_line);
+ #[test]
+ fn test_get_speech_bubble_lines_long_text() {
+ let text = "This is a very long text that should be wrapped into multiple lines";
+ let bubble = get_speech_bubble_lines(text);
+
+ // Should have: top border, multiple text lines, bottom border, 2 tail lines
+ assert!(bubble.len() > 5);
+
+ // Check top border
+ assert!(bubble[0].starts_with('╭'));
+ assert!(bubble[0].ends_with('╮'));
+
+ // Check bottom border
+ let bottom_idx = bubble.len() - 3;
+ assert!(bubble[bottom_idx].starts_with('╰'));
+ assert!(bubble[bottom_idx].ends_with('╯'));
}
- lines
+ #[test]
+ fn test_parse_color_args_no_color() {
+ let args = Args {
+ text: Some("test".to_string()),
+ style: None,
+ list: false,
+ rainbow: false,
+ saturation: 100,
+ brightness: 50,
+ color: None,
+ gradient: None,
+ no_color: false,
+ };
+
+ let config = parse_color_args(&args).unwrap();
+ // Don't assert exact color mode since it depends on environment
+ assert!(!config.rainbow);
+ assert_eq!(config.saturation, 100);
+ assert_eq!(config.brightness, 50);
+ assert!(config.single_color.is_none());
+ assert!(config.gradient.is_none());
+ assert!(config.color_cache.is_empty());
+ }
+
+ #[test]
+ fn test_parse_color_args_no_color_explicit() {
+ let args = Args {
+ text: Some("test".to_string()),
+ style: None,
+ list: false,
+ rainbow: false,
+ saturation: 100,
+ brightness: 50,
+ color: None,
+ gradient: None,
+ no_color: true,
+ };
+
+ let config = parse_color_args(&args).unwrap();
+ assert_eq!(config.mode, ColorMode::NoColor);
+ }
+
+ #[test]
+ fn test_wrap_text_edge_cases() {
+ // Test with single very long word
+ let text = "Supercalifragilisticexpialidocious";
+ let wrapped = wrap_text(text, 10);
+ assert_eq!(wrapped, vec!["Supercalifragilisticexpialidocious"]);
+
+ // Test with exact width match
+ let text = "Hello world";
+ let wrapped = wrap_text(text, 11);
+ assert_eq!(wrapped, vec!["Hello world"]);
+
+ // Test with multiple spaces
+ let text = "Hello world";
+ let wrapped = wrap_text(text, 10);
+ assert_eq!(wrapped, vec!["Hello", "world"]);
+ }
+
+ #[test]
+ fn test_get_speech_bubble_lines_edge_cases() {
+ // Test with empty string
+ let bubble = get_speech_bubble_lines("");
+ assert_eq!(bubble.len(), 4); // Should have: top, empty line, bottom, tail (no extra tail line)
+
+ // Test with very long word
+ let text = "Supercalifragilisticexpialidocious";
+ let bubble = get_speech_bubble_lines(text);
+ assert!(bubble.len() >= 5);
+ assert!(bubble[0].len() >= 24); // Should accommodate the long word
+ }
+
+ #[test]
+ fn test_generate_rainbow_cache_edge_cases() {
+ let mut config = ColorConfig {
+ mode: ColorMode::Truecolor,
+ rainbow: true,
+ saturation: 100,
+ brightness: 50,
+ single_color: None,
+ gradient: None,
+ color_cache: vec![Color::Hex("#000000".to_string())], // Pre-filled
+ };
+
+ // Test with 0 characters
+ generate_rainbow_cache(&mut config, 0);
+ assert_eq!(config.color_cache.len(), 0);
+
+ // Test with 1 character
+ generate_rainbow_cache(&mut config, 1);
+ assert_eq!(config.color_cache.len(), 1);
+ }
+
+ #[test]
+ fn test_generate_gradient_cache_edge_cases() {
+ let mut config = ColorConfig {
+ mode: ColorMode::Truecolor,
+ rainbow: false,
+ saturation: 100,
+ brightness: 50,
+ single_color: None,
+ gradient: None,
+ color_cache: vec![Color::Hex("#000000".to_string())], // Pre-filled
+ };
+
+ let start = Color::Hex("#FF0000".to_string());
+ let end = Color::Hex("#0000FF".to_string());
+
+ // Test with 0 characters
+ generate_gradient_cache(&mut config, &start, &end, 0);
+ assert_eq!(config.color_cache.len(), 0);
+
+ // Test with 1 character
+ generate_gradient_cache(&mut config, &start, &end, 1);
+ assert_eq!(config.color_cache.len(), 1);
+ }
+
+ #[test]
+ fn test_generate_rainbow_cache() {
+ let mut config = ColorConfig {
+ mode: ColorMode::Truecolor,
+ rainbow: true,
+ saturation: 100,
+ brightness: 50,
+ single_color: None,
+ gradient: None,
+ color_cache: Vec::new(),
+ };
+
+ generate_rainbow_cache(&mut config, 10);
+ assert_eq!(config.color_cache.len(), 10);
+
+ // Check that colors are different (rainbow gradient)
+ let first_color = &config.color_cache[0];
+ let last_color = &config.color_cache[9];
+ assert_ne!(first_color, last_color);
+ }
+
+ #[test]
+ fn test_generate_gradient_cache() {
+ let mut config = ColorConfig {
+ mode: ColorMode::Truecolor,
+ rainbow: false,
+ saturation: 100,
+ brightness: 50,
+ single_color: None,
+ gradient: None,
+ color_cache: Vec::new(),
+ };
+
+ let start = Color::Hex("#FF0000".to_string()); // Red
+ let end = Color::Hex("#0000FF".to_string()); // Blue
+
+ generate_gradient_cache(&mut config, &start, &end, 10);
+ assert_eq!(config.color_cache.len(), 10);
+
+ // Check that first color is close to start
+ let first_color = &config.color_cache[0];
+ // Check that last color is close to end
+ let last_color = &config.color_cache[9];
+ assert_ne!(first_color, last_color);
+ }
}
diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs
new file mode 100644
index 0000000..c589db0
--- /dev/null
+++ b/tests/integration_tests.rs
@@ -0,0 +1,55 @@
+use std::process::Command;
+use std::str;
+
+#[test]
+fn test_cli_list_styles() {
+ let output = Command::new("target/debug/mikusays")
+ .arg("--list")
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+ let stdout = str::from_utf8(&output.stdout).expect("Invalid UTF-8");
+
+ // Should contain style listing information
+ assert!(stdout.contains("Available Miku art styles:"));
+ assert!(stdout.contains("Total styles:"));
+ assert!(stdout.contains("--- Style"));
+}
+
+#[test]
+fn test_cli_error_no_text() {
+ let output = Command::new("target/debug/mikusays")
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(!output.status.success());
+ let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
+ assert!(stderr.contains("Error: Text is required when not using --list"));
+}
+
+#[test]
+fn test_cli_error_conflicting_args() {
+ let output = Command::new("target/debug/mikusays")
+ .arg("Test")
+ .arg("--rainbow")
+ .arg("--color")
+ .arg("red")
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(!output.status.success());
+ let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
+ assert!(stderr.contains("Cannot use --rainbow and --color together"));
+}
+
+#[test]
+fn test_cli_basic_functionality() {
+ // Test that the binary runs and produces some output
+ let output = Command::new("target/debug/mikusays")
+ .arg("--list")
+ .output()
+ .expect("Failed to execute command");
+
+ assert!(output.status.success());
+}