jolt-theme 1.2.0-beta.1

Theme system for terminal TUI applications with iTerm2 import support
Documentation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::iterm2::{list_available_schemes, lookup_variant_pair, Iterm2Error, SchemeVariant};

const CACHE_TTL_DAYS: u64 = 5;
const CACHE_FILENAME: &str = "iterm2_schemes.json";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeGroup {
    pub name: String,
    pub dark: Option<String>,
    pub light: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedSchemeList {
    pub timestamp: u64,
    pub schemes: Vec<String>,
    pub groups: Vec<ThemeGroup>,
}

impl CachedSchemeList {
    pub fn is_expired(&self) -> bool {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let age_days = (now - self.timestamp) / 86400;
        age_days >= CACHE_TTL_DAYS
    }

    pub fn age_description(&self) -> String {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let age_secs = now.saturating_sub(self.timestamp);

        if age_secs < 60 {
            "just now".to_string()
        } else if age_secs < 3600 {
            format!("{} minutes ago", age_secs / 60)
        } else if age_secs < 86400 {
            format!("{} hours ago", age_secs / 3600)
        } else {
            format!("{} days ago", age_secs / 86400)
        }
    }
}

fn cache_path(cache_dir: &Path) -> std::path::PathBuf {
    cache_dir.join(CACHE_FILENAME)
}

pub fn load_cached_schemes(cache_dir: &Path) -> Option<CachedSchemeList> {
    let path = cache_path(cache_dir);
    if !path.exists() {
        return None;
    }

    let content = fs::read_to_string(&path).ok()?;
    serde_json::from_str(&content).ok()
}

pub fn save_cached_schemes(cache_dir: &Path, cache: &CachedSchemeList) -> std::io::Result<()> {
    fs::create_dir_all(cache_dir)?;
    let path = cache_path(cache_dir);
    let content = serde_json::to_string_pretty(cache)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    fs::write(path, content)
}

pub fn group_schemes(schemes: &[String]) -> Vec<ThemeGroup> {
    let scheme_set: std::collections::HashSet<&str> = schemes.iter().map(|s| s.as_str()).collect();
    let mut grouped: HashMap<String, ThemeGroup> = HashMap::new();
    let mut processed: std::collections::HashSet<&str> = std::collections::HashSet::new();

    for scheme in schemes {
        if processed.contains(scheme.as_str()) {
            continue;
        }

        if let Some((dark, light)) = lookup_variant_pair(scheme) {
            let group_name = derive_group_name(dark, light);
            let has_dark = scheme_set.contains(dark);
            let has_light = scheme_set.contains(light);

            grouped.insert(
                group_name.clone(),
                ThemeGroup {
                    name: group_name,
                    dark: if has_dark {
                        Some(dark.to_string())
                    } else {
                        None
                    },
                    light: if has_light {
                        Some(light.to_string())
                    } else {
                        None
                    },
                },
            );

            processed.insert(dark);
            processed.insert(light);
        }
    }

    for scheme in schemes {
        if processed.contains(scheme.as_str()) {
            continue;
        }

        let group_name = scheme.clone();
        let variant = detect_variant_from_name(scheme);

        grouped.insert(
            group_name.clone(),
            ThemeGroup {
                name: group_name,
                dark: if variant != SchemeVariant::Light {
                    Some(scheme.clone())
                } else {
                    None
                },
                light: if variant == SchemeVariant::Light {
                    Some(scheme.clone())
                } else {
                    None
                },
            },
        );
        processed.insert(scheme);
    }

    let mut groups: Vec<ThemeGroup> = grouped.into_values().collect();
    groups.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
    groups
}

fn derive_group_name(dark: &str, light: &str) -> String {
    let dark_lower = dark.to_lowercase();
    let light_lower = light.to_lowercase();

    for suffix in [" dark", " light", " night", " day", " moon", " dawn"] {
        if dark_lower.ends_with(suffix) {
            return dark[..dark.len() - suffix.len()].to_string();
        }
        if light_lower.ends_with(suffix) {
            return light[..light.len() - suffix.len()].to_string();
        }
    }

    if dark.len() <= light.len() {
        dark.to_string()
    } else {
        light.to_string()
    }
}

fn detect_variant_from_name(name: &str) -> SchemeVariant {
    let lower = name.to_lowercase();
    if lower.contains("light") || lower.contains("day") || lower.contains("dawn") {
        SchemeVariant::Light
    } else if lower.contains("dark") || lower.contains("night") || lower.contains("moon") {
        SchemeVariant::Dark
    } else {
        SchemeVariant::Unknown
    }
}

pub fn fetch_and_cache_schemes(
    cache_dir: &Path,
    force: bool,
) -> Result<CachedSchemeList, Iterm2Error> {
    if !force {
        if let Some(cached) = load_cached_schemes(cache_dir) {
            if !cached.is_expired() {
                return Ok(cached);
            }
        }
    }

    let schemes = list_available_schemes()?;
    let groups = group_schemes(&schemes);

    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    let cache = CachedSchemeList {
        timestamp,
        schemes,
        groups,
    };

    let _ = save_cached_schemes(cache_dir, &cache);

    Ok(cache)
}

pub fn get_cached_or_empty(cache_dir: &Path) -> CachedSchemeList {
    load_cached_schemes(cache_dir).unwrap_or_else(|| CachedSchemeList {
        timestamp: 0,
        schemes: Vec::new(),
        groups: Vec::new(),
    })
}