Codex Handbook
tui/src/pets/model.rs 1036 lines
//! Pet manifest loading and normalization.//!//! This module converts several user-facing selectors into one normalized//! in-memory `Pet`: built-in catalog ids, custom pet ids under CODEX_HOME,//! legacy avatar directories, and explicit filesystem paths used by tests or//! local iteration.//!//! The key invariant is that every returned `Pet` points at a local//! spritesheet path that already exists and has app-compatible dimensions.//! Asset acquisition is intentionally out of scope here; callers must ensure a//! built-in pet has been downloaded before asking the model layer to load it.use std::collections::HashMap;use std::fs;use std::path::Component;use std::path::Path;use std::path::PathBuf;use std::time::Duration;use anyhow::Context;use anyhow::Result;use anyhow::bail;use serde::Deserialize;use sha2::Digest as _;use sha2::Sha256;use super::catalog;const MAX_PET_FRAMES: usize = 256;const MAX_ANIMATION_FPS: f64 = 60.0;#[derive(Debug, Clone)]pub struct AnimationFrame {    pub sprite_index: usize,    pub duration: Duration,}#[derive(Debug, Clone)]pub struct Animation {    pub frames: Vec<AnimationFrame>,    pub loop_start: Option<usize>,    pub fallback: String,}impl Animation {    pub(super) fn total_duration(&self) -> Duration {        self.frames            .iter()            .map(|frame| frame.duration)            .sum::<Duration>()    }}/// One named animation track for a pet spritesheet.////// Tracks use sprite indices into the already-decoded frame grid plus a/// fallback animation name for one-shot sequences. Callers should not assume/// an animation loops just because it has multiple frames; `loop_start == None`/// means the final frame eventually hands off to `fallback`.#[derive(Debug, Clone)]pub struct Pet {    pub id: String,    pub display_name: String,    pub description: String,    pub spritesheet_path: PathBuf,    pub frame_width: u32,    pub frame_height: u32,    pub columns: u32,    pub rows: u32,    pub frame_count: usize,    pub animations: HashMap<String, Animation>,}impl Pet {    /// Load a pet selector into a concrete local pet definition.    ///    /// Selectors may name a built-in catalog pet, a custom pet id, a legacy    /// avatar id, or an explicit path. This method assumes any built-in asset    /// has already been materialized into CODEX_HOME; if callers skip the    /// asset-fetch step, they will get a missing-spritesheet error here on    /// first use.    pub(super) fn load_with_codex_home(value: &str, codex_home: Option<&Path>) -> Result<Self> {        if path_like(value) {            return load_pet_path(value);        }        if let Some(custom_id) = value.strip_prefix(CUSTOM_PET_PREFIX) {            return load_custom_pet(custom_id, codex_home);        }        if let Some(builtin) = catalog::builtin_pet(value) {            return load_builtin_pet(builtin, codex_home);        }        load_custom_pet(value, codex_home)    }    pub fn frame_count(&self) -> usize {        self.frame_count    }    pub(super) fn frame_cache_key(&self) -> Result<String> {        let bytes = fs::read(&self.spritesheet_path)            .with_context(|| format!("read {}", self.spritesheet_path.display()))?;        let digest = Sha256::digest(&bytes);        Ok(format!(            "sha256-{digest:x}-{}x{}-{}x{}",            self.frame_width, self.frame_height, self.columns, self.rows        ))    }}pub(super) const CUSTOM_PET_PREFIX: &str = "custom:";#[derive(Debug, Deserialize)]struct PetFile {    #[serde(default)]    id: Option<String>,    #[serde(default, rename = "displayName")]    display_name: Option<String>,    #[serde(default)]    description: Option<String>,    #[serde(default, rename = "spritesheetPath")]    spritesheet_path: Option<String>,    frame: Option<FrameSpec>,    #[serde(default)]    animations: HashMap<String, AnimationSpec>,}#[derive(Debug, Clone, Copy, Deserialize)]struct FrameSpec {    width: u32,    height: u32,    columns: u32,    rows: u32,}impl Default for FrameSpec {    fn default() -> Self {        Self {            width: catalog::DEFAULT_FRAME_WIDTH,            height: catalog::DEFAULT_FRAME_HEIGHT,            columns: catalog::DEFAULT_FRAME_COLUMNS,            rows: catalog::DEFAULT_FRAME_ROWS,        }    }}pub(super) fn custom_pet_selector(id: &str) -> String {    format!("{CUSTOM_PET_PREFIX}{id}")}#[derive(Debug, Deserialize)]struct AnimationSpec {    #[serde(default)]    frames: Vec<usize>,    fps: Option<f64>,    #[serde(rename = "loop")]    loop_animation: Option<bool>,    #[serde(default)]    fallback: String,}fn load_builtin_pet(pet: catalog::BuiltinPet, codex_home: Option<&Path>) -> Result<Pet> {    let codex_home = codex_home.context("CODEX_HOME is not available")?;    let spritesheet_path = super::builtin_spritesheet_path(codex_home, pet.spritesheet_file);    if !spritesheet_path.exists() {        bail!("missing spritesheet {}", spritesheet_path.display());    }    Ok(Pet {        id: pet.id.to_string(),        display_name: pet.display_name.to_string(),        description: pet.description.to_string(),        spritesheet_path,        frame_width: catalog::DEFAULT_FRAME_WIDTH,        frame_height: catalog::DEFAULT_FRAME_HEIGHT,        columns: catalog::DEFAULT_FRAME_COLUMNS,        rows: catalog::DEFAULT_FRAME_ROWS,        frame_count: default_frame_count(),        animations: default_animations(),    })}fn load_custom_pet(value: &str, codex_home: Option<&Path>) -> Result<Pet> {    let codex_home = codex_home.context("CODEX_HOME is not available")?;    let pet_dir = codex_home.join("pets").join(value);    if pet_dir.join("pet.json").is_file() {        return load_pet_manifest(&pet_dir, "pet.json", value, &custom_pet_cache_id(value));    }    let avatar_dir = codex_home.join("avatars").join(value);    if avatar_dir.join("avatar.json").is_file() {        return load_pet_manifest(            &avatar_dir,            "avatar.json",            value,            &custom_pet_cache_id(value),        );    }    bail!("unknown pet {value}")}fn load_pet_path(value: &str) -> Result<Pet> {    let path = expand_path(value)?;    let metadata = fs::metadata(&path).with_context(|| format!("pet path {}", path.display()))?;    let dir = if metadata.is_dir() {        path    } else {        path.parent()            .context("pet json path has no containing directory")?            .to_path_buf()    };    let pet_dir = dir        .canonicalize()        .with_context(|| format!("resolve {}", dir.display()))?;    let manifest_file = if pet_dir.join("pet.json").is_file() {        "pet.json"    } else if pet_dir.join("avatar.json").is_file() {        "avatar.json"    } else {        bail!("missing pet.json or avatar.json in {}", pet_dir.display());    };    let fallback_id = pet_dir        .file_name()        .and_then(|name| name.to_str())        .unwrap_or("pet");    load_pet_manifest(&pet_dir, manifest_file, fallback_id, fallback_id)}fn load_pet_manifest(    pet_dir: &Path,    manifest_file: &str,    fallback_id: &str,    cache_id: &str,) -> Result<Pet> {    let config_path = pet_dir.join(manifest_file);    let raw = fs::read_to_string(&config_path)        .with_context(|| format!("read {}", config_path.display()))?;    let file: PetFile =        serde_json::from_str(&raw).with_context(|| format!("parse {}", config_path.display()))?;    let manifest_id = file        .id        .as_deref()        .map(str::trim)        .filter(|id| !id.is_empty());    let display_name = file        .display_name        .as_deref()        .map(str::trim)        .filter(|name| !name.is_empty())        .or(manifest_id)        .unwrap_or(fallback_id)        .to_string();    let pet_id = if cache_id == fallback_id {        manifest_id.unwrap_or(fallback_id).to_string()    } else {        cache_id.to_string()    };    let description = file        .description        .map(|description| description.trim().to_string())        .unwrap_or_default();    let spritesheet_path = resolve_spritesheet_path(        pet_dir,        file.spritesheet_path            .as_deref()            .map(str::trim)            .filter(|path| !path.is_empty())            .unwrap_or("spritesheet.webp"),    )?;    if !spritesheet_path.exists() {        bail!("missing spritesheet {}", spritesheet_path.display());    }    let (spritesheet_width, spritesheet_height) =        validate_app_spritesheet_dimensions(&spritesheet_path)?;    let frame = file.frame.unwrap_or_default();    let frame_count = validate_frame_spec(&frame, spritesheet_width, spritesheet_height)?;    Ok(Pet {        id: pet_id,        display_name,        description,        spritesheet_path,        frame_width: frame.width,        frame_height: frame.height,        columns: frame.columns,        rows: frame.rows,        frame_count,        animations: load_animations(file.animations, frame_count)?,    })}/// Resolve a manifest-relative spritesheet path while keeping it inside the pet directory.////// The manifest format intentionally supports only relative child paths./// Allowing absolute or parent-traversing paths here would let one custom pet/// manifest reach outside its own directory and make cache/debug behavior/// depend on unrelated local files.fn resolve_spritesheet_path(pet_dir: &Path, spritesheet_path: &str) -> Result<PathBuf> {    let path = Path::new(spritesheet_path);    if path.is_absolute()        || path            .components()            .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))    {        bail!("spritesheet path must stay inside {}", pet_dir.display());    }    Ok(pet_dir.join(path))}fn validate_app_spritesheet_dimensions(path: &Path) -> Result<(u32, u32)> {    let (width, height) =        image::image_dimensions(path).with_context(|| format!("read {}", path.display()))?;    if width != catalog::SPRITESHEET_WIDTH || height != catalog::SPRITESHEET_HEIGHT {        bail!(            "spritesheet must be {}x{} pixels",            catalog::SPRITESHEET_WIDTH,            catalog::SPRITESHEET_HEIGHT        );    }    Ok((width, height))}fn validate_frame_spec(    frame: &FrameSpec,    spritesheet_width: u32,    spritesheet_height: u32,) -> Result<usize> {    if frame.width == 0 || frame.height == 0 || frame.columns == 0 || frame.rows == 0 {        bail!("pet frame dimensions and grid counts must be non-zero");    }    let total_width = frame        .width        .checked_mul(frame.columns)        .context("pet frame grid width overflow")?;    let total_height = frame        .height        .checked_mul(frame.rows)        .context("pet frame grid height overflow")?;    if total_width != spritesheet_width || total_height != spritesheet_height {        bail!(            "pet frame grid must cover spritesheet exactly: expected {spritesheet_width}x{spritesheet_height}, got {total_width}x{total_height}"        );    }    let frame_count = frame        .columns        .checked_mul(frame.rows)        .context("pet frame count overflow")?;    let frame_count = usize::try_from(frame_count).context("pet frame count does not fit usize")?;    if frame_count > MAX_PET_FRAMES {        bail!("pet frame count {frame_count} exceeds maximum {MAX_PET_FRAMES}");    }    Ok(frame_count)}fn custom_pet_cache_id(id: &str) -> String {    format!("custom-{id}")}fn path_like(value: &str) -> bool {    value == "."        || value == ".."        || value.starts_with("~/")        || value.starts_with("../")        || value.starts_with("./")        || Path::new(value).is_absolute()        || value.contains('/')        || value.contains('\\')}fn expand_path(value: &str) -> Result<PathBuf> {    if value == "~" || value.starts_with("~/") {        let home = std::env::var_os("HOME").context("HOME is not set")?;        if value == "~" {            return Ok(PathBuf::from(home));        }        return Ok(PathBuf::from(home).join(&value[2..]));    }    Ok(PathBuf::from(value))}fn load_animations(    specs: HashMap<String, AnimationSpec>,    frame_count: usize,) -> Result<HashMap<String, Animation>> {    let mut animations = default_animations();    if specs.is_empty() {        validate_animation_indices(&animations, frame_count)?;        return Ok(animations);    }    for (name, spec) in specs {        if spec.frames.is_empty() {            bail!("animation {name} must include at least one frame");        }        for sprite_index in &spec.frames {            if *sprite_index >= frame_count {                bail!(                    "animation {name} references sprite index {sprite_index}, but pet has {frame_count} frames"                );            }        }        let fps = match spec.fps {            Some(fps) if fps.is_finite() && fps > 0.0 && fps <= MAX_ANIMATION_FPS => fps,            Some(fps) => {                bail!(                    "animation {name} fps must be finite and between 0 and {MAX_ANIMATION_FPS}, got {fps}"                );            }            None => 8.0,        };        let duration = Duration::from_secs_f64(1.0 / fps);        let fallback = if spec.fallback.is_empty() {            "idle".to_string()        } else {            spec.fallback        };        let loop_start = spec            .loop_animation            .unwrap_or(/*default*/ true)            .then_some(/*loop_start*/ 0);        animations.insert(            name,            Animation {                frames: spec                    .frames                    .into_iter()                    .map(|sprite_index| AnimationFrame {                        sprite_index,                        duration,                    })                    .collect(),                loop_start,                fallback,            },        );    }    animations        .entry("idle".to_string())        .or_insert_with(idle_animation);    validate_animation_indices(&animations, frame_count)?;    Ok(animations)}fn validate_animation_indices(    animations: &HashMap<String, Animation>,    frame_count: usize,) -> Result<()> {    for (name, animation) in animations {        if animation.frames.is_empty() {            bail!("animation {name} must include at least one frame");        }        for frame in &animation.frames {            if frame.sprite_index >= frame_count {                bail!(                    "animation {name} references sprite index {}, but pet has {frame_count} frames",                    frame.sprite_index                );            }        }        if !animations.contains_key(&animation.fallback) {            bail!(                "animation {name} fallback {} does not exist",                animation.fallback            );        }    }    Ok(())}fn default_frame_count() -> usize {    (catalog::DEFAULT_FRAME_COLUMNS * catalog::DEFAULT_FRAME_ROWS) as usize}fn default_animations() -> HashMap<String, Animation> {    [        ("idle", idle_animation()),        (            "running-right",            app_state_animation(                /*row_index*/ 1, /*frame_count*/ 8, /*frame_duration_ms*/ 120,                /*final_frame_duration_ms*/ 220,            ),        ),        (            "running-left",            app_state_animation(                /*row_index*/ 2, /*frame_count*/ 8, /*frame_duration_ms*/ 120,                /*final_frame_duration_ms*/ 220,            ),        ),        (            "waving",            app_state_animation(                /*row_index*/ 3, /*frame_count*/ 4, /*frame_duration_ms*/ 140,                /*final_frame_duration_ms*/ 280,            ),        ),        (            "jumping",            app_state_animation(                /*row_index*/ 4, /*frame_count*/ 5, /*frame_duration_ms*/ 140,                /*final_frame_duration_ms*/ 280,            ),        ),        (            "failed",            app_state_animation(                /*row_index*/ 5, /*frame_count*/ 8, /*frame_duration_ms*/ 140,                /*final_frame_duration_ms*/ 240,            ),        ),        (            "waiting",            app_state_animation(                /*row_index*/ 6, /*frame_count*/ 6, /*frame_duration_ms*/ 150,                /*final_frame_duration_ms*/ 260,            ),        ),        (            "running",            app_state_animation(                /*row_index*/ 7, /*frame_count*/ 6, /*frame_duration_ms*/ 120,                /*final_frame_duration_ms*/ 220,            ),        ),        (            "review",            app_state_animation(                /*row_index*/ 8, /*frame_count*/ 6, /*frame_duration_ms*/ 150,                /*final_frame_duration_ms*/ 280,            ),        ),        (            "move_right",            app_state_animation(                /*row_index*/ 1, /*frame_count*/ 8, /*frame_duration_ms*/ 120,                /*final_frame_duration_ms*/ 220,            ),        ),        (            "move_left",            app_state_animation(                /*row_index*/ 2, /*frame_count*/ 8, /*frame_duration_ms*/ 120,                /*final_frame_duration_ms*/ 220,            ),        ),        (            "wave",            app_state_animation(                /*row_index*/ 3, /*frame_count*/ 4, /*frame_duration_ms*/ 140,                /*final_frame_duration_ms*/ 280,            ),        ),        (            "bounce",            app_state_animation(                /*row_index*/ 4, /*frame_count*/ 5, /*frame_duration_ms*/ 140,                /*final_frame_duration_ms*/ 280,            ),        ),        (            "sad",            app_state_animation(                /*row_index*/ 5, /*frame_count*/ 8, /*frame_duration_ms*/ 140,                /*final_frame_duration_ms*/ 240,            ),        ),    ]    .into_iter()    .map(|(name, animation)| (name.to_string(), animation))    .collect()}fn idle_animation() -> Animation {    Animation {        frames: [(0, 1680), (1, 660), (2, 660), (3, 840), (4, 840), (5, 1920)]            .into_iter()            .map(|(sprite_index, duration_ms)| AnimationFrame {                sprite_index,                duration: Duration::from_millis(duration_ms),            })            .collect(),        loop_start: Some(/*loop_start*/ 0),        fallback: "idle".to_string(),    }}fn app_state_animation(    row_index: usize,    frame_count: usize,    frame_duration_ms: u64,    final_frame_duration_ms: u64,) -> Animation {    let primary_frames = (0..frame_count)        .map(|column_index| AnimationFrame {            sprite_index: row_index * catalog::DEFAULT_FRAME_COLUMNS as usize + column_index,            duration: Duration::from_millis(if column_index == frame_count - 1 {                final_frame_duration_ms            } else {                frame_duration_ms            }),        })        .collect::<Vec<_>>();    let primary_frame_count = primary_frames.len() * 3;    let frames = primary_frames        .iter()        .chain(primary_frames.iter())        .chain(primary_frames.iter())        .cloned()        .chain(idle_animation().frames)        .collect();    Animation {        frames,        loop_start: Some(primary_frame_count),        fallback: "idle".to_string(),    }}#[cfg(test)]mod tests {    use super::*;    fn write_minimal_pet() -> tempfile::TempDir {        write_pet_manifest(            r#"{                "id": "chefito",                "displayName": "Chefito",                "description": "A tiny recipe-loving chef",                "spritesheetPath": "spritesheet.webp"            }"#,        )    }    fn write_pet_manifest(manifest: &str) -> tempfile::TempDir {        let dir = tempfile::tempdir().unwrap();        fs::write(dir.path().join("pet.json"), manifest).unwrap();        catalog::write_test_spritesheet(&dir.path().join("spritesheet.webp"));        dir    }    fn load_pet_from_dir(dir: &tempfile::TempDir) -> Pet {        Pet::load_with_codex_home(dir.path().to_str().unwrap(), /*codex_home*/ None).unwrap()    }    fn load_pet_error_from_dir(dir: &tempfile::TempDir) -> anyhow::Error {        Pet::load_with_codex_home(dir.path().to_str().unwrap(), /*codex_home*/ None).unwrap_err()    }    #[test]    fn load_builtin_pet_uses_app_catalog_storage() {        let codex_home = tempfile::tempdir().unwrap();        super::super::asset_pack::write_test_pack(codex_home.path());        let pet =            Pet::load_with_codex_home("dewey", /*codex_home*/ Some(codex_home.path())).unwrap();        assert_eq!(pet.id, "dewey");        assert_eq!(pet.display_name, "Dewey");        assert_eq!(pet.description, "A tidy duck for calm workspace days");        assert_eq!(            pet.spritesheet_path,            super::super::builtin_spritesheet_path(codex_home.path(), "dewey-spritesheet-v4.webp")        );        assert_eq!(pet.frame_width, 192);        assert_eq!(pet.frame_height, 208);        assert_eq!(pet.columns, 8);        assert_eq!(pet.rows, 9);    }    #[test]    fn app_idle_animation_uses_calm_loop() {        let animations = default_animations();        let idle = &animations["idle"];        assert_eq!(sprite_indices(idle), vec![0, 1, 2, 3, 4, 5]);        assert_eq!(durations_ms(idle), vec![1680, 660, 660, 840, 840, 1920]);        assert_eq!(idle.loop_start, Some(/*loop_start*/ 0));    }    #[test]    fn app_running_animation_repeats_then_settles_into_idle() {        let animations = default_animations();        let running = &animations["running"];        let primary = vec![56, 57, 58, 59, 60, 61];        assert_eq!(sprite_indices(running)[0..6], primary);        assert_eq!(sprite_indices(running)[6..12], primary);        assert_eq!(sprite_indices(running)[12..18], primary);        assert_eq!(            sprite_indices(running)[18..],            sprite_indices(&animations["idle"])        );        assert_eq!(            durations_ms(running)[0..6],            vec![120, 120, 120, 120, 120, 220]        );        assert_eq!(running.loop_start, Some(/*loop_start*/ 18));    }    #[test]    fn app_notification_states_use_expected_rows() {        let animations = default_animations();        assert_eq!(            sprite_indices(&animations["waiting"])[0..6],            vec![48, 49, 50, 51, 52, 53]        );        assert_eq!(            sprite_indices(&animations["review"])[0..6],            vec![64, 65, 66, 67, 68, 69]        );        assert_eq!(            sprite_indices(&animations["failed"])[0..8],            vec![40, 41, 42, 43, 44, 45, 46, 47]        );    }    #[test]    fn custom_animation_specs_keep_manifest_fps_and_loop_shape() {        let animations = load_animations(            HashMap::from([(                "custom".to_string(),                AnimationSpec {                    frames: vec![1, 2],                    fps: Some(/*fps*/ 2.0),                    loop_animation: Some(/*loop_animation*/ false),                    fallback: "idle".to_string(),                },            )]),            default_frame_count(),        )        .unwrap();        let custom = &animations["custom"];        assert_eq!(sprite_indices(custom), vec![1, 2]);        assert_eq!(durations_ms(custom), vec![500, 500]);        assert_eq!(custom.loop_start, None);        assert_eq!(custom.fallback, "idle");    }    #[test]    fn load_pet_directory_uses_app_pet_manifest_defaults() {        let dir = write_minimal_pet();        let pet = load_pet_from_dir(&dir);        assert_eq!(pet.id, "chefito");        assert_eq!(pet.display_name, "Chefito");        assert_eq!(pet.frame_width, 192);        assert_eq!(pet.frame_height, 208);        assert_eq!(pet.columns, 8);        assert_eq!(pet.rows, 9);        assert_eq!(pet.frame_count(), 72);        assert!(!pet.animations["idle"].frames.is_empty());    }    #[test]    fn frame_cache_key_changes_with_spritesheet_contents() {        let dir = write_minimal_pet();        let spritesheet_path = dir.path().join("spritesheet.webp");        let pet = load_pet_from_dir(&dir);        let first_key = pet.frame_cache_key().unwrap();        let image = image::RgbaImage::from_pixel(            catalog::SPRITESHEET_WIDTH,            catalog::SPRITESHEET_HEIGHT,            image::Rgba([1, 2, 3, 255]),        );        image.save(&spritesheet_path).unwrap();        let pet = load_pet_from_dir(&dir);        assert_ne!(pet.frame_cache_key().unwrap(), first_key);    }    #[test]    fn frame_cache_key_changes_with_frame_spec() {        let default_dir = write_minimal_pet();        let default_pet = load_pet_from_dir(&default_dir);        let custom_dir = write_pet_manifest(            r#"{                "displayName": "Tall",                "spritesheetPath": "spritesheet.webp",                "frame": { "width": 384, "height": 104, "columns": 4, "rows": 18 }            }"#,        );        let custom_pet = load_pet_from_dir(&custom_dir);        assert_ne!(            custom_pet.frame_cache_key().unwrap(),            default_pet.frame_cache_key().unwrap()        );    }    #[test]    fn load_pet_json_path_uses_containing_directory() {        let dir = write_minimal_pet();        let pet = Pet::load_with_codex_home(            dir.path().join("pet.json").to_str().unwrap(),            /*codex_home*/ None,        )        .unwrap();        let expected = dir.path().join("spritesheet.webp").canonicalize().unwrap();        assert_eq!(pet.spritesheet_path, expected);    }    #[test]    fn custom_pet_selector_loads_codex_home_pet_manifest() {        let dir = write_minimal_pet();        let codex_home = tempfile::tempdir().unwrap();        let pet_dir = codex_home.path().join("pets").join("chefito");        fs::create_dir_all(&pet_dir).unwrap();        fs::copy(dir.path().join("pet.json"), pet_dir.join("pet.json")).unwrap();        fs::copy(            dir.path().join("spritesheet.webp"),            pet_dir.join("spritesheet.webp"),        )        .unwrap();        let pet = Pet::load_with_codex_home(            &custom_pet_selector("chefito"),            /*codex_home*/ Some(codex_home.path()),        )        .unwrap();        assert_eq!(pet.id, "custom-chefito");        assert_eq!(pet.spritesheet_path, pet_dir.join("spritesheet.webp"),);    }    #[test]    fn custom_pet_selector_falls_back_to_legacy_avatar_manifest() {        let dir = write_minimal_pet();        let codex_home = tempfile::tempdir().unwrap();        let avatar_dir = codex_home.path().join("avatars").join("legacy");        fs::create_dir_all(&avatar_dir).unwrap();        fs::copy(dir.path().join("pet.json"), avatar_dir.join("avatar.json")).unwrap();        fs::copy(            dir.path().join("spritesheet.webp"),            avatar_dir.join("spritesheet.webp"),        )        .unwrap();        let pet = Pet::load_with_codex_home(            &custom_pet_selector("legacy"),            /*codex_home*/ Some(codex_home.path()),        )        .unwrap();        assert_eq!(pet.id, "custom-legacy");        assert_eq!(pet.display_name, "Chefito");    }    #[test]    fn custom_pet_rejects_spritesheet_path_escape() {        let codex_home = tempfile::tempdir().unwrap();        let pet_dir = codex_home.path().join("pets").join("escape");        fs::create_dir_all(&pet_dir).unwrap();        fs::write(            pet_dir.join("pet.json"),            r#"{                "displayName": "Escape",                "spritesheetPath": "../spritesheet.webp"            }"#,        )        .unwrap();        let err = Pet::load_with_codex_home(            &custom_pet_selector("escape"),            /*codex_home*/ Some(codex_home.path()),        )        .unwrap_err();        assert!(            err.to_string()                .contains("spritesheet path must stay inside")        );    }    #[test]    fn custom_pet_rejects_zero_frame_dimensions() {        let dir = write_pet_manifest(            r#"{                "displayName": "Zero",                "spritesheetPath": "spritesheet.webp",                "frame": { "width": 0, "height": 208, "columns": 8, "rows": 9 }            }"#,        );        let err = load_pet_error_from_dir(&dir);        assert!(            err.to_string()                .contains("pet frame dimensions and grid counts must be non-zero")        );    }    #[test]    fn custom_pet_rejects_frame_grid_that_does_not_cover_spritesheet() {        let dir = write_pet_manifest(            r#"{                "displayName": "Short",                "spritesheetPath": "spritesheet.webp",                "frame": { "width": 192, "height": 208, "columns": 7, "rows": 9 }            }"#,        );        let err = load_pet_error_from_dir(&dir);        assert!(            err.to_string()                .contains("pet frame grid must cover spritesheet exactly")        );    }    #[test]    fn custom_pet_rejects_excessive_frame_count() {        let dir = write_pet_manifest(            r#"{                "displayName": "Dense",                "spritesheetPath": "spritesheet.webp",                "frame": { "width": 8, "height": 8, "columns": 192, "rows": 234 }            }"#,        );        let err = load_pet_error_from_dir(&dir);        assert!(err.to_string().contains("exceeds maximum"));    }    #[test]    fn custom_pet_rejects_empty_animation_frames() {        let dir = write_pet_manifest(            r#"{                "displayName": "Empty",                "spritesheetPath": "spritesheet.webp",                "animations": {                    "idle": { "frames": [] }                }            }"#,        );        let err = load_pet_error_from_dir(&dir);        assert!(            err.to_string()                .contains("animation idle must include at least one frame")        );    }    #[test]    fn custom_pet_rejects_animation_frame_outside_grid() {        let dir = write_pet_manifest(            r#"{                "displayName": "Outside",                "spritesheetPath": "spritesheet.webp",                "animations": {                    "idle": { "frames": [72] }                }            }"#,        );        let err = load_pet_error_from_dir(&dir);        assert!(            err.to_string()                .contains("animation idle references sprite index 72")        );    }    #[test]    fn custom_pet_rejects_invalid_animation_fps() {        let dir = write_pet_manifest(            r#"{                "displayName": "Fast",                "spritesheetPath": "spritesheet.webp",                "animations": {                    "idle": { "frames": [0], "fps": 120.0 }                }            }"#,        );        let err = load_pet_error_from_dir(&dir);        assert!(            err.to_string()                .contains("animation idle fps must be finite and between")        );    }    #[test]    fn custom_pet_rejects_animation_fallback_to_missing_animation() {        let dir = write_pet_manifest(            r#"{                "displayName": "Fallback",                "spritesheetPath": "spritesheet.webp",                "animations": {                    "wave": { "frames": [1], "loop": false, "fallback": "missing" }                }            }"#,        );        let err = load_pet_error_from_dir(&dir);        assert!(            err.to_string()                .contains("animation wave fallback missing does not exist")        );    }    fn sprite_indices(animation: &Animation) -> Vec<usize> {        animation            .frames            .iter()            .map(|frame| frame.sprite_index)            .collect()    }    fn durations_ms(animation: &Animation) -> Vec<u128> {        animation            .frames            .iter()            .map(|frame| frame.duration.as_millis())            .collect()    }}