gitmoji_rs/
history.rs

1use std::collections::hash_map::DefaultHasher;
2use std::fs;
3use std::hash::{Hash, Hasher};
4use std::path::PathBuf;
5
6use dialoguer::{BasicHistory, History};
7use serde::{Deserialize, Serialize};
8use tracing::warn;
9
10use crate::git::get_git_dir_sync;
11
12const HISTORY_DIR: &str = "history";
13const MAX_HISTORY_ENTRIES: usize = 20;
14
15#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
16#[non_exhaustive]
17pub enum HistoryError {
18    #[display("Cannot find base directories")]
19    #[from(ignore)]
20    NoBaseDirectories,
21
22    #[display("Cannot get git directory: {_0}")]
23    GitDir(crate::git::GitCommandError),
24
25    #[display("Cannot read history: {_0}")]
26    Read(std::io::Error),
27
28    #[display("Cannot write history: {_0}")]
29    #[from(ignore)]
30    Write(std::io::Error),
31
32    #[display("Cannot serialize history: {_0}")]
33    Serialize(toml_edit::ser::Error),
34
35    #[display("Cannot deserialize history: {_0}")]
36    Deserialize(toml_edit::de::Error),
37}
38
39type Result<T> = std::result::Result<T, HistoryError>;
40
41#[derive(Debug, Serialize, Deserialize, Default)]
42struct HistoryData {
43    entries: Vec<String>,
44}
45
46/// Scope history with persistence
47pub struct ScopeHistory {
48    inner: BasicHistory,
49    file_path: PathBuf,
50}
51
52impl Default for ScopeHistory {
53    fn default() -> Self {
54        Self {
55            inner: BasicHistory::new()
56                .max_entries(MAX_HISTORY_ENTRIES)
57                .no_duplicates(true),
58            file_path: PathBuf::new(),
59        }
60    }
61}
62
63impl ScopeHistory {
64    /// Load history for the current repository
65    ///
66    /// # Errors
67    /// Returns an error if:
68    /// - Base directories cannot be determined
69    /// - Git directory cannot be found (not in a git repository)
70    /// - History file exists but cannot be read or parsed
71    pub fn load() -> Result<Self> {
72        let file_path = Self::history_file_path()?;
73        let mut inner = BasicHistory::new()
74            .max_entries(MAX_HISTORY_ENTRIES)
75            .no_duplicates(true);
76
77        if file_path.exists() {
78            let content = fs::read_to_string(&file_path)?;
79            let data: HistoryData = toml_edit::de::from_str(&content)?;
80            // Load in reverse so most recent is first
81            for entry in data.entries.into_iter().rev() {
82                inner.write(&entry);
83            }
84        }
85
86        Ok(Self { inner, file_path })
87    }
88
89    fn save(&self) -> Result<()> {
90        if let Some(dir) = self.file_path.parent() {
91            fs::create_dir_all(dir).map_err(HistoryError::Write)?;
92        }
93        // Collect entries from history
94        let mut entries = Vec::new();
95        let mut pos = 0;
96        while let Some(entry) = <BasicHistory as History<String>>::read(&self.inner, pos) {
97            entries.push(entry);
98            pos += 1;
99        }
100        let data = HistoryData { entries };
101        let content = toml_edit::ser::to_string(&data)?;
102        fs::write(&self.file_path, content).map_err(HistoryError::Write)?;
103        Ok(())
104    }
105
106    fn history_file_path() -> Result<PathBuf> {
107        let base_dirs = directories::BaseDirs::new().ok_or(HistoryError::NoBaseDirectories)?;
108        let repo_hash = Self::get_repo_hash()?;
109        let path = base_dirs
110            .cache_dir()
111            .join("gitmoji-rs")
112            .join(HISTORY_DIR)
113            .join(format!("{repo_hash}.toml"));
114        Ok(path)
115    }
116
117    fn get_repo_hash() -> Result<String> {
118        let git_dir = get_git_dir_sync()?;
119        let mut hasher = DefaultHasher::new();
120        git_dir.hash(&mut hasher);
121        Ok(format!("{:x}", hasher.finish()))
122    }
123}
124
125impl History<String> for ScopeHistory {
126    fn read(&self, pos: usize) -> Option<String> {
127        <BasicHistory as History<String>>::read(&self.inner, pos)
128    }
129
130    fn write(&mut self, val: &String) {
131        <BasicHistory as History<String>>::write(&mut self.inner, val);
132        if let Err(err) = self.save() {
133            warn!("Failed to save scope history: {err}");
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn should_serialize_and_deserialize_history_data() {
144        let data = HistoryData {
145            entries: vec!["api".to_string(), "cli".to_string(), "tests".to_string()],
146        };
147
148        let toml = toml_edit::ser::to_string(&data).expect("serialize");
149        let result: HistoryData = toml_edit::de::from_str(&toml).expect("deserialize");
150
151        assert_eq!(result.entries, data.entries);
152    }
153}