rmcl 0.3.1

A fully featured Minecraft TUI launcher
// theme resolution: loads theme.toml, picks a base theme (builtin or custom file),
// then layers user color overrides on top. supports loading .toml themes from the
// config/theme/ directory or by absolute path.

use std::path::Path;
use std::sync::LazyLock;

use ratatui::style::Color;
use ratatui::widgets::BorderType;
use ratatui_themekit::{CustomTheme, Theme, resolve_theme};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BorderStyle {
    #[default]
    Rounded,
    Plain,
    Double,
    Thick,
}

impl BorderStyle {
    pub fn to_border_type(&self) -> BorderType {
        match self {
            Self::Rounded => BorderType::Rounded,
            Self::Plain => BorderType::Plain,
            Self::Double => BorderType::Double,
            Self::Thick => BorderType::Thick,
        }
    }
}

#[derive(Debug, Deserialize, Default)]
pub struct ThemeOverrides {
    pub accent: Option<Color>,
    pub accent_dim: Option<Color>,
    pub text: Option<Color>,
    pub text_dim: Option<Color>,
    pub text_bright: Option<Color>,
    pub success: Option<Color>,
    pub error: Option<Color>,
    pub warning: Option<Color>,
    pub info: Option<Color>,
    pub diff_added: Option<Color>,
    pub diff_removed: Option<Color>,
    pub diff_context: Option<Color>,
    pub border: Option<Color>,
    pub surface: Option<Color>,
    pub background: Option<Color>,
}

#[derive(Debug, Deserialize)]
pub struct ThemeConfig {
    #[serde(default)]
    pub border_style: BorderStyle,
    #[serde(default = "default_theme_name")]
    pub theme: String,
    #[serde(default)]
    pub custom: Option<ThemeOverrides>,
}

fn default_theme_name() -> String {
    "catppuccin".to_owned()
}

impl Default for ThemeConfig {
    fn default() -> Self {
        Self {
            border_style: BorderStyle::default(),
            theme: default_theme_name(),
            custom: None,
        }
    }
}

fn load_theme_config() -> ThemeConfig {
    let path = super::get_config_path().join("theme.toml");
    ensure_theme_exists(&path);
    match std::fs::read_to_string(&path) {
        Ok(content) => toml::from_str(&content).unwrap_or_else(|e| {
            tracing::warn!("Failed to parse theme.toml: {}. Using defaults.", e);
            ThemeConfig::default()
        }),
        Err(_) => ThemeConfig::default(),
    }
}

fn ensure_theme_exists(path: &Path) {
    if path.exists() {
        return;
    }
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let _ = std::fs::write(path, include_str!("../../assets/theme.toml"));
}

// start from a base theme, then override individual colors if the user specified any
fn resolve_app_theme(config: &ThemeConfig) -> Box<dyn Theme> {
    let base = load_base_theme(&config.theme);

    let Some(overrides) = &config.custom else {
        return base;
    };

    Box::new(CustomTheme {
        name: format!("{} (customized)", base.name()),
        id: base.id().to_owned(),
        accent: overrides.accent.unwrap_or_else(|| base.accent()),
        accent_dim: overrides.accent_dim.unwrap_or_else(|| base.accent_dim()),
        text: overrides.text.unwrap_or_else(|| base.text()),
        text_dim: overrides.text_dim.unwrap_or_else(|| base.text_dim()),
        text_bright: overrides.text_bright.unwrap_or_else(|| base.text_bright()),
        success: overrides.success.unwrap_or_else(|| base.success()),
        error: overrides.error.unwrap_or_else(|| base.error()),
        warning: overrides.warning.unwrap_or_else(|| base.warning()),
        info: overrides.info.unwrap_or_else(|| base.info()),
        diff_added: overrides.diff_added.unwrap_or_else(|| base.diff_added()),
        diff_removed: overrides
            .diff_removed
            .unwrap_or_else(|| base.diff_removed()),
        diff_context: overrides
            .diff_context
            .unwrap_or_else(|| base.diff_context()),
        border: overrides.border.unwrap_or_else(|| base.border()),
        surface: overrides.surface.unwrap_or_else(|| base.surface()),
        background: overrides.background.unwrap_or_else(|| base.background()),
    })
}

// tries to find the theme: absolute path > config/theme/<name> > config/theme/<name>.toml > builtin
fn load_base_theme(name: &str) -> Box<dyn Theme> {
    let path = if Path::new(name).is_absolute() {
        Some(std::path::PathBuf::from(name))
    } else {
        let theme_dir = super::get_config_path().join("theme");
        let candidate = theme_dir.join(name);
        if candidate.exists() {
            Some(candidate)
        } else {
            let with_ext = theme_dir.join(format!("{name}.toml"));
            if with_ext.exists() {
                Some(with_ext)
            } else {
                None
            }
        }
    };

    if let Some(path) = path
        && let Ok(content) = std::fs::read_to_string(&path)
    {
        if let Ok(custom) = toml::from_str::<CustomTheme>(&content) {
            return Box::new(custom);
        } else {
            tracing::warn!("Failed to parse theme file: {}", path.display());
        }
    }

    resolve_theme(name)
}

static THEME_CONFIG: LazyLock<ThemeConfig> = LazyLock::new(load_theme_config);

pub static THEME: LazyLock<Box<dyn Theme>> = LazyLock::new(|| resolve_app_theme(&THEME_CONFIG));

pub static BORDER_STYLE: LazyLock<BorderStyle> =
    LazyLock::new(|| THEME_CONFIG.border_style.clone());

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

    // cover every BorderStyle variant. a mutation that swaps two arms of the
    // match (e.g. Rounded -> Plain) would slip past testing just one variant.
    #[rstest::rstest]
    #[case::plain(BorderStyle::Plain, BorderType::Plain)]
    #[case::rounded(BorderStyle::Rounded, BorderType::Rounded)]
    #[case::double(BorderStyle::Double, BorderType::Double)]
    #[case::thick(BorderStyle::Thick, BorderType::Thick)]
    fn border_style_roundtrip(#[case] style: BorderStyle, #[case] expected: BorderType) {
        assert_eq!(style.to_border_type(), expected);
    }

    #[test]
    fn theme_config_deserialize_builtin() {
        let toml_str = r#"
theme = "dracula"
border_style = "plain"
"#;
        let config: ThemeConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(config.theme, "dracula");
        assert_eq!(config.border_style, BorderStyle::Plain);
        assert!(config.custom.is_none());
    }

    #[test]
    fn theme_config_with_partial_overrides() {
        let toml_str = r#"
theme = "dracula"

[custom]
accent = "Red"
"#;
        let config: ThemeConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(config.theme, "dracula");
        let overrides = config.custom.unwrap();
        assert_eq!(overrides.accent, Some(Color::Red));
        assert!(overrides.text.is_none());
    }

    #[test]
    fn resolve_with_overrides_keeps_base() {
        let config = ThemeConfig {
            theme: "dracula".to_owned(),
            custom: Some(ThemeOverrides {
                accent: Some(Color::Red),
                ..ThemeOverrides::default()
            }),
            ..ThemeConfig::default()
        };
        let theme = resolve_app_theme(&config);
        assert_eq!(theme.accent(), Color::Red);
        let base = resolve_theme("dracula");
        assert_eq!(theme.text(), base.text());
        assert_eq!(theme.error(), base.error());
    }

    #[test]
    fn resolve_builtin_theme() {
        let config = ThemeConfig {
            theme: "dracula".to_owned(),
            ..ThemeConfig::default()
        };
        let theme = resolve_app_theme(&config);
        assert_eq!(theme.id(), "dracula");
    }

    #[test]
    fn theme_config_empty_toml_uses_defaults() {
        let config: ThemeConfig = toml::from_str("").unwrap();
        assert_eq!(config.theme, "catppuccin");
        assert_eq!(config.border_style, BorderStyle::Rounded);
    }
}