Skip to main content

gitgraph_core/
state.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use directories::ProjectDirs;
5use serde::{Deserialize, Serialize};
6
7use crate::actions::ActionCatalog;
8use crate::error::{GitLgError, Result};
9use crate::models::GraphQuery;
10
11const DEFAULT_STATE_FILENAME: &str = "state.json";
12const CURRENT_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct AppState {
16    pub schema_version: u32,
17    pub selected_repo_path: Option<PathBuf>,
18    pub preferred_git_binary: Option<String>,
19    pub default_remote_name: String,
20    pub graph_query: GraphQuery,
21    pub selected_commit_hashes: Vec<String>,
22    pub actions: ActionCatalog,
23}
24
25impl Default for AppState {
26    fn default() -> Self {
27        Self {
28            schema_version: CURRENT_SCHEMA_VERSION,
29            selected_repo_path: None,
30            preferred_git_binary: None,
31            default_remote_name: "origin".to_string(),
32            graph_query: GraphQuery::default(),
33            selected_commit_hashes: Vec::new(),
34            actions: ActionCatalog::with_defaults(),
35        }
36    }
37}
38
39#[derive(Debug, Clone)]
40pub struct StateStore {
41    path: PathBuf,
42}
43
44impl StateStore {
45    pub fn at(path: PathBuf) -> Self {
46        Self { path }
47    }
48
49    pub fn default_location() -> Result<PathBuf> {
50        let project_dirs = ProjectDirs::from("dev", "GitGraph", "gitgraph")
51            .ok_or_else(|| GitLgError::State("cannot resolve project directories".to_string()))?;
52        Ok(project_dirs.config_dir().join(DEFAULT_STATE_FILENAME))
53    }
54
55    pub fn default_store() -> Result<Self> {
56        Ok(Self {
57            path: Self::default_location()?,
58        })
59    }
60
61    pub fn path(&self) -> &Path {
62        &self.path
63    }
64
65    pub fn load(&self) -> Result<AppState> {
66        if !self.path.exists() {
67            return Ok(AppState::default());
68        }
69        let text = fs::read_to_string(&self.path)
70            .map_err(|source| GitLgError::io("reading state file", source))?;
71        let mut state: AppState = serde_json::from_str(&text)
72            .map_err(|e| GitLgError::State(format!("invalid state json: {}", e)))?;
73        if state.schema_version == 0 {
74            state.schema_version = CURRENT_SCHEMA_VERSION;
75        }
76        Ok(state)
77    }
78
79    pub fn save(&self, state: &AppState) -> Result<()> {
80        if let Some(parent) = self.path.parent() {
81            fs::create_dir_all(parent)
82                .map_err(|source| GitLgError::io("creating state directory", source))?;
83        }
84        let text = serde_json::to_string_pretty(state)
85            .map_err(|e| GitLgError::State(format!("serialize state failed: {}", e)))?;
86        fs::write(&self.path, text).map_err(|source| GitLgError::io("writing state file", source))
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use tempfile::TempDir;
93
94    use super::{AppState, StateStore};
95
96    #[test]
97    fn roundtrip_state_file() {
98        let tmp = TempDir::new().expect("tempdir");
99        let store = StateStore::at(tmp.path().join("state.json"));
100
101        let mut state = AppState::default();
102        state.selected_repo_path = Some(tmp.path().to_path_buf());
103        state.default_remote_name = "upstream".to_string();
104
105        store.save(&state).expect("save state");
106        let loaded = store.load().expect("load state");
107        assert_eq!(loaded.default_remote_name, "upstream");
108        assert_eq!(loaded.selected_repo_path, state.selected_repo_path);
109    }
110}