Skip to main content

flashkraft_gui/core/
storage.rs

1//! GUI Settings Persistence
2//!
3//! Stores user preferences as a human-readable JSON file:
4//!
5//! | OS      | Path                                                              |
6//! |---------|-------------------------------------------------------------------|
7//! | macOS   | `~/Library/Application Support/flashkraft/gui-settings.json`     |
8//! | Linux   | `~/.config/flashkraft/gui-settings.json`                         |
9//! | Windows | `%APPDATA%\flashkraft\gui-settings.json`                          |
10//!
11//! # Theme catalogue
12//!
13//! All themes come directly from [`flashkraft_core::THEME_NAMES`] — **the
14//! single source of truth** shared with the TUI.  Every core theme is
15//! represented as an [`iced::Theme::Custom`] built via
16//! [`theme_from_core`], so GUI and TUI always show exactly the same set.
17
18use iced::Theme;
19use serde::{Deserialize, Serialize};
20use std::path::PathBuf;
21use std::sync::Arc;
22
23// ── Core → Iced conversion ────────────────────────────────────────────────────
24
25/// Build an Iced [`Theme::Custom`] from a core [`AppTheme`] preset.
26///
27/// This is the **only** theme-construction path in the GUI — every theme,
28/// including those that happen to share a name with an Iced built-in, is
29/// represented as a `Custom` theme so colours always match the core definition.
30pub fn custom_theme_from_core(name: &str, t: &flashkraft_core::AppTheme) -> Theme {
31    use iced::theme::{Custom, Palette};
32    let palette = Palette {
33        background: iced::Color::from_rgb8(t.background.r, t.background.g, t.background.b),
34        text: iced::Color::from_rgb8(t.text_primary.r, t.text_primary.g, t.text_primary.b),
35        primary: iced::Color::from_rgb8(t.accent.r, t.accent.g, t.accent.b),
36        success: iced::Color::from_rgb8(t.success.r, t.success.g, t.success.b),
37        warning: iced::Color::from_rgb8(t.warning.r, t.warning.g, t.warning.b),
38        danger: iced::Color::from_rgb8(t.error.r, t.error.g, t.error.b),
39    };
40    Theme::Custom(Arc::new(Custom::new(name.to_string(), palette)))
41}
42
43/// Build the Iced theme for core preset at `index`.
44///
45/// Convenience wrapper used by [`all_themes`] and [`theme_from_string`].
46fn theme_from_core(index: usize) -> Theme {
47    let t = flashkraft_core::theme_by_index(index);
48    custom_theme_from_core(flashkraft_core::THEME_NAMES[index], &t)
49}
50
51// ── Theme catalogue (single source of truth = core) ───────────────────────────
52
53/// Return all supported GUI themes, derived entirely from
54/// [`flashkraft_core::THEME_NAMES`].
55///
56/// The list is always in sync with the TUI — adding a theme to core
57/// automatically makes it available here with no further changes.
58pub fn all_themes() -> Vec<Theme> {
59    (0..flashkraft_core::THEME_COUNT)
60        .map(theme_from_core)
61        .collect()
62}
63
64/// Convert an Iced [`Theme`] to the string name used for persistence.
65///
66/// Only `Theme::Custom` can be produced by this crate, so we extract the
67/// name from the custom wrapper.  Fallback for any unexpected variant is
68/// `"Default"`.
69fn theme_to_string(theme: &Theme) -> String {
70    match theme {
71        Theme::Custom(c) => c.to_string(),
72        // Should never occur — all our themes are Custom — but handle
73        // gracefully in case a user somehow has a raw Iced variant saved.
74        other => {
75            // Best-effort: match against known Iced variant names
76            let s = format!("{other:?}");
77            // Iced Debug output is the variant name (e.g. "Dark", "Dracula")
78            // Check if core knows this name; if so use it, else "Default".
79            if flashkraft_core::theme_index_by_name(&s).is_some() {
80                s
81            } else {
82                "Default".to_string()
83            }
84        }
85    }
86}
87
88/// Reconstruct an Iced [`Theme`] from its persisted string name.
89///
90/// Looks up the name in the core catalogue and builds a `Theme::Custom`.
91/// Returns `None` only if the name is not found in core.
92fn theme_from_string(s: &str) -> Option<Theme> {
93    let idx = flashkraft_core::theme_index_by_name(s)?;
94    Some(theme_from_core(idx))
95}
96
97// ── Settings struct ───────────────────────────────────────────────────────────
98
99/// Persistent GUI user preferences.
100///
101/// Serialised to / deserialised from `gui-settings.json` in the OS config dir.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GuiSettings {
104    /// Name of the active Iced theme (e.g. `"TokyoNight"`).
105    #[serde(default = "default_theme_name")]
106    pub theme: String,
107}
108
109fn default_theme_name() -> String {
110    "Default".to_string()
111}
112
113impl Default for GuiSettings {
114    fn default() -> Self {
115        Self {
116            theme: default_theme_name(),
117        }
118    }
119}
120
121// ── Storage ───────────────────────────────────────────────────────────────────
122
123/// Manages loading and saving of [`GuiSettings`].
124#[derive(Debug)]
125pub struct Storage {
126    path: PathBuf,
127    settings: GuiSettings,
128}
129
130impl Storage {
131    /// Load settings from disk, creating the file with defaults if absent.
132    ///
133    /// Never panics — a missing or corrupt file silently yields defaults.
134    pub fn new() -> Result<Self, String> {
135        let path = Self::settings_path()?;
136        let settings = Self::load_from(&path);
137        Ok(Self { path, settings })
138    }
139
140    /// Return the active [`Theme`], or `None` if the stored name is unrecognised.
141    pub fn load_theme(&self) -> Option<Theme> {
142        theme_from_string(&self.settings.theme)
143    }
144
145    /// Persist a new theme choice to disk.
146    ///
147    /// Returns `Err` only if the file cannot be written.
148    pub fn save_theme(&mut self, theme: &Theme) -> Result<(), String> {
149        self.settings.theme = theme_to_string(theme);
150        self.flush()
151    }
152
153    // ── Internal ─────────────────────────────────────────────────────────────
154
155    fn settings_path() -> Result<PathBuf, String> {
156        let mut path =
157            dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
158        path.push("flashkraft");
159        std::fs::create_dir_all(&path)
160            .map_err(|e| format!("Failed to create config directory: {e}"))?;
161        path.push("gui-settings.json");
162        Ok(path)
163    }
164
165    fn load_from(path: &PathBuf) -> GuiSettings {
166        std::fs::read_to_string(path)
167            .ok()
168            .and_then(|s| serde_json::from_str(&s).ok())
169            .unwrap_or_default()
170    }
171
172    fn flush(&self) -> Result<(), String> {
173        let json = serde_json::to_string_pretty(&self.settings)
174            .map_err(|e| format!("Failed to serialise settings: {e}"))?;
175        std::fs::write(&self.path, json).map_err(|e| format!("Failed to write settings file: {e}"))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use tempfile::TempDir;
183
184    fn temp_storage() -> (Storage, TempDir) {
185        let tmp = tempfile::tempdir().unwrap();
186        let path = tmp.path().join("gui-settings.json");
187        let settings = GuiSettings::default();
188        let storage = Storage { path, settings };
189        (storage, tmp)
190    }
191
192    #[test]
193    fn test_theme_to_string() {
194        // All themes are now Custom — the name comes from the Custom wrapper
195        let default_theme = theme_from_core(0);
196        assert_eq!(theme_to_string(&default_theme), "Default");
197        let dracula = theme_from_string("Dracula").unwrap();
198        assert_eq!(theme_to_string(&dracula), "Dracula");
199    }
200
201    #[test]
202    fn test_theme_from_string() {
203        // All themes resolve to Theme::Custom
204        let default_theme = theme_from_string("Default");
205        assert!(default_theme.is_some());
206        assert!(matches!(default_theme.unwrap(), Theme::Custom(_)));
207
208        let dracula = theme_from_string("Dracula");
209        assert!(dracula.is_some());
210        assert!(matches!(dracula.unwrap(), Theme::Custom(_)));
211
212        assert!(theme_from_string("Invalid").is_none());
213    }
214
215    #[test]
216    fn test_roundtrip() {
217        for theme in all_themes() {
218            let name = theme_to_string(&theme);
219            let restored = theme_from_string(&name);
220            assert!(restored.is_some(), "roundtrip failed for {name}");
221        }
222    }
223
224    #[test]
225    fn test_all_themes_count() {
226        // Exactly matches flashkraft_core::THEME_COUNT — single source of truth
227        assert_eq!(all_themes().len(), flashkraft_core::THEME_COUNT);
228        assert_eq!(all_themes().len(), 43);
229    }
230
231    #[test]
232    fn test_save_and_load_theme() {
233        let (mut storage, _tmp) = temp_storage();
234        let tokyo = theme_from_core(flashkraft_core::theme_index_by_name("Tokyo Night").unwrap());
235        storage.save_theme(&tokyo).unwrap();
236        let loaded = storage.load_theme().unwrap();
237        assert_eq!(theme_to_string(&loaded), "Tokyo Night");
238    }
239
240    #[test]
241    fn test_save_writes_json_file() {
242        let (mut storage, _tmp) = temp_storage();
243        let nord = theme_from_string("Nord").unwrap();
244        storage.save_theme(&nord).unwrap();
245        let contents = std::fs::read_to_string(&storage.path).unwrap();
246        assert!(contents.contains("Nord"), "JSON should contain theme name");
247    }
248
249    #[test]
250    fn test_missing_file_yields_default() {
251        let tmp = tempfile::tempdir().unwrap();
252        let path = tmp.path().join("nonexistent.json");
253        let settings = Storage::load_from(&path);
254        assert_eq!(settings.theme, "Default");
255    }
256
257    #[test]
258    fn test_corrupt_file_yields_default() {
259        let tmp = tempfile::tempdir().unwrap();
260        let path = tmp.path().join("corrupt.json");
261        std::fs::write(&path, "not valid json {{{{").unwrap();
262        let settings = Storage::load_from(&path);
263        assert_eq!(settings.theme, "Default");
264    }
265
266    #[test]
267    fn test_all_themes_roundtrip() {
268        for theme in all_themes() {
269            let name = theme_to_string(&theme);
270            let restored = theme_from_string(&name).expect("roundtrip should succeed");
271            assert_eq!(
272                theme_to_string(&restored),
273                name,
274                "roundtrip failed for {name}"
275            );
276        }
277    }
278
279    #[test]
280    fn every_core_theme_is_present_in_gui() {
281        let gui_names: Vec<String> = all_themes().iter().map(theme_to_string).collect();
282        for name in flashkraft_core::THEME_NAMES {
283            assert!(
284                gui_names.iter().any(|n| n == name),
285                "Core theme '{name}' is missing from GUI all_themes()"
286            );
287        }
288    }
289}