Skip to main content

gitkraft_core/features/persistence/
ops.rs

1//! Persistence operations — load, save, and query application settings
2//! backed by a redb embedded key-value database.
3
4use super::types::{AppSettings, RepoHistoryEntry};
5use anyhow::{Context, Result};
6use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
7use std::path::{Path, PathBuf};
8
9const SETTINGS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("settings");
10const RECENT_REPOS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("recent_repos");
11
12/// Get the settings directory (~/.config/gitkraft/ or platform equivalent).
13pub fn settings_dir() -> Result<PathBuf> {
14    let base = dirs::config_dir().context("could not determine config directory")?;
15    Ok(base.join("gitkraft"))
16}
17
18/// Full path to the database file.
19fn db_path() -> Result<PathBuf> {
20    Ok(settings_dir()?.join("gitkraft.redb"))
21}
22
23/// Open or create the database.
24/// If the file exists but cannot be opened (e.g. stale format from an older
25/// redb version), the file is deleted and a fresh database is created.
26fn open_db() -> Result<Database> {
27    let dir = settings_dir()?;
28    std::fs::create_dir_all(&dir)
29        .with_context(|| format!("failed to create settings directory {}", dir.display()))?;
30    let path = db_path()?;
31    match Database::create(&path) {
32        Ok(db) => Ok(db),
33        Err(e) => {
34            tracing::warn!(
35                "Could not open database ({e}); removing stale file and creating fresh one."
36            );
37            let _ = std::fs::remove_file(&path);
38            Database::create(&path)
39                .with_context(|| format!("failed to open database at {}", path.display()))
40        }
41    }
42}
43
44/// Load settings from the database. Returns default settings if the database
45/// doesn't exist or any table is missing.
46pub fn load_settings() -> Result<AppSettings> {
47    let db = match open_db() {
48        Ok(db) => db,
49        Err(_) => return Ok(AppSettings::default()),
50    };
51
52    let read_txn = db.begin_read()?;
53    let mut settings = AppSettings::default();
54
55    // Read scalar settings
56    if let Ok(table) = read_txn.open_table(SETTINGS_TABLE) {
57        if let Ok(Some(val)) = table.get("last_repo") {
58            settings.last_repo = Some(PathBuf::from(val.value()));
59        }
60        if let Ok(Some(val)) = table.get("theme_name") {
61            settings.theme_name = Some(val.value().to_string());
62        }
63        if let Ok(Some(val)) = table.get("layout") {
64            if let Ok(layout) = serde_json::from_str::<super::types::LayoutSettings>(val.value()) {
65                settings.layout = Some(layout);
66            }
67        }
68        if let Ok(Some(val)) = table.get("open_tabs") {
69            if let Ok(tabs) = serde_json::from_str::<Vec<PathBuf>>(val.value()) {
70                settings.open_tabs = tabs;
71            }
72        }
73        if let Ok(Some(val)) = table.get("active_tab_index") {
74            if let Ok(idx) = val.value().parse::<usize>() {
75                settings.active_tab_index = idx;
76            }
77        }
78        if let Ok(Some(val)) = table.get("editor_name") {
79            settings.editor_name = Some(val.value().to_string());
80        }
81    }
82
83    // Read recent repos
84    if let Ok(table) = read_txn.open_table(RECENT_REPOS_TABLE) {
85        let mut entries: Vec<RepoHistoryEntry> = Vec::new();
86        if let Ok(iter) = table.iter() {
87            for (_key, value) in iter.flatten() {
88                if let Ok(entry) = serde_json::from_slice::<RepoHistoryEntry>(value.value()) {
89                    entries.push(entry);
90                }
91            }
92        }
93        // Sort by last_opened descending (most recent first)
94        entries.sort_by_key(|e| std::cmp::Reverse(e.last_opened));
95        settings.recent_repos = entries;
96    }
97
98    Ok(settings)
99}
100
101/// Save settings to the database. Creates the database and tables if needed.
102pub fn save_settings(settings: &AppSettings) -> Result<()> {
103    let db = open_db()?;
104    let write_txn = db.begin_write()?;
105
106    // Write scalar settings
107    {
108        let mut table = write_txn.open_table(SETTINGS_TABLE)?;
109        if let Some(ref path) = settings.last_repo {
110            table.insert("last_repo", path.to_string_lossy().as_ref())?;
111        }
112        if let Some(ref theme) = settings.theme_name {
113            table.insert("theme_name", theme.as_str())?;
114        }
115        if let Some(ref layout) = settings.layout {
116            let layout_json =
117                serde_json::to_string(layout).context("failed to serialize layout settings")?;
118            table.insert("layout", layout_json.as_str())?;
119        }
120        if let Some(ref editor) = settings.editor_name {
121            table.insert("editor_name", editor.as_str())?;
122        }
123        if !settings.open_tabs.is_empty() {
124            let tabs_json = serde_json::to_string(&settings.open_tabs)
125                .context("failed to serialize open_tabs")?;
126            table.insert("open_tabs", tabs_json.as_str())?;
127        } else {
128            let _ = table.remove("open_tabs");
129        }
130        let idx_str = settings.active_tab_index.to_string();
131        table.insert("active_tab_index", idx_str.as_str())?;
132    }
133
134    // Write recent repos — clear existing entries then rewrite
135    {
136        let mut table = write_txn.open_table(RECENT_REPOS_TABLE)?;
137
138        // Collect existing keys so we can remove them
139        let existing_keys: Vec<String> = {
140            let iter = table.iter()?;
141            iter.filter_map(|e| e.ok().map(|(k, _)| k.value().to_string()))
142                .collect()
143        };
144        for key in &existing_keys {
145            table.remove(key.as_str())?;
146        }
147
148        // Insert current entries (path string is the key)
149        for entry in &settings.recent_repos {
150            let key = entry.path.to_string_lossy();
151            let value =
152                serde_json::to_vec(entry).context("failed to serialize repo history entry")?;
153            table.insert(key.as_ref(), value.as_slice())?;
154        }
155    }
156
157    write_txn.commit()?;
158    Ok(())
159}
160
161/// Convenience: record that a repo was opened (updates history + last_repo).
162pub fn record_repo_opened(path: &Path) -> Result<()> {
163    let mut settings = load_settings()?;
164    settings.add_recent_repo(path.to_path_buf());
165    save_settings(&settings)
166}
167
168/// Convenience: get the last opened repo path.
169pub fn get_last_repo() -> Result<Option<PathBuf>> {
170    let settings = load_settings()?;
171    Ok(settings.last_repo)
172}
173
174/// Convenience: save theme preference.
175pub fn save_theme(theme_name: &str) -> Result<()> {
176    let mut settings = load_settings()?;
177    settings.theme_name = Some(theme_name.to_string());
178    save_settings(&settings)
179}
180
181/// Convenience: get saved theme name.
182pub fn get_saved_theme() -> Result<Option<String>> {
183    let settings = load_settings()?;
184    Ok(settings.theme_name)
185}
186
187/// Persist the selected editor name.
188pub fn save_editor(editor_name: &str) -> Result<()> {
189    let mut settings = load_settings()?;
190    settings.editor_name = Some(editor_name.to_string());
191    save_settings(&settings)
192}
193
194/// Retrieve the persisted editor name.
195pub fn get_saved_editor() -> Result<Option<String>> {
196    let settings = load_settings()?;
197    Ok(settings.editor_name)
198}
199
200/// Convenience: save layout preferences.
201pub fn save_layout(layout: &super::types::LayoutSettings) -> Result<()> {
202    let mut settings = load_settings()?;
203    settings.layout = Some(layout.clone());
204    save_settings(&settings)
205}
206
207/// Convenience: get saved layout preferences.
208pub fn get_saved_layout() -> Result<Option<super::types::LayoutSettings>> {
209    let settings = load_settings()?;
210    Ok(settings.layout)
211}
212
213/// Record that a repo was opened AND update the session in one DB write.
214/// Returns the updated recent-repos list.
215pub fn record_repo_and_save_session(
216    path: &Path,
217    open_tabs: &[PathBuf],
218    active_tab_index: usize,
219) -> Result<Vec<RepoHistoryEntry>> {
220    let mut settings = load_settings()?;
221    settings.add_recent_repo(path.to_path_buf());
222    settings.open_tabs = open_tabs.to_vec();
223    settings.active_tab_index = active_tab_index;
224    save_settings(&settings)?;
225    Ok(settings.recent_repos)
226}
227
228/// Persist the open-tab session without touching the recent-repos list.
229pub fn save_session(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
230    let mut settings = load_settings()?;
231    settings.open_tabs = open_tabs.to_vec();
232    settings.active_tab_index = active_tab_index;
233    save_settings(&settings)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn add_recent_deduplicates() {
242        let mut settings = AppSettings::default();
243        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
244        settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
245        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
246        assert_eq!(settings.recent_repos.len(), 2);
247        assert_eq!(settings.recent_repos[0].path, PathBuf::from("/tmp/repo1"));
248    }
249
250    #[test]
251    fn add_recent_respects_max() {
252        let mut settings = AppSettings {
253            max_recent: 3,
254            ..Default::default()
255        };
256        for i in 0..5 {
257            settings.add_recent_repo(PathBuf::from(format!("/tmp/repo{i}")));
258        }
259        assert_eq!(settings.recent_repos.len(), 3);
260    }
261
262    #[test]
263    fn settings_round_trip() {
264        // Test the serde round-trip of RepoHistoryEntry (the encoding used inside redb values)
265        let mut settings = AppSettings::default();
266        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
267        settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
268        settings.theme_name = Some("Dark".to_string());
269
270        let entry = &settings.recent_repos[0];
271        let bytes = serde_json::to_vec(entry).unwrap();
272        let decoded: RepoHistoryEntry = serde_json::from_slice(&bytes).unwrap();
273        assert_eq!(decoded.path, entry.path);
274        assert_eq!(decoded.display_name, entry.display_name);
275    }
276}