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    }
79
80    // Read recent repos
81    if let Ok(table) = read_txn.open_table(RECENT_REPOS_TABLE) {
82        let mut entries: Vec<RepoHistoryEntry> = Vec::new();
83        if let Ok(iter) = table.iter() {
84            for (_key, value) in iter.flatten() {
85                if let Ok(entry) = serde_json::from_slice::<RepoHistoryEntry>(value.value()) {
86                    entries.push(entry);
87                }
88            }
89        }
90        // Sort by last_opened descending (most recent first)
91        entries.sort_by_key(|e| std::cmp::Reverse(e.last_opened));
92        settings.recent_repos = entries;
93    }
94
95    Ok(settings)
96}
97
98/// Save settings to the database. Creates the database and tables if needed.
99pub fn save_settings(settings: &AppSettings) -> Result<()> {
100    let db = open_db()?;
101    let write_txn = db.begin_write()?;
102
103    // Write scalar settings
104    {
105        let mut table = write_txn.open_table(SETTINGS_TABLE)?;
106        if let Some(ref path) = settings.last_repo {
107            table.insert("last_repo", path.to_string_lossy().as_ref())?;
108        }
109        if let Some(ref theme) = settings.theme_name {
110            table.insert("theme_name", theme.as_str())?;
111        }
112        if let Some(ref layout) = settings.layout {
113            let layout_json =
114                serde_json::to_string(layout).context("failed to serialize layout settings")?;
115            table.insert("layout", layout_json.as_str())?;
116        }
117        if !settings.open_tabs.is_empty() {
118            let tabs_json = serde_json::to_string(&settings.open_tabs)
119                .context("failed to serialize open_tabs")?;
120            table.insert("open_tabs", tabs_json.as_str())?;
121        } else {
122            let _ = table.remove("open_tabs");
123        }
124        let idx_str = settings.active_tab_index.to_string();
125        table.insert("active_tab_index", idx_str.as_str())?;
126    }
127
128    // Write recent repos — clear existing entries then rewrite
129    {
130        let mut table = write_txn.open_table(RECENT_REPOS_TABLE)?;
131
132        // Collect existing keys so we can remove them
133        let existing_keys: Vec<String> = {
134            let iter = table.iter()?;
135            iter.filter_map(|e| e.ok().map(|(k, _)| k.value().to_string()))
136                .collect()
137        };
138        for key in &existing_keys {
139            table.remove(key.as_str())?;
140        }
141
142        // Insert current entries (path string is the key)
143        for entry in &settings.recent_repos {
144            let key = entry.path.to_string_lossy();
145            let value =
146                serde_json::to_vec(entry).context("failed to serialize repo history entry")?;
147            table.insert(key.as_ref(), value.as_slice())?;
148        }
149    }
150
151    write_txn.commit()?;
152    Ok(())
153}
154
155/// Convenience: record that a repo was opened (updates history + last_repo).
156pub fn record_repo_opened(path: &Path) -> Result<()> {
157    let mut settings = load_settings()?;
158    settings.add_recent_repo(path.to_path_buf());
159    save_settings(&settings)
160}
161
162/// Convenience: get the last opened repo path.
163pub fn get_last_repo() -> Result<Option<PathBuf>> {
164    let settings = load_settings()?;
165    Ok(settings.last_repo)
166}
167
168/// Convenience: save theme preference.
169pub fn save_theme(theme_name: &str) -> Result<()> {
170    let mut settings = load_settings()?;
171    settings.theme_name = Some(theme_name.to_string());
172    save_settings(&settings)
173}
174
175/// Convenience: get saved theme name.
176pub fn get_saved_theme() -> Result<Option<String>> {
177    let settings = load_settings()?;
178    Ok(settings.theme_name)
179}
180
181/// Convenience: save layout preferences.
182pub fn save_layout(layout: &super::types::LayoutSettings) -> Result<()> {
183    let mut settings = load_settings()?;
184    settings.layout = Some(layout.clone());
185    save_settings(&settings)
186}
187
188/// Convenience: get saved layout preferences.
189pub fn get_saved_layout() -> Result<Option<super::types::LayoutSettings>> {
190    let settings = load_settings()?;
191    Ok(settings.layout)
192}
193
194/// Record that a repo was opened AND update the session in one DB write.
195/// Returns the updated recent-repos list.
196pub fn record_repo_and_save_session(
197    path: &Path,
198    open_tabs: &[PathBuf],
199    active_tab_index: usize,
200) -> Result<Vec<RepoHistoryEntry>> {
201    let mut settings = load_settings()?;
202    settings.add_recent_repo(path.to_path_buf());
203    settings.open_tabs = open_tabs.to_vec();
204    settings.active_tab_index = active_tab_index;
205    save_settings(&settings)?;
206    Ok(settings.recent_repos)
207}
208
209/// Persist the open-tab session without touching the recent-repos list.
210pub fn save_session(open_tabs: &[PathBuf], active_tab_index: usize) -> Result<()> {
211    let mut settings = load_settings()?;
212    settings.open_tabs = open_tabs.to_vec();
213    settings.active_tab_index = active_tab_index;
214    save_settings(&settings)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn add_recent_deduplicates() {
223        let mut settings = AppSettings::default();
224        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
225        settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
226        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
227        assert_eq!(settings.recent_repos.len(), 2);
228        assert_eq!(settings.recent_repos[0].path, PathBuf::from("/tmp/repo1"));
229    }
230
231    #[test]
232    fn add_recent_respects_max() {
233        let mut settings = AppSettings {
234            max_recent: 3,
235            ..Default::default()
236        };
237        for i in 0..5 {
238            settings.add_recent_repo(PathBuf::from(format!("/tmp/repo{i}")));
239        }
240        assert_eq!(settings.recent_repos.len(), 3);
241    }
242
243    #[test]
244    fn settings_round_trip() {
245        // Test the serde round-trip of RepoHistoryEntry (the encoding used inside redb values)
246        let mut settings = AppSettings::default();
247        settings.add_recent_repo(PathBuf::from("/tmp/repo1"));
248        settings.add_recent_repo(PathBuf::from("/tmp/repo2"));
249        settings.theme_name = Some("Dark".to_string());
250
251        let entry = &settings.recent_repos[0];
252        let bytes = serde_json::to_vec(entry).unwrap();
253        let decoded: RepoHistoryEntry = serde_json::from_slice(&bytes).unwrap();
254        assert_eq!(decoded.path, entry.path);
255        assert_eq!(decoded.display_name, entry.display_name);
256    }
257}