gitkraft_core/features/persistence/
ops.rs1use super::types::{AppSettings, RepoHistoryEntry};
13use anyhow::{Context, Result};
14use std::path::{Path, PathBuf};
15
16pub fn settings_dir() -> Result<PathBuf> {
20 let base = dirs::config_dir().context("could not determine config directory")?;
21 Ok(base.join("gitkraft"))
22}
23
24pub fn settings_json_path() -> Result<PathBuf> {
26 Ok(settings_dir()?.join("settings.json"))
27}
28
29pub fn tui_settings_json_path() -> Result<PathBuf> {
31 Ok(settings_dir()?.join("tui-settings.json"))
32}
33
34fn json_path() -> Result<PathBuf> {
36 settings_json_path()
37}
38
39fn tui_json_path() -> Result<PathBuf> {
41 tui_settings_json_path()
42}
43
44fn 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
65fn 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
79pub fn load_settings() -> Result<AppSettings> {
86 load_from(&json_path()?)
87}
88
89pub fn save_settings(settings: &AppSettings) -> Result<()> {
91 save_to(&json_path()?, settings)
92}
93
94pub 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
101pub fn get_last_repo() -> Result<Option<PathBuf>> {
103 Ok(load_settings()?.last_repo)
104}
105
106pub 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
113pub fn get_saved_theme() -> Result<Option<String>> {
115 Ok(load_settings()?.theme_name)
116}
117
118pub 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
125pub fn get_saved_editor() -> Result<Option<String>> {
127 Ok(load_settings()?.editor_name)
128}
129
130pub 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
137pub fn get_saved_layout() -> Result<Option<super::types::LayoutSettings>> {
139 Ok(load_settings()?.layout)
140}
141
142pub 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
157pub 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
165pub fn load_tui_settings() -> Result<AppSettings> {
169 let mut settings = load_from(&tui_json_path()?)?;
170 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
182pub fn save_tui_settings(settings: &AppSettings) -> Result<()> {
184 save_to(&tui_json_path()?, settings)
185}
186
187pub 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
194pub fn get_last_tui_repo() -> Result<Option<PathBuf>> {
196 Ok(load_tui_settings()?.last_repo)
197}
198
199pub 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
206pub 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
213pub 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#[cfg(test)]
224mod tests {
225 use super::*;
226 use tempfile::TempDir;
227
228 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 #[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 std::fs::write(&path, b"{ this is not valid json !!!").unwrap();
314
315 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 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 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); assert_eq!(loaded.active_tab_index, 0); assert!(loaded.open_tabs.is_empty()); }
353
354 #[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 #[test]
394 fn tui_and_gui_settings_are_independent() {
395 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 let gui = AppSettings {
409 editor_name: Some("Helix".to_string()),
410 ..Default::default()
411 };
412 write_json(&dir, &gui);
413
414 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 let tui_raw = load_from(&tui_path).unwrap();
421 assert!(tui_raw.editor_name.is_none());
422
423 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 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 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 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}