summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml4
-rw-r--r--.github/workflows/package.yml87
-rw-r--r--.github/workflows/release.yml79
-rw-r--r--Cargo.lock2
-rw-r--r--Cargo.toml2
-rw-r--r--README.md30
-rw-r--r--src/color.rs26
-rw-r--r--src/main.rs351
-rw-r--r--tests/integration_tests.rs88
9 files changed, 433 insertions, 236 deletions
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
+ - name: Upload to release
if: github.event_name == 'release' || github.event.inputs.release_tag
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ RELEASE_TAG: ${{ github.event.release.tag_name || 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
+ 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
- - name: Upload to release
- if: github.event_name == 'release' || github.event.inputs.release_tag
- uses: actions/upload-release-asset@v1
+ 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:
- 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: |
+ 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 }}"
+ 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 }}"
- 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"
+ 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<String> {
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<String>,
/// 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<i32>,
/// 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<String>,
/// 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<ColorConfig, Box<dyn Error>> {
+#[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<ColorConfig, ColorConfigError> {
// Detect color mode
let mut color_mode = detect_color_mode();
@@ -86,83 +93,65 @@ pub fn parse_color_args(args: &Args) -> Result<ColorConfig, Box<dyn Error>> {
}
// 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(),
+ )
+ })?;
- 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);
- }
- };
+ 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 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<ColorConfig, Box<dyn Error>> {
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<String> {
}
pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
+ 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<String> {
lines
}
+fn split_word_by_width(word: &str, max_width: usize) -> Vec<String> {
+ 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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'"));
+}