Skip to main content

gitkraft_core/features/persistence/
ops.rs

1//! Persistence operations backed by a plain JSON file.
2//!
3//! Settings are stored at `~/.config/gitkraft/settings.json` (or the
4//! platform-appropriate config directory).  Writes are **atomic**: content is
5//! first written to a `.tmp` sibling and then renamed into place, so a crash
6//! mid-write can never produce a corrupted file.
7//!
8//! GUI settings are stored in `settings.json`; TUI settings are stored in
9//! `tui-settings.json`.  The two files are independent so each UI can evolve
10//! its own preferences (theme index, session, etc.) without stomping the other.
11
12use super::types::{AppSettings, RepoHistoryEntry};
13use anyhow::{Context, Result};
14use std::path::{Path, PathBuf};
15
16// ── Path helpers ──────────────────────────────────────────────────────────────
17
18/// Returns the settings directory (`~/.config/gitkraft/` or equivalent).
19pub fn settings_dir() -> Result<PathBuf> {
20    let base = dirs::config_dir().context("could not determine config directory")?;
21    Ok(base.join("gitkraft"))
22}
23
24/// Full path to the GUI JSON settings file (public so frontends can open it in an editor).
25pub fn settings_json_path() -> Result<PathBuf> {
26    Ok(settings_dir()?.join("settings.json"))
27}
28
29/// Full path to the TUI-specific JSON settings file (public so the TUI can open it in an editor).
30pub fn tui_settings_json_path() -> Result<PathBuf> {
31    Ok(settings_dir()?.join("tui-settings.json"))
32}
33
34/// Full path to the GUI JSON settings file.
35fn json_path() -> Result<PathBuf> {
36    settings_json_path()
37}
38
39/// Full path to the TUI-specific JSON settings file.
40fn tui_json_path() -> Result<PathBuf> {
41    tui_settings_json_path()
42}
43
44// ── Internal I/O helpers ──────────────────────────────────────────────────────
45
46/// Load settings from any JSON path (internal).
47fn load_from(path: &std::path::Path) -> Result<AppSettings> {
48    if path.exists() {
49        let content = std::fs::read_to_string(path)
50            .with_context(|| format!("failed to read {}", path.display()))?;
51        return match serde_json::from_str::<AppSettings>(&content) {
52            Ok(s) => Ok(s),
53            Err(e) => {
54                tracing::warn!(
55                    "settings file {:?} is malformed ({e}); using defaults",
56                    path
57                );
58                Ok(AppSettings::default())
59            }
60        };
61    }
62    Ok(AppSettings::default())
63}
64
65/// Save settings to any JSON path (internal, atomic write).
66fn save_to(path: &std::path::Path, settings: &AppSettings) -> Result<()> {
67    if let Some(parent) = path.parent() {
68        std::fs::create_dir_all(parent)
69            .with_context(|| format!("failed to create directory {}", parent.display()))?;
70    }
71    let tmp = path.with_extension("json.tmp");
72    let content = serde_json::to_string_pretty(settings).context("failed to serialise settings")?;
73    std::fs::write(&tmp, &content).with_context(|| format!("failed to write {}", tmp.display()))?;
74    std::fs::rename(&tmp, path)
75        .with_context(|| format!("failed to rename {} → {}", tmp.display(), path.display()))?;
76    Ok(())
77}
78
79// ── GUI settings (settings.json) ─────────────────────────────────────────────
80
81/// Load GUI application settings.
82///
83/// Returns `AppSettings::default()` when the file does not exist yet (first run)
84/// or when the file is malformed (file is preserved for manual recovery).
85pub fn load_settings() -> Result<AppSettings> {
86    load_from(&json_path()?)
87}
88
89/// Persist GUI application settings to `settings.json` using an atomic write.
90pub fn save_settings(settings: &AppSettings) -> Result<()> {
91    save_to(&json_path()?, settings)
92}
93
94/// Record that a repository was opened (updates history + `last_repo`).
95pub fn record_repo_opened(path: &Path) -> Result<()> {
96    let mut settings = load_settings()?;
97    settings.add_recent_repo(path.to_path_buf());
98    save_settings(&settings)
99}
100
101/// Return the last opened repository path, if any.
102pub fn get_last_repo() -> Result<Option<PathBuf>> {
103    Ok(load_settings()?.last_repo)
104}
105
106/// Persist the selected theme name.
107pub fn save_theme(theme_name: &str) -> Result<()> {
108    let mut settings = load_settings()?;
109    settings.theme_name = Some(theme_name.to_string());
110    save_settings(&settings)
111}
112
113/// Return the saved theme name, if any.
114pub fn get_saved_theme() -> Result<Option<String>> {
115    Ok(load_settings()?.theme_name)
116}
117
118/// Persist the selected editor name.
119pub fn save_editor(editor_name: &str) -> Result<()> {
120    let mut settings = load_settings()?;
121    settings.editor_name = Some(editor_name.to_string());
122    save_settings(&settings)
123}
124
125/// Return the saved editor name, if any.
126pub fn get_saved_editor() -> Result<Option<String>> {
127    Ok(load_settings()?.editor_name)
128}
129
130/// Persist layout preferences.
131pub fn save_layout(layout: &super::types::LayoutSettings) -> Result<()> {
132    let mut settings = load_settings()?;
133    settings.layout = Some(layout.clone());
134    save_settings(&settings)
135}
136
137/// Return saved layout preferences, if any.
138pub fn get_saved_layout() -> Result<Option<super::types::LayoutSettings>> {
139    Ok(load_settings()?.layout)
140}
141
142/// Record that a repo was opened AND update the open-tab session in a single
143/// write (saves one round-trip to disk).
144pub fn record_repo_and_save_session(
145    path: &Path,
146    open_tabs: &[PathBuf],
147    active_tab_index: usize,
148) -> Result<Vec<RepoHistoryEntry>> {
149    let mut settings = load_settings()?;
150    settings.add_recent_repo(path.to_path_buf());
151    settings.open_tabs = open_tabs.to_vec();
152    settings.active_tab_index = active_tab_index;
153    save_settings(&settings)?;
154    Ok(settings.recent_repos)
155}
156
157/// Persist the open-tab session without modifying the recent-repos list.
158pub fn save_session(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
159    let mut settings = load_settings()?;
160    settings.open_tabs = open_tabs.to_vec();
161    settings.active_tab_index = active_tab_index;
162    save_settings(&settings)
163}
164
165// ── TUI settings (tui-settings.json) ─────────────────────────────────────────
166
167/// Load TUI application settings from `tui-settings.json`.
168pub fn load_tui_settings() -> Result<AppSettings> {
169    let mut settings = load_from(&tui_json_path()?)?;
170    // If the TUI has no editor configured, fall back to the GUI's editor
171    // setting so the user only has to configure their editor once.
172    if settings.editor_name.is_none() {
173        if let Ok(gui) = load_from(&json_path()?) {
174            if gui.editor_name.is_some() {
175                settings.editor_name = gui.editor_name;
176            }
177        }
178    }
179    Ok(settings)
180}
181
182/// Persist TUI application settings to `tui-settings.json` using an atomic write.
183pub fn save_tui_settings(settings: &AppSettings) -> Result<()> {
184    save_to(&tui_json_path()?, settings)
185}
186
187/// Record that a repository was opened in the TUI.
188pub fn record_repo_opened_tui(path: &std::path::Path) -> Result<()> {
189    let mut settings = load_tui_settings()?;
190    settings.add_recent_repo(path.to_path_buf());
191    save_tui_settings(&settings)
192}
193
194/// Return the last TUI-opened repository path, if any.
195pub fn get_last_tui_repo() -> Result<Option<PathBuf>> {
196    Ok(load_tui_settings()?.last_repo)
197}
198
199/// Persist the TUI theme selection.
200pub fn save_theme_tui(theme_name: &str) -> Result<()> {
201    let mut settings = load_tui_settings()?;
202    settings.theme_name = Some(theme_name.to_string());
203    save_tui_settings(&settings)
204}
205
206/// Persist the TUI editor selection.
207pub fn save_editor_tui(editor_name: &str) -> Result<()> {
208    let mut settings = load_tui_settings()?;
209    settings.editor_name = Some(editor_name.to_string());
210    save_tui_settings(&settings)
211}
212
213/// Persist the TUI open-tab session.
214pub fn save_session_tui(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
215    let mut settings = load_tui_settings()?;
216    settings.open_tabs = open_tabs.to_vec();
217    settings.active_tab_index = active_tab_index;
218    save_tui_settings(&settings)
219}
220
221// ── Tests ─────────────────────────────────────────────────────────────────────
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use tempfile::TempDir;
227
228    // ── In-process helpers (bypass dirs::config_dir) ──────────────────────────
229
230    fn write_json(dir: &TempDir, settings: &AppSettings) {
231        let path = dir.path().join("settings.json");
232        let tmp = dir.path().join("settings.json.tmp");
233        let content = serde_json::to_string_pretty(settings).unwrap();
234        std::fs::write(&tmp, &content).unwrap();
235        std::fs::rename(&tmp, &path).unwrap();
236    }
237
238    fn read_json(dir: &TempDir) -> AppSettings {
239        let path = dir.path().join("settings.json");
240        let content = std::fs::read_to_string(&path).unwrap();
241        serde_json::from_str(&content).unwrap()
242    }
243
244    // ── AppSettings serde round-trip ──────────────────────────────────────────
245
246    #[test]
247    fn settings_json_round_trip() {
248        let dir = TempDir::new().unwrap();
249        let mut s = AppSettings {
250            theme_name: Some("Dracula".to_string()),
251            editor_name: Some("code".to_string()),
252            ..Default::default()
253        };
254        s.add_recent_repo(PathBuf::from("/tmp/repo-a"));
255        s.add_recent_repo(PathBuf::from("/tmp/repo-b"));
256
257        write_json(&dir, &s);
258        let loaded = read_json(&dir);
259
260        assert_eq!(loaded.theme_name, Some("Dracula".to_string()));
261        assert_eq!(loaded.editor_name, Some("code".to_string()));
262        assert_eq!(loaded.recent_repos.len(), 2);
263        assert_eq!(loaded.recent_repos[0].path, PathBuf::from("/tmp/repo-b"));
264        assert_eq!(loaded.recent_repos[1].path, PathBuf::from("/tmp/repo-a"));
265    }
266
267    #[test]
268    fn settings_json_preserves_open_tabs_and_active_index() {
269        let dir = TempDir::new().unwrap();
270        let s = AppSettings {
271            open_tabs: vec![PathBuf::from("/tmp/repo-1"), PathBuf::from("/tmp/repo-2")],
272            active_tab_index: 1,
273            ..Default::default()
274        };
275
276        write_json(&dir, &s);
277        let loaded = read_json(&dir);
278
279        assert_eq!(loaded.open_tabs.len(), 2);
280        assert_eq!(loaded.active_tab_index, 1);
281    }
282
283    #[test]
284    fn settings_json_preserves_layout() {
285        let dir = TempDir::new().unwrap();
286        let s = AppSettings {
287            layout: Some(super::super::types::LayoutSettings {
288                sidebar_width: Some(220.0),
289                commit_log_width: Some(400.0),
290                staging_height: Some(150.0),
291                diff_file_list_width: Some(180.0),
292                sidebar_expanded: Some(true),
293                ui_scale: Some(1.25),
294                ..Default::default()
295            }),
296            ..Default::default()
297        };
298
299        write_json(&dir, &s);
300        let loaded = read_json(&dir);
301        let layout = loaded.layout.unwrap();
302
303        assert!((layout.sidebar_width.unwrap() - 220.0).abs() < f32::EPSILON);
304        assert_eq!(layout.sidebar_expanded, Some(true));
305        assert!((layout.ui_scale.unwrap() - 1.25).abs() < f32::EPSILON);
306    }
307
308    #[test]
309    fn malformed_json_deserialises_to_defaults() {
310        let dir = TempDir::new().unwrap();
311        let path = dir.path().join("settings.json");
312        // Write garbage — simulates a half-written file from a crash.
313        std::fs::write(&path, b"{ this is not valid json !!!").unwrap();
314
315        // serde_json::from_str should fail; caller should get AppSettings::default().
316        let result = serde_json::from_str::<AppSettings>(&std::fs::read_to_string(&path).unwrap());
317        assert!(
318            result.is_err(),
319            "malformed JSON must not parse successfully"
320        );
321        // The file must still exist (we must not delete it).
322        assert!(path.exists(), "malformed file must be preserved");
323    }
324
325    #[test]
326    fn atomic_write_produces_no_tmp_file_on_success() {
327        let dir = TempDir::new().unwrap();
328        let s = AppSettings::default();
329        write_json(&dir, &s);
330
331        let tmp = dir.path().join("settings.json.tmp");
332        assert!(
333            !tmp.exists(),
334            "tmp file must be removed after a successful atomic write"
335        );
336        assert!(dir.path().join("settings.json").exists());
337    }
338
339    #[test]
340    fn serde_default_missing_fields_load_cleanly() {
341        // A JSON object with only known fields — new fields added in future
342        // versions should not break loading older settings files.
343        let dir = TempDir::new().unwrap();
344        let minimal = r#"{"last_repo": null, "recent_repos": [], "theme_name": "Nord"}"#;
345        std::fs::write(dir.path().join("settings.json"), minimal).unwrap();
346
347        let loaded = read_json(&dir);
348        assert_eq!(loaded.theme_name, Some("Nord".to_string()));
349        assert_eq!(loaded.max_recent, 20); // default
350        assert_eq!(loaded.active_tab_index, 0); // default
351        assert!(loaded.open_tabs.is_empty()); // default
352    }
353
354    // ── AppSettings helper logic ──────────────────────────────────────────────
355
356    #[test]
357    fn add_recent_deduplicates() {
358        let mut settings = AppSettings::default();
359        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
360        settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
361        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
362        assert_eq!(settings.recent_repos.len(), 2);
363        assert_eq!(settings.recent_repos[0].path, PathBuf::from("/tmp/repo1"));
364    }
365
366    #[test]
367    fn add_recent_respects_max() {
368        let mut settings = AppSettings {
369            max_recent: 3,
370            ..Default::default()
371        };
372        for i in 0..5 {
373            settings.add_recent_repo(PathBuf::from(format!("/tmp/repo{i}")));
374        }
375        assert_eq!(settings.recent_repos.len(), 3);
376    }
377
378    #[test]
379    fn settings_round_trip_via_json_bytes() {
380        let mut settings = AppSettings::default();
381        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
382        settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
383        settings.theme_name = Some("Dark".to_string());
384
385        let json = serde_json::to_string(&settings).unwrap();
386        let decoded: AppSettings = serde_json::from_str(&json).unwrap();
387        assert_eq!(decoded.recent_repos.len(), 2);
388        assert_eq!(decoded.theme_name, Some("Dark".to_string()));
389    }
390
391    // ── TUI path tests ────────────────────────────────────────────────────────
392
393    #[test]
394    fn tui_and_gui_settings_are_independent() {
395        // Verify the path names differ so they won't overwrite each other.
396        let gui = json_path().unwrap();
397        let tui = tui_json_path().unwrap();
398        assert_ne!(gui, tui);
399        assert!(gui.to_str().unwrap().ends_with("settings.json"));
400        assert!(tui.to_str().unwrap().ends_with("tui-settings.json"));
401    }
402
403    #[test]
404    fn load_tui_inherits_editor_from_gui_when_tui_has_none() {
405        let dir = TempDir::new().unwrap();
406
407        // Write GUI settings with an editor configured.
408        let gui = AppSettings {
409            editor_name: Some("Helix".to_string()),
410            ..Default::default()
411        };
412        write_json(&dir, &gui);
413
414        // TUI settings exist but have no editor_name.
415        let tui_path = dir.path().join("tui-settings.json");
416        let tui_content = r#"{"last_repo":null,"recent_repos":[],"theme_name":null}"#;
417        std::fs::write(&tui_path, tui_content).unwrap();
418
419        // load_from on just the TUI file gives no editor.
420        let tui_raw = load_from(&tui_path).unwrap();
421        assert!(tui_raw.editor_name.is_none());
422
423        // The fallback logic (mirroring load_tui_settings) should pick up the
424        // GUI editor when TUI has none.
425        let gui_loaded = load_from(&dir.path().join("settings.json")).unwrap();
426        let mut merged = tui_raw;
427        if merged.editor_name.is_none() {
428            merged.editor_name = gui_loaded.editor_name;
429        }
430        assert_eq!(merged.editor_name.as_deref(), Some("Helix"));
431    }
432
433    #[test]
434    fn load_tui_keeps_own_editor_when_configured() {
435        let dir = TempDir::new().unwrap();
436
437        // GUI has one editor, TUI has a different one.
438        let gui = AppSettings {
439            editor_name: Some("VS Code".to_string()),
440            ..Default::default()
441        };
442        write_json(&dir, &gui);
443
444        let tui_path = dir.path().join("tui-settings.json");
445        let tui_content = r#"{"last_repo":null,"recent_repos":[],"editor_name":"Neovim"}"#;
446        std::fs::write(&tui_path, tui_content).unwrap();
447
448        let tui_raw = load_from(&tui_path).unwrap();
449        assert_eq!(tui_raw.editor_name.as_deref(), Some("Neovim"));
450
451        // When TUI already has an editor, the GUI value must not override it.
452        let gui_loaded = load_from(&dir.path().join("settings.json")).unwrap();
453        let mut merged = tui_raw;
454        if merged.editor_name.is_none() {
455            merged.editor_name = gui_loaded.editor_name;
456        }
457        assert_eq!(merged.editor_name.as_deref(), Some("Neovim"));
458    }
459
460    #[test]
461    fn load_tui_settings_returns_default_when_no_file() {
462        // Can't easily control the real config dir in unit tests, but we can
463        // verify load_from works correctly with a nonexistent path.
464        let tmp = std::path::Path::new("/nonexistent/path/that/does/not/exist.json");
465        let result = load_from(tmp).unwrap();
466        assert_eq!(result.theme_name, None);
467        assert!(result.recent_repos.is_empty());
468    }
469}