diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fdb6cc..63e65ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,10 @@ jobs: run: cargo fmt -- --check - name: Run clippy - run: cargo clippy -- -D warnings + run: cargo clippy --all-targets -- -D warnings - name: Run tests - run: cargo test --verbose + run: cargo test --all-targets --verbose - name: Build run: cargo build --verbose diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 01aa465..c34080b 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -23,10 +23,8 @@ jobs: include: - arch: x86_64 target: x86_64-unknown-linux-gnu - deb_arch: amd64 - arch: aarch64 target: aarch64-unknown-linux-gnu - deb_arch: arm64 steps: - uses: actions/checkout@v4 @@ -55,10 +53,7 @@ jobs: DEB_PATH=$(find target/${{ matrix.target }}/debian -name "*.deb" | head -n 1) echo "path=$DEB_PATH" >> $GITHUB_OUTPUT VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') - ARCH_NAME="${{ matrix.deb_arch }}" - if [ "$ARCH_NAME" = "amd64" ]; then ARCH_NAME="x64"; fi - DEB_NAME="mikusays-debian-${ARCH_NAME}-${VERSION}.deb" - # Store version for later use + DEB_NAME="mikusays-debian-${{ matrix.arch }}-${VERSION}.deb" echo "version=$VERSION" >> $GITHUB_OUTPUT echo "name=$DEB_NAME" >> $GITHUB_OUTPUT @@ -68,29 +63,18 @@ jobs: name: deb-package-${{ matrix.arch }} path: ${{ steps.find-deb.outputs.path }} - - name: Get release upload URL - id: get-release - if: github.event_name == 'release' || github.event.inputs.release_tag - run: | - if [ "${{ github.event_name }}" = "release" ]; then - echo "upload_url=${{ github.event.release.upload_url }}" >> $GITHUB_OUTPUT - else - RELEASE_DATA=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ github.event.inputs.release_tag }}") - UPLOAD_URL=$(echo "$RELEASE_DATA" | jq -r '.upload_url') - echo "upload_url=$UPLOAD_URL" >> $GITHUB_OUTPUT - fi - - name: Upload to release if: github.event_name == 'release' || github.event.inputs.release_tag - uses: actions/upload-release-asset@v1 env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - with: - upload_url: ${{ steps.get-release.outputs.upload_url }} - asset_path: ${{ steps.find-deb.outputs.path }} - asset_name: mikusays-debian-${{ matrix.arch }}-${{ steps.find-deb.outputs.version }}.deb - asset_content_type: application/vnd.debian.binary-package + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }} + run: | + install -m 0644 "${{ steps.find-deb.outputs.path }}" \ + "$RUNNER_TEMP/${{ steps.find-deb.outputs.name }}" + gh release upload "$RELEASE_TAG" \ + "$RUNNER_TEMP/${{ steps.find-deb.outputs.name }}" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber build-rpm: name: Build RPM Package (${{ matrix.arch }}) @@ -102,10 +86,8 @@ jobs: include: - arch: x86_64 target: x86_64-unknown-linux-gnu - rpm_arch: x86_64 - arch: aarch64 target: aarch64-unknown-linux-gnu - rpm_arch: aarch64 steps: - uses: actions/checkout@v4 @@ -145,10 +127,7 @@ jobs: RPM_PATH=$(find target/${{ matrix.target }}/generate-rpm -name "*.rpm" | head -n 1) echo "path=$RPM_PATH" >> $GITHUB_OUTPUT VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') - ARCH_NAME="${{ matrix.rpm_arch }}" - if [ "$ARCH_NAME" = "x86_64" ]; then ARCH_NAME="x64"; fi - RPM_NAME="mikusays-redhat-${ARCH_NAME}-${VERSION}.rpm" - # Store version for later use + RPM_NAME="mikusays-redhat-${{ matrix.arch }}-${VERSION}.rpm" echo "version=$VERSION" >> $GITHUB_OUTPUT echo "name=$RPM_NAME" >> $GITHUB_OUTPUT @@ -158,26 +137,32 @@ jobs: name: rpm-package-${{ matrix.arch }} path: ${{ steps.find-rpm.outputs.path }} - - name: Get release upload URL - id: get-release - if: github.event_name == 'release' || github.event.inputs.release_tag - run: | - if [ "${{ github.event_name }}" = "release" ]; then - echo "upload_url=${{ github.event.release.upload_url }}" >> $GITHUB_OUTPUT - else - RELEASE_DATA=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ github.event.inputs.release_tag }}") - UPLOAD_URL=$(echo "$RELEASE_DATA" | jq -r '.upload_url') - echo "upload_url=$UPLOAD_URL" >> $GITHUB_OUTPUT - fi - - name: Upload to release if: github.event_name == 'release' || github.event.inputs.release_tag - uses: actions/upload-release-asset@v1 env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - with: - upload_url: ${{ steps.get-release.outputs.upload_url }} - asset_path: ${{ steps.find-rpm.outputs.path }} - asset_name: mikusays-redhat-${{ matrix.arch }}-${{ steps.find-rpm.outputs.version }}.rpm - asset_content_type: application/x-rpm + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }} + run: | + install -m 0644 "${{ steps.find-rpm.outputs.path }}" \ + "$RUNNER_TEMP/${{ steps.find-rpm.outputs.name }}" + gh release upload "$RELEASE_TAG" \ + "$RUNNER_TEMP/${{ steps.find-rpm.outputs.name }}" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + + notify-package-repository: + name: Notify package repository + runs-on: ubuntu-latest + needs: [build-deb, build-rpm] + if: github.event_name == 'release' || github.event.inputs.release_tag + permissions: + contents: read + steps: + - name: Dispatch exact release tag + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }} + run: | + gh api --method POST repos/xxanqw/apt-repository/dispatches \ + --raw-field event_type=new-release \ + --field "client_payload[tag_name]=$RELEASE_TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e86ee25..46d410d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,10 +113,21 @@ jobs: - name: Prepare artifact run: | - mkdir -p artifacts + mkdir -p artifacts staging version="${{ needs.extract-version.outputs.version }}" - cp target/${{ matrix.target }}/release/mikusays artifacts/mikusays-linux-${{ matrix.arch }}-$version - chmod +x artifacts/mikusays-linux-${{ matrix.arch }}-$version + stem="mikusays-linux-${{ matrix.arch }}-$version" + cp target/${{ matrix.target }}/release/mikusays "staging/$stem" + chmod 755 "staging/$stem" + tar -C staging -czf "artifacts/$stem.tar.gz" "$stem" + + - name: Verify artifact + run: | + version="${{ needs.extract-version.outputs.version }}" + stem="mikusays-linux-${{ matrix.arch }}-$version" + extract_dir="$(mktemp -d)" + tar -tzf "artifacts/$stem.tar.gz" | grep -Fx "$stem" + tar -xzf "artifacts/$stem.tar.gz" -C "$extract_dir" + test -x "$extract_dir/$stem" - name: Upload artifact uses: actions/upload-artifact@v4 @@ -150,10 +161,21 @@ jobs: - name: Prepare artifact run: | - mkdir -p artifacts + mkdir -p artifacts staging version="${{ needs.extract-version.outputs.version }}" - cp target/${{ matrix.target }}/release/mikusays artifacts/mikusays-macos-${{ matrix.arch }}-$version - chmod +x artifacts/mikusays-macos-${{ matrix.arch }}-$version + stem="mikusays-macos-${{ matrix.arch }}-$version" + cp target/${{ matrix.target }}/release/mikusays "staging/$stem" + chmod 755 "staging/$stem" + tar -C staging -czf "artifacts/$stem.tar.gz" "$stem" + + - name: Verify artifact + run: | + version="${{ needs.extract-version.outputs.version }}" + stem="mikusays-macos-${{ matrix.arch }}-$version" + extract_dir="$(mktemp -d)" + tar -tzf "artifacts/$stem.tar.gz" | grep -Fx "$stem" + tar -xzf "artifacts/$stem.tar.gz" -C "$extract_dir" + test -x "$extract_dir/$stem" - name: Upload artifact uses: actions/upload-artifact@v4 @@ -179,7 +201,7 @@ jobs: run: ls -R artifacts - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: files: artifacts/mikusays-* draft: false @@ -195,33 +217,48 @@ jobs: ### Windows Installer - **Any**: `mikusays-windows-inst-any-${{ needs.extract-version.outputs.version }}.exe` - ### Linux (binary) - - **x64**: `mikusays-linux-x64-${{ needs.extract-version.outputs.version }}` - - **ARM64**: `mikusays-linux-arm64-${{ needs.extract-version.outputs.version }}` + ### Linux (tar.gz) + - **x64**: `mikusays-linux-x64-${{ needs.extract-version.outputs.version }}.tar.gz` + - **ARM64**: `mikusays-linux-arm64-${{ needs.extract-version.outputs.version }}.tar.gz` ### Debian Package - - **x64 Debian Package**: `mikusays-debian-x64-${{ needs.extract-version.outputs.version }}.deb` - - **ARM64 Debian Package**: `mikusays-debian-arm64-${{ needs.extract-version.outputs.version }}.deb` + - **x64 Debian Package**: `mikusays-debian-x86_64-${{ needs.extract-version.outputs.version }}.deb` + - **ARM64 Debian Package**: `mikusays-debian-aarch64-${{ needs.extract-version.outputs.version }}.deb` ### RedHat (RPM) Package - - **x64 RPM Package**: `mikusays-redhat-x64-${{ needs.extract-version.outputs.version }}.rpm` - - **ARM64 RPM Package**: `mikusays-redhat-arm64-${{ needs.extract-version.outputs.version }}.rpm` + - **x64 RPM Package**: `mikusays-redhat-x86_64-${{ needs.extract-version.outputs.version }}.rpm` + - **ARM64 RPM Package**: `mikusays-redhat-aarch64-${{ needs.extract-version.outputs.version }}.rpm` ### Repository for Debian / Ubuntu / Fedora / RHEL / CentOS Information on how to add it available here https://apt.xxanqw.pp.ua - ### macOS - - **Intel (x64)**: `mikusays-macos-x64-${{ needs.extract-version.outputs.version }}` - - **Apple Silicon (ARM64)**: `mikusays-macos-arm64-${{ needs.extract-version.outputs.version }}` + ### macOS (tar.gz) + - **Intel (x64)**: `mikusays-macos-x64-${{ needs.extract-version.outputs.version }}.tar.gz` + - **Apple Silicon (ARM64)**: `mikusays-macos-arm64-${{ needs.extract-version.outputs.version }}.tar.gz` ## Usage ```bash - # Download the appropriate binary for your platform - # On Unix-like systems, make it executable: - chmod +x mikusays-* + # Download and extract the appropriate archive on Linux/macOS: + archive="mikusays-linux-x64-${{ needs.extract-version.outputs.version }}.tar.gz" + tar -xzf "$archive" # Run the program: - ./mikusays-* "Hello, World!" + "./${archive%.tar.gz}" "Hello, World!" + + # If Gatekeeper blocks an unsigned macOS binary: + xattr -d com.apple.quarantine "./${archive%.tar.gz}" ``` env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Notify package managers + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + RELEASE_TAG: v${{ needs.extract-version.outputs.version }} + run: | + gh api --method POST repos/xxanqw/scoop-bucket/dispatches \ + --raw-field event_type=new-release \ + --field "client_payload[tag_name]=$RELEASE_TAG" + gh api --method POST repos/xxanqw/homebrew-mikusays/dispatches \ + --raw-field event_type=new-release \ + --field "client_payload[tag_name]=$RELEASE_TAG" diff --git a/Cargo.lock b/Cargo.lock index 95c078e..8626b3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -252,7 +252,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "mikusays" -version = "0.1.4-2" +version = "0.1.5" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index d1ff110..3bf4f61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mikusays" -version = "0.1.4-2" +version = "0.1.5" edition = "2024" description = "A \"cowsay\" clone with Hatsune Miku ASCII art and speech bubbles." license = "MIT" diff --git a/README.md b/README.md index dd83524..81f3e15 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,23 @@ brew install mikusays - **Windows**: `mikusays-windows-*.exe` - Small note for Windows that application depends on `vcredist2022` (installing with scoop and installer resolve this dependency automatically) -- **Linux**: `mikusays-linux-*` -- **macOS**: `mikusays-macos-*` +- **Linux**: `mikusays-linux-*.tar.gz` +- **macOS**: `mikusays-macos-*.tar.gz` + +Linux and macOS archives preserve the executable permission. Extract and run one with: + +```bash +archive="mikusays-linux-x64-VERSION.tar.gz" +tar -xzf "$archive" +"./${archive%.tar.gz}" "Hello, World!" +``` + +The macOS binaries are currently unsigned. If Gatekeeper quarantines an extracted binary, +remove the quarantine attribute before running it: + +```bash +xattr -d com.apple.quarantine ./mikusays-macos-arm64-VERSION +``` Or build from source: ```bash @@ -84,6 +99,17 @@ cargo build --release ## Development +### Release automation + +Tagged releases build the platform binaries first, then trigger the Scoop bucket. +After all DEB and RPM assets have uploaded, the package workflow triggers the APT/RPM +repository with the exact tag. The `RELEASE_TOKEN` Actions secret must be able to create +repository dispatch events in `xxanqw/scoop-bucket`, `xxanqw/homebrew-mikusays`, and +`xxanqw/apt-repository`. + +The distribution repositories keep scheduled and manual workflows as recovery paths if +an event dispatch fails. + 1. Fork repository 2. Clone your fork 3. Implement your changes diff --git a/src/color.rs b/src/color.rs index 548c222..74497ab 100644 --- a/src/color.rs +++ b/src/color.rs @@ -301,14 +301,8 @@ fn hsl_to_rgb(h: u16, s: u8, l: u8) -> (u8, u8, u8) { 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; + let (r, g, b) = if s == 0.0 { + (l, l, l) } else { let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 { let mut t = t; @@ -337,10 +331,12 @@ fn hsl_to_rgb(h: u16, s: u8, l: u8) -> (u8, u8, u8) { }; 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); - } + ( + hue_to_rgb(p, q, h + 1.0 / 3.0), + hue_to_rgb(p, q, h), + hue_to_rgb(p, q, h - 1.0 / 3.0), + ) + }; ( (r * 255.0).round() as u8, @@ -493,12 +489,6 @@ pub fn detect_windows_shell() -> Option { None } -/// Convert Color to a crossterm Color (helper for use across platforms) -pub fn to_crossterm_color(color: &Color) -> crossterm::style::Color { - let (r, g, b) = color.to_rgb(); - crossterm::style::Color::Rgb { r, g, b } -} - /// Detect the parent shell name on Windows, using the sysinfo crate. pub fn detect_color_mode() -> ColorMode { if std::env::var("NO_COLOR").is_ok() { diff --git a/src/main.rs b/src/main.rs index 8a50a54..3e6c430 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,19 @@ -use clap::{Parser, ValueEnum}; +use clap::error::ErrorKind; +use clap::{CommandFactory, Parser}; use crossterm::QueueableCommand; -use crossterm::style::{Print, ResetColor, SetForegroundColor}; +use crossterm::style::{Print, ResetColor}; use crossterm::terminal::size; use std::error::Error; +use std::fmt; use std::io::{Write, stdout}; use std::str::FromStr; -use unicode_width::UnicodeWidthStr; +use unicode_width::{UnicodeWidthChar, 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)] pub struct Args { @@ -28,7 +21,7 @@ pub struct Args { pub text: Option, /// Style of the Miku art. A random one is chosen if not specified - #[arg(short, long)] + #[arg(short, long, value_parser = clap::value_parser!(i32).range(0..))] pub style: Option, /// List all available art styles with their indices @@ -36,19 +29,19 @@ pub struct Args { pub list: bool, /// Apply a smooth rainbow gradient across the ASCII art - #[arg(long)] + #[arg(long, conflicts_with_all = ["color", "gradient"])] pub rainbow: bool, /// Saturation level for rainbow gradient (0-100) - #[arg(long, default_value_t = 100)] + #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u8).range(0..=100))] pub saturation: u8, /// Brightness level for rainbow gradient (0-100) - #[arg(long, default_value_t = 50)] + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u8).range(0..=100))] pub brightness: u8, /// Override with a single solid color - #[arg(long, value_name = "COLOR")] + #[arg(long, value_name = "COLOR", conflicts_with = "gradient")] pub color: Option, /// Define a custom two-color gradient (e.g., --gradient red:blue) @@ -76,7 +69,21 @@ pub struct ColorConfig { pub debug: bool, } -pub fn parse_color_args(args: &Args) -> Result> { +#[derive(Debug, PartialEq)] +pub struct ColorConfigError(String); + +impl fmt::Display for ColorConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl Error for ColorConfigError {} + +const COLOR_FORMAT_HELP: &str = + "expected a named color, #RRGGBB, #RGB, R,G,B, hsl(H,S%,L%), or ANSI value 0-255"; + +pub fn parse_color_args(args: &Args) -> Result { // Detect color mode let mut color_mode = detect_color_mode(); @@ -86,83 +93,65 @@ pub fn parse_color_args(args: &Args) -> Result> { } // 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 - }; + let single_color = args + .color + .as_deref() + .map(|color_str| { + Color::from_str(color_str).map_err(|error| { + ColorConfigError(format!( + "invalid color '{color_str}': {error}; {COLOR_FORMAT_HELP}" + )) + }) + }) + .transpose()?; // 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_str, end_str) = gradient_str.split_once(':').ok_or_else(|| { + ColorConfigError( + "invalid gradient: expected START:END (for example, red:blue)".to_string(), + ) + })?; + + if start_str.is_empty() || end_str.is_empty() || end_str.contains(':') { + return Err(ColorConfigError( + "invalid gradient: expected exactly two colors as START:END".to_string(), + )); } - 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); - } - }; + let start = Color::from_str(start_str).map_err(|error| { + ColorConfigError(format!( + "invalid gradient start '{start_str}': {error}; {COLOR_FORMAT_HELP}" + )) + })?; + let end = Color::from_str(end_str).map_err(|error| { + ColorConfigError(format!( + "invalid gradient end '{end_str}': {error}; {COLOR_FORMAT_HELP}" + )) + })?; 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 + // Keep this validation for direct callers as well as Clap-parsed CLI input. if args.rainbow && single_color.is_some() { - eprintln!("Error: Cannot use --rainbow and --color together"); - std::process::exit(1); + return Err(ColorConfigError( + "--rainbow cannot be used with --color".to_string(), + )); } if args.rainbow && gradient.is_some() { - eprintln!("Error: Cannot use --rainbow and --gradient together"); - std::process::exit(1); + return Err(ColorConfigError( + "--rainbow cannot be used with --gradient".to_string(), + )); } if single_color.is_some() && gradient.is_some() { - eprintln!("Error: Cannot use --color and --gradient together"); - std::process::exit(1); + return Err(ColorConfigError( + "--color cannot be used with --gradient".to_string(), + )); } // Determine if ANSI is enabled (Windows toggle helper) — don't enable if --no-color @@ -174,8 +163,8 @@ pub fn parse_color_args(args: &Args) -> Result> { Ok(ColorConfig { mode: color_mode, rainbow: args.rainbow, - saturation, - brightness, + saturation: args.saturation, + brightness: args.brightness, single_color, gradient, color_cache: Vec::new(), @@ -245,24 +234,47 @@ pub fn get_speech_bubble_lines(text: &str) -> Vec { } pub fn wrap_text(text: &str, max_width: usize) -> Vec { + if max_width == 0 { + return if text.is_empty() { + Vec::new() + } else { + vec![text.to_string()] + }; + } + let mut lines = Vec::new(); - let words: Vec<&str> = text.split_whitespace().collect(); let mut current_line = String::new(); - for word in words { + for word in text.split_whitespace() { let word_width = word.width(); let current_width = current_line.width(); + let separator_width = usize::from(!current_line.is_empty()); - if current_width + word_width < max_width { + if word_width <= max_width && current_width + separator_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); + continue; + } + + if !current_line.is_empty() { + lines.push(std::mem::take(&mut current_line)); + } + + if word_width <= max_width { + current_line.push_str(word); + continue; + } + + let chunks = split_word_by_width(word, max_width); + let last_index = chunks.len().saturating_sub(1); + for (index, chunk) in chunks.into_iter().enumerate() { + if index == last_index && chunk.width() < max_width { + current_line = chunk; + } else { + lines.push(chunk); } - current_line = word.to_string(); } } @@ -273,6 +285,28 @@ pub fn wrap_text(text: &str, max_width: usize) -> Vec { lines } +fn split_word_by_width(word: &str, max_width: usize) -> Vec { + let mut chunks = Vec::new(); + let mut chunk = String::new(); + let mut chunk_width = 0; + + for character in word.chars() { + let character_width = character.width().unwrap_or(0); + if !chunk.is_empty() && chunk_width + character_width > max_width { + chunks.push(std::mem::take(&mut chunk)); + chunk_width = 0; + } + chunk.push(character); + chunk_width += character_width; + } + + if !chunk.is_empty() { + chunks.push(chunk); + } + + chunks +} + pub fn generate_rainbow_cache(color_config: &mut ColorConfig, total_chars: usize) { color_config.color_cache.clear(); @@ -309,7 +343,11 @@ pub fn generate_gradient_cache( // Generate smooth gradient across all characters for i in 0..total_chars { - let t = i as f32 / total_chars as f32; + let t = if total_chars == 1 { + 0.0 + } else { + i as f32 / (total_chars - 1) as f32 + }; let color = lerp_color_hsv(start, end, t); color_config.color_cache.push(color); } @@ -359,8 +397,15 @@ fn draw_miku_says( 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(); + // Distribute colors across characters that are actually colored. + let total_chars: usize = miku_art + .iter() + .map(|line| { + line.chars() + .filter(|character| !character.is_whitespace()) + .count() + }) + .sum(); // Generate color cache if needed if color_config.rainbow { @@ -443,36 +488,36 @@ fn draw_miku_says( if color_config.mode != ColorMode::NoColor && !color_config.color_cache.is_empty() { // Use crossterm queueing to color characters so it's platform-agnostic let mut stdout = stdout(); - stdout.queue(Print(horizontal_padding.clone())).ok(); + stdout.queue(Print(horizontal_padding.clone()))?; for c in line.chars() { if c.is_whitespace() { - stdout.queue(Print(c)).ok(); + stdout.queue(Print(c))?; } else if color_index < color_config.color_cache.len() { let color = &color_config.color_cache[color_index]; - let ct_color = color::to_crossterm_color(color); - stdout.queue(SetForegroundColor(ct_color)).ok(); - stdout.queue(Print(c)).ok(); - stdout.queue(ResetColor).ok(); + stdout.queue(Print(color.to_ansi_fg(color_config.mode)))?; + stdout.queue(Print(c))?; + stdout.queue(ResetColor)?; color_index += 1; } else { - stdout.queue(Print(c)).ok(); + stdout.queue(Print(c))?; } } - stdout.queue(Print("\n")).ok(); - stdout.flush().ok(); + stdout.queue(Print("\n"))?; + stdout.flush()?; continue; - } else if let Some(single_color) = &color_config.single_color { + } else if color_config.mode != ColorMode::NoColor + && let Some(single_color) = &color_config.single_color + { // Apply single solid color using crossterm let mut stdout = stdout(); - let ct_color = color::to_crossterm_color(single_color); - stdout.queue(Print(horizontal_padding.clone())).ok(); - stdout.queue(SetForegroundColor(ct_color)).ok(); - stdout.queue(Print(line)).ok(); - stdout.queue(ResetColor).ok(); - stdout.queue(Print("\n")).ok(); - stdout.flush().ok(); + stdout.queue(Print(horizontal_padding.clone()))?; + stdout.queue(Print(single_color.to_ansi_fg(color_config.mode)))?; + stdout.queue(Print(line))?; + stdout.queue(ResetColor)?; + stdout.queue(Print("\n"))?; + stdout.flush()?; } else { // No coloring println!("{}{}", horizontal_padding, line); @@ -500,10 +545,27 @@ fn main() -> Result<(), Box> { std::process::exit(1); }); - let style = args.style.unwrap_or(-1); // Default to -1 to select a random style + let styles_count = get_miku_art(None, Some(true)).len(); + let style = match args.style { + Some(style) if style as usize >= styles_count => Args::command() + .error( + ErrorKind::InvalidValue, + format!( + "style {style} does not exist; choose a value between 0 and {}", + styles_count - 1 + ), + ) + .exit(), + Some(style) => style, + None => -1, + }; // Parse color-related arguments - let mut color_config = parse_color_args(&args)?; + let mut color_config = parse_color_args(&args).unwrap_or_else(|error| { + Args::command() + .error(ErrorKind::ValueValidation, error.to_string()) + .exit() + }); if color_config.debug { println!("Detected color mode: {:?}", color_config.mode); @@ -518,25 +580,6 @@ fn main() -> Result<(), Box> { return Ok(()); } - // If color debug is requested, print information and exit - if args.color_debug { - eprintln!( - "Color Debug: mode={:?}, ansi_enabled={}, rainbow={}, saturation={}, brightness={}", - color_config.mode, - color_config.ansi_enabled, - color_config.rainbow, - color_config.saturation, - color_config.brightness - ); - if let Some(single) = &color_config.single_color { - eprintln!("Single color: {:?}", single); - } - if let Some((start, end)) = &color_config.gradient { - eprintln!("Gradient: {:?} -> {:?}", start, end); - } - return Ok(()); - } - draw_miku_says(&text, style, &mut color_config)?; Ok(()) @@ -566,7 +609,8 @@ mod tests { fn test_wrap_text_long_word() { let text = "ThisIsAVeryLongWordThatExceedsMaxWidth"; let wrapped = wrap_text(text, 10); - assert_eq!(wrapped, vec!["ThisIsAVeryLongWordThatExceedsMaxWidth"]); + assert_eq!(wrapped.concat(), text); + assert!(wrapped.iter().all(|line| line.width() <= 10)); } #[test] @@ -667,7 +711,8 @@ mod tests { // Test with single very long word let text = "Supercalifragilisticexpialidocious"; let wrapped = wrap_text(text, 10); - assert_eq!(wrapped, vec!["Supercalifragilisticexpialidocious"]); + assert_eq!(wrapped.concat(), text); + assert!(wrapped.iter().all(|line| line.width() <= 10)); // Test with exact width match let text = "Hello world"; @@ -690,7 +735,20 @@ mod tests { let text = "Supercalifragilisticexpialidocious"; let bubble = get_speech_bubble_lines(text); assert!(bubble.len() >= 5); - assert!(bubble[0].len() >= 24); // Should accommodate the long word + let border_width = bubble[0].width(); + assert!( + bubble[..bubble.len() - 2] + .iter() + .all(|line| line.width() == border_width) + ); + } + + #[test] + fn test_wrap_text_splits_long_unicode_word_by_display_width() { + let wrapped = wrap_text("初音ミク初音ミク初音ミク", 8); + + assert!(wrapped.len() > 1); + assert!(wrapped.iter().all(|line| line.width() <= 8)); } #[test] @@ -793,11 +851,38 @@ mod tests { 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); + assert_eq!(first_color.to_rgb(), start.to_rgb()); + assert_eq!(last_color.to_rgb(), end.to_rgb()); + } + + #[test] + fn test_parse_color_args_reports_conflicts_without_exiting() { + let args = Args { + text: Some("test".to_string()), + style: None, + list: false, + rainbow: true, + saturation: 100, + brightness: 50, + color: Some("red".to_string()), + gradient: None, + no_color: false, + color_debug: false, + }; + + let error = parse_color_args(&args).unwrap_err(); + assert_eq!(error.to_string(), "--rainbow cannot be used with --color"); + } + + #[test] + fn test_color_conversion_respects_terminal_mode() { + let red = Color::Rgb(255, 0, 0); + assert_eq!(red.to_ansi_fg(ColorMode::Truecolor), "\x1b[38;2;255;0;0m"); + assert_eq!(red.to_ansi_fg(ColorMode::Ansi256), "\x1b[38;5;196m"); + assert_eq!(red.to_ansi_fg(ColorMode::Ansi16), "\x1b[31m"); + assert_eq!(red.to_ansi_fg(ColorMode::NoColor), ""); } #[test] diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index c589db0..ea7cfc1 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,9 +1,13 @@ use std::process::Command; use std::str; +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_mikusays")) +} + #[test] fn test_cli_list_styles() { - let output = Command::new("target/debug/mikusays") + let output = command() .arg("--list") .output() .expect("Failed to execute command"); @@ -19,9 +23,7 @@ fn test_cli_list_styles() { #[test] fn test_cli_error_no_text() { - let output = Command::new("target/debug/mikusays") - .output() - .expect("Failed to execute command"); + let output = command().output().expect("Failed to execute command"); assert!(!output.status.success()); let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); @@ -30,7 +32,7 @@ fn test_cli_error_no_text() { #[test] fn test_cli_error_conflicting_args() { - let output = Command::new("target/debug/mikusays") + let output = command() .arg("Test") .arg("--rainbow") .arg("--color") @@ -40,16 +42,88 @@ fn test_cli_error_conflicting_args() { assert!(!output.status.success()); let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); - assert!(stderr.contains("Cannot use --rainbow and --color together")); + assert!(stderr.contains("cannot be used with")); } #[test] fn test_cli_basic_functionality() { // Test that the binary runs and produces some output - let output = Command::new("target/debug/mikusays") + let output = command() .arg("--list") .output() .expect("Failed to execute command"); assert!(output.status.success()); } + +#[test] +fn test_cli_no_color_overrides_solid_color() { + let output = command() + .args(["test", "--style", "0", "--color", "red", "--no-color"]) + .env("TERM", "xterm-256color") + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + assert!(!output.stdout.contains(&0x1b)); +} + +#[test] +fn test_cli_no_color_environment_overrides_gradient() { + let output = command() + .args(["test", "--style", "0", "--gradient", "red:blue"]) + .env("NO_COLOR", "1") + .env("TERM", "xterm-256color") + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + assert!(!output.stdout.contains(&0x1b)); +} + +#[test] +fn test_cli_rejects_out_of_range_style_without_panicking() { + let output = command() + .args(["test", "--style", "999"]) + .output() + .expect("Failed to execute command"); + + assert_eq!(output.status.code(), Some(2)); + let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); + assert!(stderr.contains("style 999 does not exist")); + assert!(!stderr.contains("panicked")); +} + +#[test] +fn test_cli_rejects_negative_style() { + let output = command() + .args(["test", "--style", "-1"]) + .output() + .expect("Failed to execute command"); + + assert_eq!(output.status.code(), Some(2)); + let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); + assert!(!stderr.contains("panicked")); +} + +#[test] +fn test_cli_rejects_out_of_range_saturation() { + let output = command() + .args(["test", "--rainbow", "--saturation", "101"]) + .output() + .expect("Failed to execute command"); + + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn test_cli_rejects_invalid_color() { + let output = command() + .args(["test", "--color", "not-a-color"]) + .output() + .expect("Failed to execute command"); + + assert_eq!(output.status.code(), Some(2)); + let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); + assert!(stderr.contains("invalid color 'not-a-color'")); +}