use rusqlite::params; use serde::{Deserialize, Serialize}; use crate::db::Database; // ── domain types ───────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct Theme { pub id: i64, pub name: String, pub foreground: String, pub background: String, pub cursor: String, pub black: String, pub red: String, pub green: String, pub yellow: String, pub blue: String, pub magenta: String, pub cyan: String, pub white: String, pub bright_black: String, pub bright_red: String, pub bright_green: String, pub bright_yellow: String, pub bright_blue: String, pub bright_magenta: String, pub bright_cyan: String, pub bright_white: String, pub is_builtin: bool, } // ── helper struct for seed data ─────────────────────────────────────────────── struct BuiltinTheme { name: &'static str, foreground: &'static str, background: &'static str, cursor: &'static str, black: &'static str, red: &'static str, green: &'static str, yellow: &'static str, blue: &'static str, magenta: &'static str, cyan: &'static str, white: &'static str, bright_black: &'static str, bright_red: &'static str, bright_green: &'static str, bright_yellow: &'static str, bright_blue: &'static str, bright_magenta: &'static str, bright_cyan: &'static str, bright_white: &'static str, } // ── service ─────────────────────────────────────────────────────────────────── pub struct ThemeService { db: Database, } impl ThemeService { pub fn new(db: Database) -> Self { Self { db } } /// Insert the 7 built-in themes if they don't exist yet. /// /// Uses INSERT OR IGNORE so re-running on an existing database is safe. pub fn seed_builtins(&self) { let themes: &[BuiltinTheme] = &[ BuiltinTheme { name: "Dracula", foreground: "#f8f8f2", background: "#282a36", cursor: "#f8f8f2", black: "#21222c", red: "#ff5555", green: "#50fa7b", yellow: "#f1fa8c", blue: "#bd93f9", magenta: "#ff79c6", cyan: "#8be9fd", white: "#f8f8f2", bright_black: "#6272a4", bright_red: "#ff6e6e", bright_green: "#69ff94", bright_yellow: "#ffffa5", bright_blue: "#d6acff", bright_magenta: "#ff92df", bright_cyan: "#a4ffff", bright_white: "#ffffff", }, BuiltinTheme { name: "Nord", foreground: "#d8dee9", background: "#2e3440", cursor: "#d8dee9", black: "#3b4252", red: "#bf616a", green: "#a3be8c", yellow: "#ebcb8b", blue: "#81a1c1", magenta: "#b48ead", cyan: "#88c0d0", white: "#e5e9f0", bright_black: "#4c566a", bright_red: "#bf616a", bright_green: "#a3be8c", bright_yellow: "#ebcb8b", bright_blue: "#81a1c1", bright_magenta: "#b48ead", bright_cyan: "#8fbcbb", bright_white: "#eceff4", }, BuiltinTheme { name: "Monokai", foreground: "#f8f8f2", background: "#272822", cursor: "#f8f8f0", black: "#272822", red: "#f92672", green: "#a6e22e", yellow: "#f4bf75", blue: "#66d9ef", magenta: "#ae81ff", cyan: "#a1efe4", white: "#f8f8f2", bright_black: "#75715e", bright_red: "#f92672", bright_green: "#a6e22e", bright_yellow: "#f4bf75", bright_blue: "#66d9ef", bright_magenta: "#ae81ff", bright_cyan: "#a1efe4", bright_white: "#f9f8f5", }, BuiltinTheme { name: "One Dark", foreground: "#abb2bf", background: "#282c34", cursor: "#528bff", black: "#282c34", red: "#e06c75", green: "#98c379", yellow: "#e5c07b", blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf", bright_black: "#545862", bright_red: "#e06c75", bright_green: "#98c379", bright_yellow: "#e5c07b", bright_blue: "#61afef", bright_magenta: "#c678dd", bright_cyan: "#56b6c2", bright_white: "#c8ccd4", }, BuiltinTheme { name: "Solarized Dark", foreground: "#839496", background: "#002b36", cursor: "#839496", black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900", blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5", bright_black: "#002b36", bright_red: "#cb4b16", bright_green: "#586e75", bright_yellow: "#657b83", bright_blue: "#839496", bright_magenta: "#6c71c4", bright_cyan: "#93a1a1", bright_white: "#fdf6e3", }, BuiltinTheme { name: "Gruvbox Dark", foreground: "#ebdbb2", background: "#282828", cursor: "#ebdbb2", black: "#282828", red: "#cc241d", green: "#98971a", yellow: "#d79921", blue: "#458588", magenta: "#b16286", cyan: "#689d6a", white: "#a89984", bright_black: "#928374", bright_red: "#fb4934", bright_green: "#b8bb26", bright_yellow: "#fabd2f", bright_blue: "#83a598", bright_magenta: "#d3869b", bright_cyan: "#8ec07c", bright_white: "#ebdbb2", }, BuiltinTheme { name: "MobaXTerm Classic", foreground: "#ececec", background: "#242424", cursor: "#b4b4c0", black: "#000000", red: "#aa4244", green: "#7e8d53", yellow: "#e4b46d", blue: "#6e9aba", magenta: "#9e5085", cyan: "#80d5cf", white: "#cccccc", bright_black: "#808080", bright_red: "#cc7b7d", bright_green: "#a5b17c", bright_yellow: "#ecc995", bright_blue: "#96b6cd", bright_magenta: "#c083ac", bright_cyan: "#a9e2de", bright_white: "#cccccc", }, ]; let conn = self.db.conn(); for t in themes { if let Err(e) = conn.execute( "INSERT OR IGNORE INTO themes ( name, foreground, background, cursor, black, red, green, yellow, blue, magenta, cyan, white, bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white, is_builtin ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, 1 )", params![ t.name, t.foreground, t.background, t.cursor, t.black, t.red, t.green, t.yellow, t.blue, t.magenta, t.cyan, t.white, t.bright_black, t.bright_red, t.bright_green, t.bright_yellow, t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white, ], ) { eprintln!("theme::seed_builtins: failed to seed '{}': {}", t.name, e); } } } /// Return all themes ordered by built-in first, then name. pub fn list(&self) -> Vec { let conn = self.db.conn(); let mut stmt = match conn.prepare( "SELECT id, name, foreground, background, cursor, black, red, green, yellow, blue, magenta, cyan, white, bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white, is_builtin FROM themes ORDER BY is_builtin DESC, name COLLATE NOCASE ASC", ) { Ok(s) => s, Err(e) => { eprintln!("theme::list: failed to prepare query: {}", e); return vec![]; } }; match stmt.query_map([], map_theme_row) { Ok(rows) => rows .filter_map(|r| { r.map_err(|e| eprintln!("theme::list: row error: {}", e)) .ok() }) .collect(), Err(e) => { eprintln!("theme::list: query failed: {}", e); vec![] } } } /// Look up a theme by exact name (case-sensitive). pub fn get_by_name(&self, name: &str) -> Option { let conn = self.db.conn(); conn.query_row( "SELECT id, name, foreground, background, cursor, black, red, green, yellow, blue, magenta, cyan, white, bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white, is_builtin FROM themes WHERE name = ?1", params![name], map_theme_row, ) .ok() } } // ── private helpers ─────────────────────────────────────────────────────────── fn map_theme_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Theme { id: row.get(0)?, name: row.get(1)?, foreground: row.get(2)?, background: row.get(3)?, cursor: row.get(4)?, black: row.get(5)?, red: row.get(6)?, green: row.get(7)?, yellow: row.get(8)?, blue: row.get(9)?, magenta: row.get(10)?, cyan: row.get(11)?, white: row.get(12)?, bright_black: row.get(13)?, bright_red: row.get(14)?, bright_green: row.get(15)?, bright_yellow: row.get(16)?, bright_blue: row.get(17)?, bright_magenta: row.get(18)?, bright_cyan: row.get(19)?, bright_white: row.get(20)?, is_builtin: row.get(21)?, }) } // ── tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::db::Database; fn make_service() -> ThemeService { let db = Database::open(std::path::Path::new(":memory:")).unwrap(); db.migrate().unwrap(); ThemeService::new(db) } #[test] fn list_empty_before_seed() { let svc = make_service(); assert!(svc.list().is_empty()); } #[test] fn seed_builtins_creates_seven_themes() { let svc = make_service(); svc.seed_builtins(); let themes = svc.list(); assert_eq!(themes.len(), 7); } #[test] fn seed_builtins_is_idempotent() { let svc = make_service(); svc.seed_builtins(); svc.seed_builtins(); // second run must not duplicate assert_eq!(svc.list().len(), 7); } #[test] fn all_builtins_marked_as_builtin() { let svc = make_service(); svc.seed_builtins(); for theme in svc.list() { assert!(theme.is_builtin, "{} should be marked as builtin", theme.name); } } #[test] fn builtin_names_correct() { let svc = make_service(); svc.seed_builtins(); let names: Vec = svc.list().into_iter().map(|t| t.name).collect(); assert!(names.contains(&"Dracula".to_string())); assert!(names.contains(&"Nord".to_string())); assert!(names.contains(&"Monokai".to_string())); assert!(names.contains(&"One Dark".to_string())); assert!(names.contains(&"Solarized Dark".to_string())); assert!(names.contains(&"Gruvbox Dark".to_string())); assert!(names.contains(&"MobaXTerm Classic".to_string())); } #[test] fn get_by_name_returns_theme() { let svc = make_service(); svc.seed_builtins(); let theme = svc.get_by_name("Dracula"); assert!(theme.is_some()); let t = theme.unwrap(); assert_eq!(t.name, "Dracula"); assert_eq!(t.background, "#282a36"); assert_eq!(t.foreground, "#f8f8f2"); } #[test] fn get_by_name_missing_returns_none() { let svc = make_service(); svc.seed_builtins(); assert!(svc.get_by_name("Nonexistent Theme").is_none()); } #[test] fn get_by_name_is_case_sensitive() { let svc = make_service(); svc.seed_builtins(); assert!(svc.get_by_name("dracula").is_none()); // lowercase should not match } #[test] fn all_themes_have_valid_hex_colors() { let svc = make_service(); svc.seed_builtins(); for theme in svc.list() { let colors = [ &theme.foreground, &theme.background, &theme.cursor, &theme.black, &theme.red, &theme.green, &theme.yellow, &theme.blue, &theme.magenta, &theme.cyan, &theme.white, &theme.bright_black, &theme.bright_red, &theme.bright_green, &theme.bright_yellow, &theme.bright_blue, &theme.bright_magenta, &theme.bright_cyan, &theme.bright_white, ]; for color in colors { assert!( color.starts_with('#') && color.len() == 7, "Theme '{}' has invalid color: '{}'", theme.name, color ); } } } #[test] fn list_ordered_builtin_first_then_name() { let svc = make_service(); svc.seed_builtins(); let themes = svc.list(); // All are builtin, so should be ordered by name (case-insensitive) for w in themes.windows(2) { assert!( w[0].name.to_lowercase() <= w[1].name.to_lowercase(), "'{}' should come before '{}'", w[0].name, w[1].name ); } } }