par_term/
command_history.rs1use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::fs;
9use std::path::PathBuf;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct CommandHistoryEntry {
15 pub command: String,
17 pub timestamp_ms: u64,
19 pub exit_code: Option<i32>,
21 pub duration_ms: Option<u64>,
23}
24
25#[derive(Debug)]
27pub struct CommandHistory {
28 entries: VecDeque<CommandHistoryEntry>,
29 max_entries: usize,
30 path: PathBuf,
31 dirty: bool,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
36struct CommandHistoryFile {
37 commands: Vec<CommandHistoryEntry>,
38}
39
40impl CommandHistory {
41 pub fn new(max_entries: usize) -> Self {
43 Self {
44 entries: VecDeque::new(),
45 max_entries,
46 path: Self::default_path(),
47 dirty: false,
48 }
49 }
50
51 fn default_path() -> PathBuf {
53 dirs::config_dir()
54 .unwrap_or_else(|| PathBuf::from("."))
55 .join("par-term")
56 .join("command_history.yaml")
57 }
58
59 pub fn load(&mut self) {
61 if !self.path.exists() {
62 return;
63 }
64 match fs::read_to_string(&self.path) {
65 Ok(contents) => match serde_yaml::from_str::<CommandHistoryFile>(&contents) {
66 Ok(file) => {
67 self.entries = file.commands.into();
69 self.truncate();
70 log::info!("Loaded {} command history entries", self.entries.len());
71 }
72 Err(e) => {
73 log::error!("Failed to parse command history: {}", e);
74 }
75 },
76 Err(e) => {
77 log::error!("Failed to read command history file: {}", e);
78 }
79 }
80 }
81
82 pub fn save(&mut self) {
84 if !self.dirty {
85 return;
86 }
87 let file = CommandHistoryFile {
88 commands: self.entries.iter().cloned().collect(),
89 };
90 if let Some(parent) = self.path.parent()
91 && let Err(e) = fs::create_dir_all(parent)
92 {
93 log::error!("Failed to create command history directory: {}", e);
94 return;
95 }
96 match serde_yaml::to_string(&file) {
97 Ok(yaml) => {
98 if let Err(e) = fs::write(&self.path, yaml) {
99 log::error!("Failed to write command history: {}", e);
100 } else {
101 self.dirty = false;
102 log::debug!("Saved {} command history entries", self.entries.len());
103 }
104 }
105 Err(e) => {
106 log::error!("Failed to serialize command history: {}", e);
107 }
108 }
109 }
110
111 pub fn save_background(&mut self) {
114 if !self.dirty {
115 return;
116 }
117 let file = CommandHistoryFile {
118 commands: self.entries.iter().cloned().collect(),
119 };
120 self.dirty = false;
121 let path = self.path.clone();
122 let _ = std::thread::Builder::new()
123 .name("cmd-history-save".into())
124 .spawn(move || {
125 if let Some(parent) = path.parent()
126 && let Err(e) = fs::create_dir_all(parent)
127 {
128 log::error!("Failed to create command history directory: {}", e);
129 return;
130 }
131 match serde_yaml::to_string(&file) {
132 Ok(yaml) => {
133 if let Err(e) = fs::write(&path, yaml) {
134 log::error!("Failed to write command history: {}", e);
135 }
136 }
137 Err(e) => {
138 log::error!("Failed to serialize command history: {}", e);
139 }
140 }
141 });
142 }
143
144 pub fn add(&mut self, command: String, exit_code: Option<i32>, duration_ms: Option<u64>) {
147 let trimmed = command.trim().to_string();
148 if trimmed.is_empty() {
149 return;
150 }
151
152 self.entries.retain(|e| e.command != trimmed);
154
155 let timestamp_ms = SystemTime::now()
156 .duration_since(UNIX_EPOCH)
157 .unwrap_or_default()
158 .as_millis() as u64;
159
160 self.entries.push_front(CommandHistoryEntry {
161 command: trimmed,
162 timestamp_ms,
163 exit_code,
164 duration_ms,
165 });
166
167 self.truncate();
168 self.dirty = true;
169 }
170
171 pub fn entries(&self) -> &VecDeque<CommandHistoryEntry> {
173 &self.entries
174 }
175
176 pub fn set_max_entries(&mut self, max: usize) {
178 self.max_entries = max;
179 self.truncate();
180 }
181
182 pub fn is_dirty(&self) -> bool {
184 self.dirty
185 }
186
187 pub fn len(&self) -> usize {
189 self.entries.len()
190 }
191
192 pub fn is_empty(&self) -> bool {
194 self.entries.is_empty()
195 }
196
197 fn truncate(&mut self) {
198 while self.entries.len() > self.max_entries {
199 self.entries.pop_back();
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_add_and_deduplicate() {
210 let mut history = CommandHistory::new(100);
211 history.add("ls -la".to_string(), Some(0), Some(10));
212 history.add("cd /tmp".to_string(), Some(0), Some(5));
213 history.add("ls -la".to_string(), Some(0), Some(15));
214
215 assert_eq!(history.len(), 2);
216 assert_eq!(history.entries()[0].command, "ls -la");
218 assert_eq!(history.entries()[1].command, "cd /tmp");
219 }
220
221 #[test]
222 fn test_max_entries() {
223 let mut history = CommandHistory::new(3);
224 history.add("cmd1".to_string(), None, None);
225 history.add("cmd2".to_string(), None, None);
226 history.add("cmd3".to_string(), None, None);
227 history.add("cmd4".to_string(), None, None);
228
229 assert_eq!(history.len(), 3);
230 assert_eq!(history.entries()[0].command, "cmd4");
231 assert_eq!(history.entries()[2].command, "cmd2");
232 }
233
234 #[test]
235 fn test_empty_command_ignored() {
236 let mut history = CommandHistory::new(100);
237 history.add("".to_string(), None, None);
238 history.add(" ".to_string(), None, None);
239 assert!(history.is_empty());
240 }
241
242 #[test]
243 fn test_whitespace_trimmed() {
244 let mut history = CommandHistory::new(100);
245 history.add(" ls -la ".to_string(), Some(0), None);
246 assert_eq!(history.entries()[0].command, "ls -la");
247 }
248
249 #[test]
250 fn test_save_and_load() {
251 let dir = tempfile::tempdir().unwrap();
252 let path = dir.path().join("command_history.yaml");
253
254 let mut history = CommandHistory::new(100);
255 history.path = path.clone();
256 history.add("echo hello".to_string(), Some(0), Some(100));
257 history.add("ls -la".to_string(), Some(0), Some(50));
258 history.save();
259
260 let mut loaded = CommandHistory::new(100);
261 loaded.path = path;
262 loaded.load();
263
264 assert_eq!(loaded.len(), 2);
265 assert_eq!(loaded.entries()[0].command, "ls -la");
266 assert_eq!(loaded.entries()[1].command, "echo hello");
267 }
268
269 #[test]
270 fn test_set_max_entries_truncates() {
271 let mut history = CommandHistory::new(10);
272 for i in 0..10 {
273 history.add(format!("cmd{i}"), None, None);
274 }
275 assert_eq!(history.len(), 10);
276
277 history.set_max_entries(5);
278 assert_eq!(history.len(), 5);
279 assert_eq!(history.entries()[0].command, "cmd9");
281 }
282}