Prepare v0.1.5 release

This commit is contained in:
Ivan Potiienko committed 2026-08-31 22:07:34 +02:00
1 parent 3e964f98da
commit 531480d898
9 files changed
+434 -237

No files matched your search

+2 -2
View File
@@ -23,10 +23,10 @@ jobs:
run: cargo fmt -- --check run: cargo fmt -- --check
- name: Run clippy - name: Run clippy
run: cargo clippy -- -D warnings run: cargo clippy --all-targets -- -D warnings
- name: Run tests - name: Run tests
run: cargo test --verbose run: cargo test --all-targets --verbose
- name: Build - name: Build
run: cargo build --verbose run: cargo build --verbose
+37 -52
View File
@@ -23,10 +23,8 @@ jobs:
include: include:
- arch: x86_64 - arch: x86_64
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
deb_arch: amd64
- arch: aarch64 - arch: aarch64
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
deb_arch: arm64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -55,10 +53,7 @@ jobs:
DEB_PATH=$(find target/${{ matrix.target }}/debian -name "*.deb" | head -n 1) DEB_PATH=$(find target/${{ matrix.target }}/debian -name "*.deb" | head -n 1)
echo "path=$DEB_PATH" >> $GITHUB_OUTPUT echo "path=$DEB_PATH" >> $GITHUB_OUTPUT
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
ARCH_NAME="${{ matrix.deb_arch }}" DEB_NAME="mikusays-debian-${{ matrix.arch }}-${VERSION}.deb"
if [ "$ARCH_NAME" = "amd64" ]; then ARCH_NAME="x64"; fi
DEB_NAME="mikusays-debian-${ARCH_NAME}-${VERSION}.deb"
# Store version for later use
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "name=$DEB_NAME" >> $GITHUB_OUTPUT echo "name=$DEB_NAME" >> $GITHUB_OUTPUT
@@ -68,29 +63,18 @@ jobs:
name: deb-package-${{ matrix.arch }} name: deb-package-${{ matrix.arch }}
path: ${{ steps.find-deb.outputs.path }} 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 - name: Upload to release
if: github.event_name == 'release' || github.event.inputs.release_tag if: github.event_name == 'release' || github.event.inputs.release_tag
uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with: RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
upload_url: ${{ steps.get-release.outputs.upload_url }} run: |
asset_path: ${{ steps.find-deb.outputs.path }} install -m 0644 "${{ steps.find-deb.outputs.path }}" \
asset_name: mikusays-debian-${{ matrix.arch }}-${{ steps.find-deb.outputs.version }}.deb "$RUNNER_TEMP/${{ steps.find-deb.outputs.name }}"
asset_content_type: application/vnd.debian.binary-package gh release upload "$RELEASE_TAG" \
"$RUNNER_TEMP/${{ steps.find-deb.outputs.name }}" \
--repo "$GITHUB_REPOSITORY" \
--clobber
build-rpm: build-rpm:
name: Build RPM Package (${{ matrix.arch }}) name: Build RPM Package (${{ matrix.arch }})
@@ -102,10 +86,8 @@ jobs:
include: include:
- arch: x86_64 - arch: x86_64
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
rpm_arch: x86_64
- arch: aarch64 - arch: aarch64
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
rpm_arch: aarch64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -145,10 +127,7 @@ jobs:
RPM_PATH=$(find target/${{ matrix.target }}/generate-rpm -name "*.rpm" | head -n 1) RPM_PATH=$(find target/${{ matrix.target }}/generate-rpm -name "*.rpm" | head -n 1)
echo "path=$RPM_PATH" >> $GITHUB_OUTPUT echo "path=$RPM_PATH" >> $GITHUB_OUTPUT
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
ARCH_NAME="${{ matrix.rpm_arch }}" RPM_NAME="mikusays-redhat-${{ matrix.arch }}-${VERSION}.rpm"
if [ "$ARCH_NAME" = "x86_64" ]; then ARCH_NAME="x64"; fi
RPM_NAME="mikusays-redhat-${ARCH_NAME}-${VERSION}.rpm"
# Store version for later use
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "name=$RPM_NAME" >> $GITHUB_OUTPUT echo "name=$RPM_NAME" >> $GITHUB_OUTPUT
@@ -158,26 +137,32 @@ jobs:
name: rpm-package-${{ matrix.arch }} name: rpm-package-${{ matrix.arch }}
path: ${{ steps.find-rpm.outputs.path }} 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 - name: Upload to release
if: github.event_name == 'release' || github.event.inputs.release_tag if: github.event_name == 'release' || github.event.inputs.release_tag
uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with: RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
upload_url: ${{ steps.get-release.outputs.upload_url }} run: |
asset_path: ${{ steps.find-rpm.outputs.path }} install -m 0644 "${{ steps.find-rpm.outputs.path }}" \
asset_name: mikusays-redhat-${{ matrix.arch }}-${{ steps.find-rpm.outputs.version }}.rpm "$RUNNER_TEMP/${{ steps.find-rpm.outputs.name }}"
asset_content_type: application/x-rpm 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"
+58 -21
View File
@@ -113,10 +113,21 @@ jobs:
- name: Prepare artifact - name: Prepare artifact
run: | run: |
mkdir -p artifacts mkdir -p artifacts staging
version="${{ needs.extract-version.outputs.version }}" version="${{ needs.extract-version.outputs.version }}"
cp target/${{ matrix.target }}/release/mikusays artifacts/mikusays-linux-${{ matrix.arch }}-$version stem="mikusays-linux-${{ matrix.arch }}-$version"
chmod +x artifacts/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 - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -150,10 +161,21 @@ jobs:
- name: Prepare artifact - name: Prepare artifact
run: | run: |
mkdir -p artifacts mkdir -p artifacts staging
version="${{ needs.extract-version.outputs.version }}" version="${{ needs.extract-version.outputs.version }}"
cp target/${{ matrix.target }}/release/mikusays artifacts/mikusays-macos-${{ matrix.arch }}-$version stem="mikusays-macos-${{ matrix.arch }}-$version"
chmod +x artifacts/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 - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -179,7 +201,7 @@ jobs:
run: ls -R artifacts run: ls -R artifacts
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with: with:
files: artifacts/mikusays-* files: artifacts/mikusays-*
draft: false draft: false
@@ -195,33 +217,48 @@ jobs:
### Windows Installer ### Windows Installer
- **Any**: `mikusays-windows-inst-any-${{ needs.extract-version.outputs.version }}.exe` - **Any**: `mikusays-windows-inst-any-${{ needs.extract-version.outputs.version }}.exe`
### Linux (binary) ### Linux (tar.gz)
- **x64**: `mikusays-linux-x64-${{ needs.extract-version.outputs.version }}` - **x64**: `mikusays-linux-x64-${{ needs.extract-version.outputs.version }}.tar.gz`
- **ARM64**: `mikusays-linux-arm64-${{ needs.extract-version.outputs.version }}` - **ARM64**: `mikusays-linux-arm64-${{ needs.extract-version.outputs.version }}.tar.gz`
### Debian Package ### Debian Package
- **x64 Debian Package**: `mikusays-debian-x64-${{ needs.extract-version.outputs.version }}.deb` - **x64 Debian Package**: `mikusays-debian-x86_64-${{ needs.extract-version.outputs.version }}.deb`
- **ARM64 Debian Package**: `mikusays-debian-arm64-${{ needs.extract-version.outputs.version }}.deb` - **ARM64 Debian Package**: `mikusays-debian-aarch64-${{ needs.extract-version.outputs.version }}.deb`
### RedHat (RPM) Package ### RedHat (RPM) Package
- **x64 RPM Package**: `mikusays-redhat-x64-${{ needs.extract-version.outputs.version }}.rpm` - **x64 RPM Package**: `mikusays-redhat-x86_64-${{ needs.extract-version.outputs.version }}.rpm`
- **ARM64 RPM Package**: `mikusays-redhat-arm64-${{ 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 ### Repository for Debian / Ubuntu / Fedora / RHEL / CentOS
Information on how to add it available here https://apt.xxanqw.pp.ua Information on how to add it available here https://apt.xxanqw.pp.ua
### macOS ### macOS (tar.gz)
- **Intel (x64)**: `mikusays-macos-x64-${{ needs.extract-version.outputs.version }}` - **Intel (x64)**: `mikusays-macos-x64-${{ needs.extract-version.outputs.version }}.tar.gz`
- **Apple Silicon (ARM64)**: `mikusays-macos-arm64-${{ needs.extract-version.outputs.version }}` - **Apple Silicon (ARM64)**: `mikusays-macos-arm64-${{ needs.extract-version.outputs.version }}.tar.gz`
## Usage ## Usage
```bash ```bash
# Download the appropriate binary for your platform # Download and extract the appropriate archive on Linux/macOS:
# On Unix-like systems, make it executable: archive="mikusays-linux-x64-${{ needs.extract-version.outputs.version }}.tar.gz"
chmod +x mikusays-* tar -xzf "$archive"
# Run the program: # 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: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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"
Generated
+1 -1
View File
@@ -252,7 +252,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]] [[package]]
name = "mikusays" name = "mikusays"
version = "0.1.4-2" version = "0.1.5"
dependencies = [ dependencies = [
"clap", "clap",
"crossterm", "crossterm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mikusays" name = "mikusays"
version = "0.1.4-2" version = "0.1.5"
edition = "2024" edition = "2024"
description = "A \"cowsay\" clone with Hatsune Miku ASCII art and speech bubbles." description = "A \"cowsay\" clone with Hatsune Miku ASCII art and speech bubbles."
license = "MIT" license = "MIT"
+28 -2
View File
@@ -74,8 +74,23 @@ brew install mikusays
- **Windows**: `mikusays-windows-*.exe` - **Windows**: `mikusays-windows-*.exe`
- Small note for Windows that application depends on `vcredist2022` - Small note for Windows that application depends on `vcredist2022`
(installing with scoop and installer resolve this dependency automatically) (installing with scoop and installer resolve this dependency automatically)
- **Linux**: `mikusays-linux-*` - **Linux**: `mikusays-linux-*.tar.gz`
- **macOS**: `mikusays-macos-*` - **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: Or build from source:
```bash ```bash
@@ -84,6 +99,17 @@ cargo build --release
## Development ## 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 1. Fork repository
2. Clone your fork 2. Clone your fork
3. Implement your changes 3. Implement your changes
+8 -18
View File
@@ -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 s = s as f32 / 100.0;
let l = l as f32 / 100.0; let l = l as f32 / 100.0;
let r; let (r, g, b) = if s == 0.0 {
let g; (l, l, l)
let b;
if s == 0.0 {
r = l;
g = l;
b = l;
} else { } else {
let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 { let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 {
let mut t = t; 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; let p = 2.0 * l - q;
r = hue_to_rgb(p, q, h + 1.0 / 3.0); (
g = hue_to_rgb(p, q, h); hue_to_rgb(p, q, h + 1.0 / 3.0),
b = 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, (r * 255.0).round() as u8,
@@ -493,12 +489,6 @@ pub fn detect_windows_shell() -> Option<String> {
None 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. /// Detect the parent shell name on Windows, using the sysinfo crate.
pub fn detect_color_mode() -> ColorMode { pub fn detect_color_mode() -> ColorMode {
if std::env::var("NO_COLOR").is_ok() { if std::env::var("NO_COLOR").is_ok() {
+218 -133
View File
@@ -1,26 +1,19 @@
use clap::{Parser, ValueEnum}; use clap::error::ErrorKind;
use clap::{CommandFactory, Parser};
use crossterm::QueueableCommand; use crossterm::QueueableCommand;
use crossterm::style::{Print, ResetColor, SetForegroundColor}; use crossterm::style::{Print, ResetColor};
use crossterm::terminal::size; use crossterm::terminal::size;
use std::error::Error; use std::error::Error;
use std::fmt;
use std::io::{Write, stdout}; use std::io::{Write, stdout};
use std::str::FromStr; use std::str::FromStr;
use unicode_width::UnicodeWidthStr; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
mod color; mod color;
mod mikuart; mod mikuart;
use color::{Color, ColorMode, detect_color_mode, lerp_color_hsv}; use color::{Color, ColorMode, detect_color_mode, lerp_color_hsv};
use mikuart::get_miku_art; use mikuart::get_miku_art;
#[derive(Debug, Clone, ValueEnum)]
enum ColorFormat {
Hex,
Rgb,
Hsl,
Ansi,
Named,
}
#[derive(Parser)] #[derive(Parser)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
pub struct Args { pub struct Args {
@@ -28,7 +21,7 @@ pub struct Args {
pub text: Option<String>, pub text: Option<String>,
/// Style of the Miku art. A random one is chosen if not specified /// 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>, pub style: Option<i32>,
/// List all available art styles with their indices /// List all available art styles with their indices
@@ -36,19 +29,19 @@ pub struct Args {
pub list: bool, pub list: bool,
/// Apply a smooth rainbow gradient across the ASCII art /// Apply a smooth rainbow gradient across the ASCII art
#[arg(long)] #[arg(long, conflicts_with_all = ["color", "gradient"])]
pub rainbow: bool, pub rainbow: bool,
/// Saturation level for rainbow gradient (0-100) /// 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, pub saturation: u8,
/// Brightness level for rainbow gradient (0-100) /// 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, pub brightness: u8,
/// Override with a single solid color /// Override with a single solid color
#[arg(long, value_name = "COLOR")] #[arg(long, value_name = "COLOR", conflicts_with = "gradient")]
pub color: Option<String>, pub color: Option<String>,
/// Define a custom two-color gradient (e.g., --gradient red:blue) /// Define a custom two-color gradient (e.g., --gradient red:blue)
@@ -76,7 +69,21 @@ pub struct ColorConfig {
pub debug: bool, 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 // Detect color mode
let mut color_mode = 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 // Parse single color if provided
let single_color = if let Some(color_str) = args.color.as_deref() { let single_color = args
match Color::from_str(color_str) { .color
Ok(color) => Some(color), .as_deref()
Err(e) => { .map(|color_str| {
eprintln!("Error parsing color '{}': {}", color_str, e); Color::from_str(color_str).map_err(|error| {
eprintln!("Valid color formats:"); ColorConfigError(format!(
eprintln!(" - Named colors: red, blue, green, etc."); "invalid color '{color_str}': {error}; {COLOR_FORMAT_HELP}"
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)"); .transpose()?;
std::process::exit(1);
}
}
} else {
None
};
// Parse gradient if provided // Parse gradient if provided
let gradient = if let Some(gradient_str) = args.gradient.as_deref() { let gradient = if let Some(gradient_str) = args.gradient.as_deref() {
let parts: Vec<&str> = gradient_str.split(':').collect(); let (start_str, end_str) = gradient_str.split_once(':').ok_or_else(|| {
if parts.len() != 2 { ColorConfigError(
eprintln!("Error: Gradient format should be START:END (e.g., --gradient red:blue)"); "invalid gradient: expected START:END (for example, red:blue)".to_string(),
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 start = match Color::from_str(parts[0]) { let start = Color::from_str(start_str).map_err(|error| {
Ok(color) => color, ColorConfigError(format!(
Err(e) => { "invalid gradient start '{start_str}': {error}; {COLOR_FORMAT_HELP}"
eprintln!("Error parsing gradient start color '{}': {}", parts[0], e); ))
eprintln!("Valid color formats:"); })?;
eprintln!(" - Named colors: red, blue, green, etc."); let end = Color::from_str(end_str).map_err(|error| {
eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)"); ColorConfigError(format!(
eprintln!(" - RGB: R,G,B (e.g., 255,0,128)"); "invalid gradient end '{end_str}': {error}; {COLOR_FORMAT_HELP}"
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)) Some((start, end))
} else { } else {
None None
}; };
// Validate saturation and brightness // Keep this validation for direct callers as well as Clap-parsed CLI input.
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() { if args.rainbow && single_color.is_some() {
eprintln!("Error: Cannot use --rainbow and --color together"); return Err(ColorConfigError(
std::process::exit(1); "--rainbow cannot be used with --color".to_string(),
));
} }
if args.rainbow && gradient.is_some() { if args.rainbow && gradient.is_some() {
eprintln!("Error: Cannot use --rainbow and --gradient together"); return Err(ColorConfigError(
std::process::exit(1); "--rainbow cannot be used with --gradient".to_string(),
));
} }
if single_color.is_some() && gradient.is_some() { if single_color.is_some() && gradient.is_some() {
eprintln!("Error: Cannot use --color and --gradient together"); return Err(ColorConfigError(
std::process::exit(1); "--color cannot be used with --gradient".to_string(),
));
} }
// Determine if ANSI is enabled (Windows toggle helper) — don't enable if --no-color // 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 { Ok(ColorConfig {
mode: color_mode, mode: color_mode,
rainbow: args.rainbow, rainbow: args.rainbow,
saturation, saturation: args.saturation,
brightness, brightness: args.brightness,
single_color, single_color,
gradient, gradient,
color_cache: Vec::new(), 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> { 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 mut lines = Vec::new();
let words: Vec<&str> = text.split_whitespace().collect();
let mut current_line = String::new(); let mut current_line = String::new();
for word in words { for word in text.split_whitespace() {
let word_width = word.width(); let word_width = word.width();
let current_width = current_line.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() { if !current_line.is_empty() {
current_line.push(' '); current_line.push(' ');
} }
current_line.push_str(word); current_line.push_str(word);
} else { continue;
if !current_line.is_empty() { }
lines.push(current_line);
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 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) { pub fn generate_rainbow_cache(color_config: &mut ColorConfig, total_chars: usize) {
color_config.color_cache.clear(); color_config.color_cache.clear();
@@ -309,7 +343,11 @@ pub fn generate_gradient_cache(
// Generate smooth gradient across all characters // Generate smooth gradient across all characters
for i in 0..total_chars { 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); let color = lerp_color_hsv(start, end, t);
color_config.color_cache.push(color); color_config.color_cache.push(color);
} }
@@ -359,8 +397,15 @@ fn draw_miku_says(
let speech_bubble_lines = get_speech_bubble_lines(text); let speech_bubble_lines = get_speech_bubble_lines(text);
let miku_art = get_miku_art(Some(style), None); let miku_art = get_miku_art(Some(style), None);
// Calculate total character count for color distribution // Distribute colors across characters that are actually colored.
let total_chars: usize = miku_art.iter().map(|line| line.chars().count()).sum(); let total_chars: usize = miku_art
.iter()
.map(|line| {
line.chars()
.filter(|character| !character.is_whitespace())
.count()
})
.sum();
// Generate color cache if needed // Generate color cache if needed
if color_config.rainbow { if color_config.rainbow {
@@ -443,36 +488,36 @@ fn draw_miku_says(
if color_config.mode != ColorMode::NoColor && !color_config.color_cache.is_empty() { if color_config.mode != ColorMode::NoColor && !color_config.color_cache.is_empty() {
// Use crossterm queueing to color characters so it's platform-agnostic // Use crossterm queueing to color characters so it's platform-agnostic
let mut stdout = stdout(); let mut stdout = stdout();
stdout.queue(Print(horizontal_padding.clone())).ok(); stdout.queue(Print(horizontal_padding.clone()))?;
for c in line.chars() { for c in line.chars() {
if c.is_whitespace() { if c.is_whitespace() {
stdout.queue(Print(c)).ok(); stdout.queue(Print(c))?;
} else if color_index < color_config.color_cache.len() { } else if color_index < color_config.color_cache.len() {
let color = &color_config.color_cache[color_index]; let color = &color_config.color_cache[color_index];
let ct_color = color::to_crossterm_color(color); stdout.queue(Print(color.to_ansi_fg(color_config.mode)))?;
stdout.queue(SetForegroundColor(ct_color)).ok(); stdout.queue(Print(c))?;
stdout.queue(Print(c)).ok(); stdout.queue(ResetColor)?;
stdout.queue(ResetColor).ok();
color_index += 1; color_index += 1;
} else { } else {
stdout.queue(Print(c)).ok(); stdout.queue(Print(c))?;
} }
} }
stdout.queue(Print("\n")).ok(); stdout.queue(Print("\n"))?;
stdout.flush().ok(); stdout.flush()?;
continue; 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 // Apply single solid color using crossterm
let mut stdout = stdout(); let mut stdout = stdout();
let ct_color = color::to_crossterm_color(single_color); stdout.queue(Print(horizontal_padding.clone()))?;
stdout.queue(Print(horizontal_padding.clone())).ok(); stdout.queue(Print(single_color.to_ansi_fg(color_config.mode)))?;
stdout.queue(SetForegroundColor(ct_color)).ok(); stdout.queue(Print(line))?;
stdout.queue(Print(line)).ok(); stdout.queue(ResetColor)?;
stdout.queue(ResetColor).ok(); stdout.queue(Print("\n"))?;
stdout.queue(Print("\n")).ok(); stdout.flush()?;
stdout.flush().ok();
} else { } else {
// No coloring // No coloring
println!("{}{}", horizontal_padding, line); println!("{}{}", horizontal_padding, line);
@@ -500,10 +545,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1); 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 // 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 { if color_config.debug {
println!("Detected color mode: {:?}", color_config.mode); println!("Detected color mode: {:?}", color_config.mode);
@@ -518,25 +580,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(()); 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)?; draw_miku_says(&text, style, &mut color_config)?;
Ok(()) Ok(())
@@ -566,7 +609,8 @@ mod tests {
fn test_wrap_text_long_word() { fn test_wrap_text_long_word() {
let text = "ThisIsAVeryLongWordThatExceedsMaxWidth"; let text = "ThisIsAVeryLongWordThatExceedsMaxWidth";
let wrapped = wrap_text(text, 10); 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] #[test]
@@ -667,7 +711,8 @@ mod tests {
// Test with single very long word // Test with single very long word
let text = "Supercalifragilisticexpialidocious"; let text = "Supercalifragilisticexpialidocious";
let wrapped = wrap_text(text, 10); 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 // Test with exact width match
let text = "Hello world"; let text = "Hello world";
@@ -690,7 +735,20 @@ mod tests {
let text = "Supercalifragilisticexpialidocious"; let text = "Supercalifragilisticexpialidocious";
let bubble = get_speech_bubble_lines(text); let bubble = get_speech_bubble_lines(text);
assert!(bubble.len() >= 5); 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] #[test]
@@ -793,11 +851,38 @@ mod tests {
generate_gradient_cache(&mut config, &start, &end, 10); generate_gradient_cache(&mut config, &start, &end, 10);
assert_eq!(config.color_cache.len(), 10); assert_eq!(config.color_cache.len(), 10);
// Check that first color is close to start
let first_color = &config.color_cache[0]; let first_color = &config.color_cache[0];
// Check that last color is close to end
let last_color = &config.color_cache[9]; 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] #[test]
+81 -7
View File
@@ -1,9 +1,13 @@
use std::process::Command; use std::process::Command;
use std::str; use std::str;
fn command() -> Command {
Command::new(env!("CARGO_BIN_EXE_mikusays"))
}
#[test] #[test]
fn test_cli_list_styles() { fn test_cli_list_styles() {
let output = Command::new("target/debug/mikusays") let output = command()
.arg("--list") .arg("--list")
.output() .output()
.expect("Failed to execute command"); .expect("Failed to execute command");
@@ -19,9 +23,7 @@ fn test_cli_list_styles() {
#[test] #[test]
fn test_cli_error_no_text() { fn test_cli_error_no_text() {
let output = Command::new("target/debug/mikusays") let output = command().output().expect("Failed to execute command");
.output()
.expect("Failed to execute command");
assert!(!output.status.success()); assert!(!output.status.success());
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
@@ -30,7 +32,7 @@ fn test_cli_error_no_text() {
#[test] #[test]
fn test_cli_error_conflicting_args() { fn test_cli_error_conflicting_args() {
let output = Command::new("target/debug/mikusays") let output = command()
.arg("Test") .arg("Test")
.arg("--rainbow") .arg("--rainbow")
.arg("--color") .arg("--color")
@@ -40,16 +42,88 @@ fn test_cli_error_conflicting_args() {
assert!(!output.status.success()); assert!(!output.status.success());
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8"); 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] #[test]
fn test_cli_basic_functionality() { fn test_cli_basic_functionality() {
// Test that the binary runs and produces some output // Test that the binary runs and produces some output
let output = Command::new("target/debug/mikusays") let output = command()
.arg("--list") .arg("--list")
.output() .output()
.expect("Failed to execute command"); .expect("Failed to execute command");
assert!(output.status.success()); 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'"));
}