chara 0.2.0

Parser for layered character definition files
Documentation
#![deny(missing_docs)]

//! A parser for layered character definition files.
//!
//! Handles a simple line-based format where:
//! - Each layer starts with its internal name
//! - Optional display name can follow
//! - Variants are listed with `-` prefix
//! - First variant is default
//! - Empty path indicates disabled state

/// A single visual variant within a character layer
#[derive(Debug)]
pub struct LayerEntry {
    /// Display name for this variant
    pub name: Box<str>,
    /// Path to image asset; can be empty
    pub path: Box<str>,
}

/// Collection of variants for a logical character layer
#[derive(Debug)]
pub struct LayerGroup {
    /// Machine identifier
    pub internal_name: Box<str>,
    /// Optional human-readable name for UIs
    pub display_name: Option<Box<str>>,
    /// Available variants in declaration order
    pub entries: Vec<LayerEntry>,
}

/// Complete character configuration
#[derive(Debug)]
pub struct CharacterDefinition {
    /// All layers in their original definition order
    pub layers: Vec<LayerGroup>,
}

impl CharacterDefinition {
    /// Parses character definition from string content
    ///
    /// Supports `#` for comments and empty lines are ignored
    ///
    /// # Format
    ///
    /// ```text
    /// # Comment line
    /// [internal_name]: [display_name]  # Inline comment
    /// - [variant_name]: [image_path]
    /// - None  # Special case to disable layer
    /// ```
    ///
    /// # Example
    ///
    /// ```
    /// let input = r"
    /// base
    /// - Default: base.png
    ///
    /// expression: Mood
    /// - Happy: happy.png
    /// - Sad: sad.png
    /// ";
    ///
    /// let def = chara::CharacterDefinition::parse(input);
    /// ```
    pub fn parse(input: &str) -> Self {
        let mut layers = Vec::new();
        let mut current_group: Option<LayerGroup> = None;

        for line in input.lines() {
            let line = line
                .split_once('#')
                .map_or(line, |(entry, _comment)| entry)
                .trim();
            if line.is_empty() {
                continue;
            }

            if let Some(entry) = line.strip_prefix('-') {
                if let Some(group) = &mut current_group {
                    let entry = entry.trim();
                    group
                        .entries
                        .push(if let Some((name, path)) = entry.split_once(':') {
                            LayerEntry {
                                name: name.trim_end().into(),
                                path: path.trim_start().into(),
                            }
                        } else {
                            LayerEntry {
                                name: entry.into(),
                                path: "".into(),
                            }
                        });
                }

                continue;
            }

            if let Some(group) = current_group.take() {
                layers.push(group);
            }

            let (internal_name, display_name) = line.split_once(':').map_or_else(
                || (line.trim().into(), None),
                |(internal, display)| (internal.trim().into(), Some(display.trim().into())),
            );

            current_group = Some(LayerGroup {
                internal_name,
                display_name,
                entries: Vec::new(),
            });
        }

        if let Some(group) = current_group.take() {
            layers.push(group);
        }

        Self { layers }
    }
}

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

    #[test]
    fn parses_basic_definition() {
        let input = r"
base
- Default: base.png

expression: Mood
- Happy: happy.png
- Sad: sad.png
";

        let def = CharacterDefinition::parse(input);
        assert_eq!(def.layers.len(), 2);
        assert_eq!(def.layers[0].internal_name.as_ref(), "base");
        assert_eq!(def.layers[1].display_name.as_deref(), Some("Mood"));
        assert_eq!(def.layers[1].entries[1].name.as_ref(), "Sad");
    }

    #[test]
    fn handles_empty_paths() {
        let input = r"
outfit
- Shirt: shirt.png
- None
";

        let def = CharacterDefinition::parse(input);
        assert!(def.layers[0].entries[1].path.is_empty());
    }

    #[test]
    fn ignores_comments() {
        let input = r"
# This is a comment
base  # Base layer comment
- Default: base.png  # Default variant

# Expression group
expression: Mood
- Happy: happy.png
- Sad: sad.png  # Disabled below
- None
";

        let def = CharacterDefinition::parse(input);
        assert_eq!(def.layers.len(), 2);
        assert_eq!(def.layers[0].internal_name.as_ref(), "base");
        assert!(def.layers[1].entries[2].path.is_empty());
    }

    #[test]
    fn handles_inline_comments() {
        let input = r"
base # Important base layer
- Default: base.png # Main variant
- Alternate: alternate.png
";

        let def = CharacterDefinition::parse(input);
        assert_eq!(def.layers[0].entries[0].name.as_ref(), "Default");
        assert_eq!(def.layers[0].entries[1].path.as_ref(), "alternate.png");
    }
}