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
46pub 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 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 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 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}