ballin 0.1.2

A colorful interactive physics simulator with thousands of balls, but in your terminal.
Documentation
//! Save and load functionality for level configurations.
//!
//! This module provides serialization and deserialization of level state,
//! including shapes (positions, rotations, colors) and options settings.

use std::fs;
use std::path::Path;

use ratatui::style::Color;
use serde::{Deserialize, Serialize};

use crate::error::{AppError, AppResult};
use crate::shapes::types::{Shape, ShapeColor, ShapeType};
use crate::ui::menu::MenuValue;

/// Serializable representation of a shape's color.
///
/// Maps ratatui Color variants to string names for JSON portability.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SavedColor {
    Red,
    Green,
    Yellow,
    Blue,
    Magenta,
    Cyan,
}

impl SavedColor {
    /// Converts a ratatui Color to a SavedColor.
    ///
    /// Defaults to Green for unsupported colors.
    pub fn from_color(color: Color) -> Self {
        match color {
            Color::Red => SavedColor::Red,
            Color::Green => SavedColor::Green,
            Color::Yellow => SavedColor::Yellow,
            Color::Blue => SavedColor::Blue,
            Color::Magenta => SavedColor::Magenta,
            Color::Cyan => SavedColor::Cyan,
            _ => SavedColor::Green, // Default fallback
        }
    }

    /// Converts this SavedColor to a ratatui Color.
    pub fn to_color(&self) -> Color {
        match self {
            SavedColor::Red => Color::Red,
            SavedColor::Green => Color::Green,
            SavedColor::Yellow => Color::Yellow,
            SavedColor::Blue => Color::Blue,
            SavedColor::Magenta => Color::Magenta,
            SavedColor::Cyan => Color::Cyan,
        }
    }
}

/// Serializable representation of a shape type.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SavedShapeType {
    Circle,
    Triangle,
    Square,
    Star,
    LineStraight,
    LineVertical,
}

impl From<ShapeType> for SavedShapeType {
    fn from(shape_type: ShapeType) -> Self {
        match shape_type {
            ShapeType::Circle => SavedShapeType::Circle,
            ShapeType::Triangle => SavedShapeType::Triangle,
            ShapeType::Square => SavedShapeType::Square,
            ShapeType::Star => SavedShapeType::Star,
            ShapeType::LineStraight => SavedShapeType::LineStraight,
            ShapeType::LineVertical => SavedShapeType::LineVertical,
        }
    }
}

impl From<SavedShapeType> for ShapeType {
    fn from(saved: SavedShapeType) -> Self {
        match saved {
            SavedShapeType::Circle => ShapeType::Circle,
            SavedShapeType::Triangle => ShapeType::Triangle,
            SavedShapeType::Square => ShapeType::Square,
            SavedShapeType::Star => ShapeType::Star,
            SavedShapeType::LineStraight => ShapeType::LineStraight,
            SavedShapeType::LineVertical => ShapeType::LineVertical,
        }
    }
}

/// Serializable representation of a shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedShape {
    /// Shape type.
    pub shape_type: SavedShapeType,
    /// X position in physics coordinates.
    pub x: f32,
    /// Y position in physics coordinates.
    pub y: f32,
    /// Rotation in degrees (0, 90, 180, 270).
    pub rotation_degrees: i32,
    /// Shape color.
    pub color: SavedColor,
}

impl SavedShape {
    /// Creates a SavedShape from a Shape reference.
    pub fn from_shape(shape: &Shape) -> Self {
        let (x, y) = shape.position();
        Self {
            shape_type: shape.shape_type().into(),
            x,
            y,
            rotation_degrees: shape.rotation_degrees(),
            color: SavedColor::from_color(shape.color().color()),
        }
    }

    /// Converts this SavedShape data to parameters for creating a new Shape.
    pub fn to_shape_params(&self) -> (ShapeType, f32, f32, i32, ShapeColor) {
        let shape_type: ShapeType = self.shape_type.clone().into();
        (
            shape_type,
            self.x,
            self.y,
            self.rotation_degrees,
            ShapeColor::new(self.color.to_color()),
        )
    }
}

/// Serializable representation of options settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedOptions {
    /// Whether 60 FPS cap is enabled.
    #[serde(default)]
    pub fps_cap: bool,
    /// Whether FPS display is shown.
    #[serde(default = "default_true")]
    pub show_fps: bool,
    /// Ball count.
    #[serde(default = "default_ball_count")]
    pub ball_count: i32,
    /// Gravity percentage (0-500).
    #[serde(default = "default_percent")]
    pub gravity_percent: i32,
    /// Friction percentage (0-500).
    #[serde(default = "default_percent")]
    pub friction_percent: i32,
    /// Force percentage (10-500).
    #[serde(default = "default_percent")]
    pub force_percent: i32,
    /// Color Mode enabled.
    #[serde(default)]
    pub color_mode: bool,
    /// Spawn color probability (0-100).
    #[serde(default = "default_spawn_color")]
    pub spawn_color_percent: i32,
}

fn default_true() -> bool {
    true
}

fn default_ball_count() -> i32 {
    5000
}

fn default_percent() -> i32 {
    100
}

fn default_spawn_color() -> i32 {
    80
}

impl Default for SavedOptions {
    fn default() -> Self {
        Self {
            fps_cap: false,
            show_fps: true,
            ball_count: 5000,
            gravity_percent: 100,
            friction_percent: 100,
            force_percent: 100,
            color_mode: false,
            spawn_color_percent: 80,
        }
    }
}

/// Complete level configuration for save/load.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LevelConfig {
    /// File format version.
    #[serde(default = "default_version")]
    pub version: u32,
    /// All shapes in the level.
    pub shapes: Vec<SavedShape>,
    /// Options settings.
    pub options: SavedOptions,
}

fn default_version() -> u32 {
    1
}

impl LevelConfig {
    /// Creates a new empty level configuration.
    pub fn new() -> Self {
        Self {
            version: 1,
            shapes: Vec::new(),
            options: SavedOptions::default(),
        }
    }

    /// Saves the level configuration to a JSON file.
    ///
    /// # Arguments
    ///
    /// * `path` - Path to save the JSON file
    ///
    /// # Errors
    ///
    /// Returns an error if serialization or file writing fails.
    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> AppResult<()> {
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| AppError::Config(format!("Failed to serialize level config: {}", e)))?;

        fs::write(path, json)
            .map_err(|e| AppError::Config(format!("Failed to write level file: {}", e)))?;

        Ok(())
    }

    /// Loads a level configuration from a JSON file.
    ///
    /// # Arguments
    ///
    /// * `path` - Path to the JSON file
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be read or parsed.
    /// Gracefully handles malformed JSON by returning a descriptive error.
    pub fn load_from_file<P: AsRef<Path>>(path: P) -> AppResult<Self> {
        let path_ref = path.as_ref();

        let content = fs::read_to_string(path_ref).map_err(|e| {
            AppError::Config(format!(
                "Failed to read level file '{}': {}",
                path_ref.display(),
                e
            ))
        })?;

        let config: LevelConfig = serde_json::from_str(&content).map_err(|e| {
            AppError::Config(format!(
                "Failed to parse level file '{}': {}",
                path_ref.display(),
                e
            ))
        })?;

        Ok(config)
    }
}

impl Default for LevelConfig {
    fn default() -> Self {
        Self::new()
    }
}

/// Extracts options from an OptionsMenu and creates SavedOptions.
///
/// This function reads the current menu state and converts it to a
/// serializable format.
pub fn options_from_menu(menu: &crate::ui::OptionsMenu) -> SavedOptions {
    SavedOptions {
        fps_cap: menu.fps_cap_enabled(),
        show_fps: menu.show_fps(),
        ball_count: menu.ball_count() as i32,
        gravity_percent: menu.gravity_percent(),
        friction_percent: menu.friction_percent(),
        force_percent: (menu.force_percent() * 100.0) as i32,
        color_mode: menu.color_mode(),
        spawn_color_percent: menu.spawn_color_percent(),
    }
}

/// Applies saved options to an OptionsMenu.
///
/// This function updates the menu state with values from a SavedOptions struct.
pub fn apply_options_to_menu(options: &SavedOptions, menu: &mut crate::ui::OptionsMenu) {
    // We need to set each value individually using the menu's interface
    menu.set_value("Frame Cap", MenuValue::Boolean(options.fps_cap));
    menu.set_value("Show FPS", MenuValue::Boolean(options.show_fps));
    menu.set_value("Ball Count", MenuValue::Integer(options.ball_count));
    menu.set_value("Gravity %", MenuValue::Integer(options.gravity_percent));
    menu.set_value("Friction %", MenuValue::Integer(options.friction_percent));
    menu.set_value("Force %", MenuValue::Integer(options.force_percent));
    menu.set_value("Color Mode", MenuValue::Boolean(options.color_mode));
    menu.set_value(
        "Spawn Color %",
        MenuValue::Integer(options.spawn_color_percent),
    );
}

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

    #[test]
    fn test_saved_color_roundtrip() {
        let colors = [
            Color::Red,
            Color::Green,
            Color::Yellow,
            Color::Blue,
            Color::Magenta,
            Color::Cyan,
        ];

        for color in colors {
            let saved = SavedColor::from_color(color);
            let restored = saved.to_color();
            assert_eq!(color, restored);
        }
    }

    #[test]
    fn test_saved_shape_type_roundtrip() {
        for shape_type in ShapeType::all() {
            let saved: SavedShapeType = shape_type.into();
            let restored: ShapeType = saved.into();
            assert_eq!(shape_type, restored);
        }
    }

    #[test]
    fn test_level_config_serialization() {
        let config = LevelConfig {
            version: 1,
            shapes: vec![SavedShape {
                shape_type: SavedShapeType::Circle,
                x: 10.0,
                y: 20.0,
                rotation_degrees: 90,
                color: SavedColor::Red,
            }],
            options: SavedOptions::default(),
        };

        let json = serde_json::to_string(&config).unwrap();
        let restored: LevelConfig = serde_json::from_str(&json).unwrap();

        assert_eq!(restored.version, 1);
        assert_eq!(restored.shapes.len(), 1);
        assert_eq!(restored.shapes[0].x, 10.0);
    }

    #[test]
    fn test_malformed_json_error() {
        let result: Result<LevelConfig, _> = serde_json::from_str("{ invalid json }");
        assert!(result.is_err());
    }

    #[test]
    fn test_default_values_on_missing_fields() {
        let json = r#"{"shapes": [], "options": {}}"#;
        let config: LevelConfig = serde_json::from_str(json).unwrap();
        assert_eq!(config.version, 1);
        assert_eq!(config.options.ball_count, 5000);
        assert!(config.options.show_fps);
    }
}