ecat-cli 9.0.0

Command-line interface for e-cat
Documentation
// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
//! Project pet (E-Cat) expression forms.
//!
//! The SVGs are generated by `scripts/generate_pet_assets.py`, which writes
//! both `docs/` and `ecat-cli/assets/pet/`; the copy here keeps the assets
//! inside the published crate. `assets_match_docs` guards against drift.
//!
//! The bundled SVGs are visual assets covered by
//! `assets/pet/ASSETS-LICENSE.md` — they are NOT Apache-2.0 like the code.

use crate::cli::PetAction;
use std::fs;
use std::path::Path;

/// One form of the E-Cat project pet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PetForm {
    /// The default sitting mascot.
    Ecat,
    Smile,
    Joy,
    Cry,
    Depressed,
    Terrified,
    Angry,
    Coquettish,
    Cute,
    Pleasing,
    Knocking,
    Facepalm,
    Tongue,
}

impl PetForm {
    /// Main form first, then the twelve expression forms in README order.
    pub(crate) const ALL: [PetForm; 13] = [
        PetForm::Ecat,
        PetForm::Smile,
        PetForm::Joy,
        PetForm::Cry,
        PetForm::Depressed,
        PetForm::Terrified,
        PetForm::Angry,
        PetForm::Coquettish,
        PetForm::Cute,
        PetForm::Pleasing,
        PetForm::Knocking,
        PetForm::Facepalm,
        PetForm::Tongue,
    ];

    /// File stem / CLI selector.
    pub(crate) fn slug(self) -> &'static str {
        match self {
            PetForm::Ecat => "ecat",
            PetForm::Smile => "smile",
            PetForm::Joy => "joy",
            PetForm::Cry => "cry",
            PetForm::Depressed => "depressed",
            PetForm::Terrified => "terrified",
            PetForm::Angry => "angry",
            PetForm::Coquettish => "coquettish",
            PetForm::Cute => "cute",
            PetForm::Pleasing => "pleasing",
            PetForm::Knocking => "knocking",
            PetForm::Facepalm => "facepalm",
            PetForm::Tongue => "tongue",
        }
    }

    pub(crate) fn name_zh(self) -> &'static str {
        match self {
            PetForm::Ecat => "主形象",
            PetForm::Smile => "笑",
            PetForm::Joy => "欢乐",
            PetForm::Cry => "哭",
            PetForm::Depressed => "沮丧",
            PetForm::Terrified => "惊恐",
            PetForm::Angry => "愤怒",
            PetForm::Coquettish => "撒娇",
            PetForm::Cute => "卖萌",
            PetForm::Pleasing => "讨好",
            PetForm::Knocking => "敲打",
            PetForm::Facepalm => "捂脸",
            PetForm::Tongue => "吐舌头",
        }
    }

    pub(crate) fn name_en(self) -> &'static str {
        match self {
            PetForm::Ecat => "Ecat",
            PetForm::Smile => "Smile",
            PetForm::Joy => "Joy",
            PetForm::Cry => "Cry",
            PetForm::Depressed => "Depressed",
            PetForm::Terrified => "Terrified",
            PetForm::Angry => "Angry",
            PetForm::Coquettish => "Coquettish",
            PetForm::Cute => "Cute",
            PetForm::Pleasing => "Pleasing",
            PetForm::Knocking => "Knocking",
            PetForm::Facepalm => "Facepalm",
            PetForm::Tongue => "Tongue Out",
        }
    }

    /// Document shortcode used by the README expression table.
    pub(crate) fn shortcode(self) -> Option<&'static str> {
        match self {
            PetForm::Ecat => None,
            PetForm::Smile => Some(":emoji-smile:"),
            PetForm::Joy => Some(":emoji-joy:"),
            PetForm::Cry => Some(":emoji-cry:"),
            PetForm::Depressed => Some(":emoji-depressed:"),
            PetForm::Terrified => Some(":emoji-terrified:"),
            PetForm::Angry => Some(":emoji-angry:"),
            PetForm::Coquettish => Some(":emoji-coquettish:"),
            PetForm::Cute => Some(":emoji-cute:"),
            PetForm::Pleasing => Some(":emoji-pleasing:"),
            PetForm::Knocking => Some(":emoji-knocking:"),
            PetForm::Facepalm => Some(":emoji-facepalm:"),
            PetForm::Tongue => Some(":emoji-tongue:"),
        }
    }

    /// Embedded SVG source (animated, `prefers-reduced-motion` aware).
    pub(crate) fn svg(self) -> &'static str {
        match self {
            PetForm::Ecat => include_str!("../assets/pet/ecat.svg"),
            PetForm::Smile => include_str!("../assets/pet/smile.svg"),
            PetForm::Joy => include_str!("../assets/pet/joy.svg"),
            PetForm::Cry => include_str!("../assets/pet/cry.svg"),
            PetForm::Depressed => include_str!("../assets/pet/depressed.svg"),
            PetForm::Terrified => include_str!("../assets/pet/terrified.svg"),
            PetForm::Angry => include_str!("../assets/pet/angry.svg"),
            PetForm::Coquettish => include_str!("../assets/pet/coquettish.svg"),
            PetForm::Cute => include_str!("../assets/pet/cute.svg"),
            PetForm::Pleasing => include_str!("../assets/pet/pleasing.svg"),
            PetForm::Knocking => include_str!("../assets/pet/knocking.svg"),
            PetForm::Facepalm => include_str!("../assets/pet/facepalm.svg"),
            PetForm::Tongue => include_str!("../assets/pet/tongue.svg"),
        }
    }

    /// Resolve a slug, shortcode, Chinese name, or English name.
    pub(crate) fn parse(raw: &str) -> Option<Self> {
        let bare = raw.trim().trim_matches(':');
        let bare = bare.strip_prefix("emoji-").unwrap_or(bare);
        let lower = bare.to_ascii_lowercase();
        Self::ALL.iter().copied().find(|form| {
            form.slug() == lower
                || form.name_en().to_ascii_lowercase() == lower
                || form.name_zh() == bare
                || (matches!(form, PetForm::Ecat)
                    && matches!(lower.as_str(), "main" | "default" | "一只猫"))
        })
    }
}

pub(crate) fn run(action: PetAction) -> Result<(), String> {
    match action {
        PetAction::List => {
            list();
            Ok(())
        }
        PetAction::Show { form } => {
            let form = PetForm::parse(&form).ok_or_else(|| {
                format!("unknown pet form '{form}'; run `ecat pet list` to see the forms")
            })?;
            print!("{}", form.svg());
            Ok(())
        }
        PetAction::Export { dir } => export(Path::new(&dir)),
    }
}

fn list() {
    println!(
        "{} {} {} SHORTCODE",
        pad("FORM", 12),
        pad("中文", 10),
        pad("ENGLISH", 12)
    );
    for form in PetForm::ALL {
        println!(
            "{} {} {} {}",
            pad(form.slug(), 12),
            pad(form.name_zh(), 10),
            pad(form.name_en(), 12),
            form.shortcode().unwrap_or("-")
        );
    }
}

/// Pad by terminal display width so CJK names line up.
fn pad(text: &str, width: usize) -> String {
    let used: usize = text.chars().map(|c| if is_wide(c) { 2 } else { 1 }).sum();
    format!("{text}{}", " ".repeat(width.saturating_sub(used)))
}

fn is_wide(c: char) -> bool {
    matches!(
        c as u32,
        0x1100..=0x115F
            | 0x2E80..=0xA4CF
            | 0xAC00..=0xD7A3
            | 0xF900..=0xFAFF
            | 0xFE30..=0xFE4F
            | 0xFF00..=0xFF60
            | 0xFFE0..=0xFFE6
            | 0x20000..=0x3FFFD
    )
}

fn export(dir: &Path) -> Result<(), String> {
    fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
    for form in PetForm::ALL {
        let path = dir.join(format!("{}.svg", form.slug()));
        fs::write(&path, form.svg()).map_err(|e| format!("write {}: {e}", path.display()))?;
        println!("wrote {}", path.display());
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn twelve_expressions_plus_main() {
        assert_eq!(PetForm::ALL.len(), 13);
        assert_eq!(PetForm::ALL[0], PetForm::Ecat);
    }

    #[test]
    fn slugs_are_unique() {
        let mut slugs: Vec<_> = PetForm::ALL.iter().map(|f| f.slug()).collect();
        slugs.sort_unstable();
        slugs.dedup();
        assert_eq!(slugs.len(), PetForm::ALL.len());
    }

    #[test]
    fn parse_accepts_slug_shortcode_and_names() {
        for form in PetForm::ALL {
            assert_eq!(PetForm::parse(form.slug()), Some(form));
            assert_eq!(PetForm::parse(form.name_zh()), Some(form));
            assert_eq!(PetForm::parse(form.name_en()), Some(form));
            if let Some(code) = form.shortcode() {
                assert_eq!(PetForm::parse(code), Some(form));
                assert_eq!(PetForm::parse(code.trim_matches(':')), Some(form));
            }
        }
        assert_eq!(PetForm::parse(":emoji-smile:"), Some(PetForm::Smile));
        assert_eq!(PetForm::parse("main"), Some(PetForm::Ecat));
        assert_eq!(PetForm::parse("一只猫"), Some(PetForm::Ecat));
        assert_eq!(PetForm::parse("nope"), None);
    }

    #[test]
    fn every_svg_is_vector() {
        for form in PetForm::ALL {
            let svg = form.svg();
            assert!(svg.starts_with("<svg"), "{} is not an SVG", form.slug());
            assert!(
                !svg.contains("data:image"),
                "{} still embeds a raster image",
                form.slug()
            );
            assert!(svg.contains("<title>"), "{} lacks a title", form.slug());
        }
    }

    #[test]
    fn assets_match_docs() {
        let docs = Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs");
        for form in PetForm::ALL {
            let rel = if form == PetForm::Ecat {
                "e-cat.svg".to_string()
            } else {
                format!("pet-expressions/{}.svg", form.slug())
            };
            let on_disk = fs::read_to_string(docs.join(rel)).unwrap();
            assert_eq!(on_disk, form.svg(), "asset drift for {}", form.slug());
        }
    }
}