Prepare v0.1.5 release
This commit is contained in:
1 parent
3e964f98da
commit
531480d898
9 files changed
+434
-237
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Generated
+1
-1
@@ -252,7 +252,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "mikusays"
|
||||
version = "0.1.4-2"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-18
@@ -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() {
|
||||
|
||||
+218
-133
@@ -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(),
|
||||
)
|
||||
})?;
|
||||
|
||||
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<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]
|
||||
|
||||
@@ -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'"));
|
||||
}
|
||||
Reference in new issue
Block a user