gitkraft_core/features/persistence/
ops.rs1use super::types::{AppSettings, RepoHistoryEntry};
14use anyhow::{Context, Result};
15use std::path::{Path, PathBuf};
16
17pub fn settings_dir() -> Result<PathBuf> {
21 let base = dirs::config_dir().context("could not determine config directory")?;
22 Ok(base.join("gitkraft"))
23}
24
25pub fn settings_json_path() -> Result<PathBuf> {
27 Ok(settings_dir()?.join("settings.json"))
28}
29
30pub fn tui_settings_json_path() -> Result<PathBuf> {
32 Ok(settings_dir()?.join("tui-settings.json"))
33}
34
35fn json_path() -> Result<PathBuf> {
37 settings_json_path()
38}
39
40fn tui_json_path() -> Result<PathBuf> {
42 tui_settings_json_path()
43}
44
45fn load_from(path: &std::path::Path) -> Result<AppSettings> {
49 if path.exists() {
50 let content = std::fs::read_to_string(path)
51 .with_context(|| format!("failed to read {}", path.display()))?;
52 return match serde_json::from_str::<AppSettings>(&content) {
53 Ok(s) => Ok(s),
54 Err(e) => {
55 tracing::warn!(
56 "settings file {:?} is malformed ({e}); using defaults",
57 path
58 );
59 Ok(AppSettings::default())
60 }
61 };
62 }
63 Ok(AppSettings::default())
64}
65
66fn save_to(path: &std::path::Path, settings: &AppSettings) -> Result<()> {
68 use std::io::Write as _;
69
70 let parent = path.parent().unwrap_or(std::path::Path::new("."));
71 std::fs::create_dir_all(parent)
72 .with_context(|| format!("failed to create directory {}", parent.display()))?;
73
74 let content = serde_json::to_string_pretty(settings).context("failed to serialise settings")?;
75
76 let mut tmp = tempfile::NamedTempFile::new_in(parent)
80 .context("failed to create temporary settings file")?;
81 tmp.write_all(content.as_bytes())
82 .context("failed to write settings to temporary file")?;
83 tmp.persist(path)
84 .map_err(|e| anyhow::anyhow!("failed to persist settings file: {}", e))?;
85
86 Ok(())
87}
88
89pub fn load_settings() -> Result<AppSettings> {
96 load_from(&json_path()?)
97}
98
99pub fn save_settings(settings: &AppSettings) -> Result<()> {
101 save_to(&json_path()?, settings)
102}
103
104pub fn update_settings<F: FnOnce(&mut AppSettings)>(f: F) -> Result<()> {
108 let mut settings = load_settings()?;
109 f(&mut settings);
110 save_settings(&settings)
111}
112
113pub fn record_repo_opened(path: &Path) -> Result<()> {
115 update_settings(|s| s.add_recent_repo(path.to_path_buf()))
116}
117
118pub fn get_last_repo() -> Result<Option<PathBuf>> {
120 Ok(load_settings()?.last_repo)
121}
122
123pub fn save_theme(theme_name: &str) -> Result<()> {
125 update_settings(|s| s.theme_name = Some(theme_name.to_string()))
126}
127
128pub fn get_saved_theme() -> Result<Option<String>> {
130 Ok(load_settings()?.theme_name)
131}
132
133pub fn save_editor(editor_name: &str) -> Result<()> {
135 update_settings(|s| s.editor_name = Some(editor_name.to_string()))
136}
137
138pub fn get_saved_editor() -> Result<Option<String>> {
140 Ok(load_settings()?.editor_name)
141}
142
143pub fn save_layout(layout: &super::types::LayoutSettings) -> Result<()> {
145 let layout = layout.clone();
146 update_settings(|s| s.layout = Some(layout))
147}
148
149pub fn get_saved_layout() -> Result<Option<super::types::LayoutSettings>> {
151 Ok(load_settings()?.layout)
152}
153
154pub fn record_repo_and_save_session(
157 path: &Path,
158 open_tabs: &[PathBuf],
159 active_tab_index: usize,
160) -> Result<Vec<RepoHistoryEntry>> {
161 let mut settings = load_settings()?;
162 settings.add_recent_repo(path.to_path_buf());
163 settings.open_tabs = open_tabs.to_vec();
164 settings.active_tab_index = active_tab_index;
165 save_settings(&settings)?;
166 Ok(settings.recent_repos)
167}
168
169pub fn save_session(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
171 update_settings(|s| {
172 s.open_tabs = open_tabs.to_vec();
173 s.active_tab_index = active_tab_index;
174 })
175}
176
177pub fn load_tui_settings() -> Result<AppSettings> {
181 let mut settings = load_from(&tui_json_path()?)?;
182 if settings.editor_name.is_none() {
185 if let Ok(gui) = load_from(&json_path()?) {
186 if gui.editor_name.is_some() {
187 settings.editor_name = gui.editor_name;
188 }
189 }
190 }
191 Ok(settings)
192}
193
194pub fn save_tui_settings(settings: &AppSettings) -> Result<()> {
196 save_to(&tui_json_path()?, settings)
197}
198
199pub fn update_tui_settings<F: FnOnce(&mut AppSettings)>(f: F) -> Result<()> {
206 let mut settings = load_from(&tui_json_path()?)?;
207 f(&mut settings);
208 save_tui_settings(&settings)
209}
210
211pub fn record_repo_opened_tui(path: &std::path::Path) -> Result<()> {
213 update_tui_settings(|s| s.add_recent_repo(path.to_path_buf()))
214}
215
216pub fn get_last_tui_repo() -> Result<Option<PathBuf>> {
218 Ok(load_tui_settings()?.last_repo)
219}
220
221pub fn save_theme_tui(theme_name: &str) -> Result<()> {
223 update_tui_settings(|s| s.theme_name = Some(theme_name.to_string()))
224}
225
226pub fn save_editor_tui(editor_name: &str) -> Result<()> {
228 update_tui_settings(|s| s.editor_name = Some(editor_name.to_string()))
229}
230
231pub fn save_session_tui(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
233 update_tui_settings(|s| {
234 s.open_tabs = open_tabs.to_vec();
235 s.active_tab_index = active_tab_index;
236 })
237}
238
239#[cfg(test)]
242mod tests {
243 use super::*;
244 use tempfile::TempDir;
245
246 fn write_json(dir: &TempDir, settings: &AppSettings) {
251 let path = dir.path().join("settings.json");
252 save_to(&path, settings).unwrap();
253 }
254
255 fn read_json(dir: &TempDir) -> AppSettings {
256 let path = dir.path().join("settings.json");
257 let content = std::fs::read_to_string(&path).unwrap();
258 serde_json::from_str(&content).unwrap()
259 }
260
261 #[test]
264 fn settings_json_round_trip() {
265 let dir = TempDir::new().unwrap();
266 let mut s = AppSettings {
267 theme_name: Some("Dracula".to_string()),
268 editor_name: Some("code".to_string()),
269 ..Default::default()
270 };
271 s.add_recent_repo(PathBuf::from("/tmp/repo-a"));
272 s.add_recent_repo(PathBuf::from("/tmp/repo-b"));
273
274 write_json(&dir, &s);
275 let loaded = read_json(&dir);
276
277 assert_eq!(loaded.theme_name, Some("Dracula".to_string()));
278 assert_eq!(loaded.editor_name, Some("code".to_string()));
279 assert_eq!(loaded.recent_repos.len(), 2);
280 assert_eq!(loaded.recent_repos[0].path, PathBuf::from("/tmp/repo-b"));
281 assert_eq!(loaded.recent_repos[1].path, PathBuf::from("/tmp/repo-a"));
282 }
283
284 #[test]
285 fn settings_json_preserves_open_tabs_and_active_index() {
286 let dir = TempDir::new().unwrap();
287 let s = AppSettings {
288 open_tabs: vec![PathBuf::from("/tmp/repo-1"), PathBuf::from("/tmp/repo-2")],
289 active_tab_index: 1,
290 ..Default::default()
291 };
292
293 write_json(&dir, &s);
294 let loaded = read_json(&dir);
295
296 assert_eq!(loaded.open_tabs.len(), 2);
297 assert_eq!(loaded.active_tab_index, 1);
298 }
299
300 #[test]
301 fn settings_json_preserves_layout() {
302 let dir = TempDir::new().unwrap();
303 let s = AppSettings {
304 layout: Some(super::super::types::LayoutSettings {
305 sidebar_width: Some(220.0),
306 commit_log_width: Some(400.0),
307 staging_height: Some(150.0),
308 diff_file_list_width: Some(180.0),
309 sidebar_expanded: Some(true),
310 ui_scale: Some(1.25),
311 ..Default::default()
312 }),
313 ..Default::default()
314 };
315
316 write_json(&dir, &s);
317 let loaded = read_json(&dir);
318 let layout = loaded.layout.unwrap();
319
320 assert!((layout.sidebar_width.unwrap() - 220.0).abs() < f32::EPSILON);
321 assert_eq!(layout.sidebar_expanded, Some(true));
322 assert!((layout.ui_scale.unwrap() - 1.25).abs() < f32::EPSILON);
323 }
324
325 #[test]
326 fn malformed_json_deserialises_to_defaults() {
327 let dir = TempDir::new().unwrap();
328 let path = dir.path().join("settings.json");
329 std::fs::write(&path, b"{ this is not valid json !!!").unwrap();
331
332 let result = serde_json::from_str::<AppSettings>(&std::fs::read_to_string(&path).unwrap());
334 assert!(
335 result.is_err(),
336 "malformed JSON must not parse successfully"
337 );
338 assert!(path.exists(), "malformed file must be preserved");
340 }
341
342 #[test]
343 fn atomic_write_produces_no_tmp_file_on_success() {
344 let dir = TempDir::new().unwrap();
345 let s = AppSettings::default();
346 write_json(&dir, &s);
347
348 let tmp = dir.path().join("settings.json.tmp");
349 assert!(
350 !tmp.exists(),
351 "tmp file must be removed after a successful atomic write"
352 );
353 assert!(dir.path().join("settings.json").exists());
354 }
355
356 #[test]
357 fn serde_default_missing_fields_load_cleanly() {
358 let dir = TempDir::new().unwrap();
361 let minimal = r#"{"last_repo": null, "recent_repos": [], "theme_name": "Nord"}"#;
362 std::fs::write(dir.path().join("settings.json"), minimal).unwrap();
363
364 let loaded = read_json(&dir);
365 assert_eq!(loaded.theme_name, Some("Nord".to_string()));
366 assert_eq!(loaded.max_recent, 20); assert_eq!(loaded.active_tab_index, 0); assert!(loaded.open_tabs.is_empty()); }
370
371 #[test]
374 fn add_recent_deduplicates() {
375 let mut settings = AppSettings::default();
376 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
377 settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
378 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
379 assert_eq!(settings.recent_repos.len(), 2);
380 assert_eq!(settings.recent_repos[0].path, PathBuf::from("/tmp/repo1"));
381 }
382
383 #[test]
384 fn add_recent_respects_max() {
385 let mut settings = AppSettings {
386 max_recent: 3,
387 ..Default::default()
388 };
389 for i in 0..5 {
390 settings.add_recent_repo(PathBuf::from(format!("/tmp/repo{i}")));
391 }
392 assert_eq!(settings.recent_repos.len(), 3);
393 }
394
395 #[test]
396 fn settings_round_trip_via_json_bytes() {
397 let mut settings = AppSettings::default();
398 settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
399 settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
400 settings.theme_name = Some("Dark".to_string());
401
402 let json = serde_json::to_string(&settings).unwrap();
403 let decoded: AppSettings = serde_json::from_str(&json).unwrap();
404 assert_eq!(decoded.recent_repos.len(), 2);
405 assert_eq!(decoded.theme_name, Some("Dark".to_string()));
406 }
407
408 #[test]
411 fn tui_and_gui_settings_are_independent() {
412 let gui = json_path().unwrap();
414 let tui = tui_json_path().unwrap();
415 assert_ne!(gui, tui);
416 assert!(gui.to_str().unwrap().ends_with("settings.json"));
417 assert!(tui.to_str().unwrap().ends_with("tui-settings.json"));
418 }
419
420 #[test]
421 fn load_tui_inherits_editor_from_gui_when_tui_has_none() {
422 let dir = TempDir::new().unwrap();
423
424 let gui = AppSettings {
426 editor_name: Some("Helix".to_string()),
427 ..Default::default()
428 };
429 write_json(&dir, &gui);
430
431 let tui_path = dir.path().join("tui-settings.json");
433 let tui_content = r#"{"last_repo":null,"recent_repos":[],"theme_name":null}"#;
434 std::fs::write(&tui_path, tui_content).unwrap();
435
436 let tui_raw = load_from(&tui_path).unwrap();
438 assert!(tui_raw.editor_name.is_none());
439
440 let gui_loaded = load_from(&dir.path().join("settings.json")).unwrap();
443 let mut merged = tui_raw;
444 if merged.editor_name.is_none() {
445 merged.editor_name = gui_loaded.editor_name;
446 }
447 assert_eq!(merged.editor_name.as_deref(), Some("Helix"));
448 }
449
450 #[test]
451 fn load_tui_keeps_own_editor_when_configured() {
452 let dir = TempDir::new().unwrap();
453
454 let gui = AppSettings {
456 editor_name: Some("VS Code".to_string()),
457 ..Default::default()
458 };
459 write_json(&dir, &gui);
460
461 let tui_path = dir.path().join("tui-settings.json");
462 let tui_content = r#"{"last_repo":null,"recent_repos":[],"editor_name":"Neovim"}"#;
463 std::fs::write(&tui_path, tui_content).unwrap();
464
465 let tui_raw = load_from(&tui_path).unwrap();
466 assert_eq!(tui_raw.editor_name.as_deref(), Some("Neovim"));
467
468 let gui_loaded = load_from(&dir.path().join("settings.json")).unwrap();
470 let mut merged = tui_raw;
471 if merged.editor_name.is_none() {
472 merged.editor_name = gui_loaded.editor_name;
473 }
474 assert_eq!(merged.editor_name.as_deref(), Some("Neovim"));
475 }
476
477 #[test]
478 fn load_tui_settings_returns_default_when_no_file() {
479 let tmp = std::path::Path::new("/nonexistent/path/that/does/not/exist.json");
482 let result = load_from(tmp).unwrap();
483 assert_eq!(result.theme_name, None);
484 assert!(result.recent_repos.is_empty());
485 }
486
487 #[test]
490 fn save_to_leaves_no_temp_files() {
491 let dir = TempDir::new().unwrap();
494 let path = dir.path().join("settings.json");
495 save_to(&path, &AppSettings::default()).unwrap();
496
497 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
498 .unwrap()
499 .filter_map(|e| e.ok())
500 .filter(|e| {
501 e.path()
502 .extension()
503 .and_then(|x| x.to_str())
504 .map(|ext| ext.contains("tmp"))
505 .unwrap_or(false)
506 })
507 .collect();
508
509 assert!(
510 leftovers.is_empty(),
511 "save_to must not leave any temp files: {leftovers:?}"
512 );
513 assert!(path.exists(), "settings file must exist after save_to");
514 }
515
516 #[test]
517 fn save_to_creates_parent_directories_automatically() {
518 let dir = TempDir::new().unwrap();
519 let nested = dir.path().join("a").join("b").join("settings.json");
520 save_to(&nested, &AppSettings::default()).unwrap();
522 assert!(nested.exists());
523 }
524
525 #[test]
526 fn save_to_and_load_from_round_trip() {
527 let dir = TempDir::new().unwrap();
528 let path = dir.path().join("settings.json");
529
530 let mut s = AppSettings {
531 theme_name: Some("Nord".to_string()),
532 editor_name: Some("Neovim".to_string()),
533 ..Default::default()
534 };
535 s.add_recent_repo(PathBuf::from("/tmp/my-repo"));
536
537 save_to(&path, &s).unwrap();
538 let loaded = load_from(&path).unwrap();
539
540 assert_eq!(loaded.theme_name, Some("Nord".to_string()));
541 assert_eq!(loaded.editor_name, Some("Neovim".to_string()));
542 assert_eq!(loaded.recent_repos.len(), 1);
543 assert_eq!(loaded.recent_repos[0].path, PathBuf::from("/tmp/my-repo"));
544 }
545
546 #[test]
552 fn update_via_save_to_load_from_preserves_other_fields() {
553 let dir = TempDir::new().unwrap();
554 let path = dir.path().join("settings.json");
555
556 let mut initial = AppSettings {
558 editor_name: Some("Vim".to_string()),
559 ..Default::default()
560 };
561 initial.add_recent_repo(PathBuf::from("/tmp/repo1"));
562 save_to(&path, &initial).unwrap();
563
564 let mut s = load_from(&path).unwrap();
566 s.theme_name = Some("Dracula".to_string());
567 save_to(&path, &s).unwrap();
568
569 let loaded = load_from(&path).unwrap();
570
571 assert_eq!(loaded.theme_name, Some("Dracula".to_string()));
573 assert_eq!(loaded.editor_name, Some("Vim".to_string()));
575 assert_eq!(loaded.recent_repos.len(), 1);
576 }
577
578 #[test]
579 fn update_via_save_to_multiple_mutations_in_one_write() {
580 let dir = TempDir::new().unwrap();
581 let path = dir.path().join("settings.json");
582 save_to(&path, &AppSettings::default()).unwrap();
583
584 let mut s = load_from(&path).unwrap();
586 s.theme_name = Some("Monokai".to_string());
587 s.editor_name = Some("Helix".to_string());
588 s.active_tab_index = 2;
589 save_to(&path, &s).unwrap();
590
591 let loaded = load_from(&path).unwrap();
592 assert_eq!(loaded.theme_name, Some("Monokai".to_string()));
593 assert_eq!(loaded.editor_name, Some("Helix".to_string()));
594 assert_eq!(loaded.active_tab_index, 2);
595 }
596
597 #[test]
603 fn update_tui_settings_does_not_inherit_gui_editor() {
604 let dir = TempDir::new().unwrap();
605 let gui_path = dir.path().join("settings.json");
606 let tui_path = dir.path().join("tui-settings.json");
607
608 let gui = AppSettings {
610 editor_name: Some("VS Code".to_string()),
611 ..Default::default()
612 };
613 save_to(&gui_path, &gui).unwrap();
614 save_to(&tui_path, &AppSettings::default()).unwrap();
615
616 let mut s = load_from(&tui_path).unwrap();
618 s.theme_name = Some("Nord".to_string());
619 save_to(&tui_path, &s).unwrap();
620
621 let tui_loaded = load_from(&tui_path).unwrap();
623 assert_eq!(tui_loaded.theme_name, Some("Nord".to_string()));
624 assert!(
625 tui_loaded.editor_name.is_none(),
626 "update_tui_settings must not inherit the GUI editor into the TUI file"
627 );
628 }
629}