1use par_term_config::Config;
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9use std::fs;
10use std::path::PathBuf;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct CommandHistoryEntry {
16 pub command: String,
18 pub timestamp_ms: u64,
20 pub exit_code: Option<i32>,
22 pub duration_ms: Option<u64>,
24}
25
26#[derive(Debug)]
28pub struct CommandHistory {
29 entries: VecDeque<CommandHistoryEntry>,
30 max_entries: usize,
31 path: PathBuf,
32 dirty: bool,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
37struct CommandHistoryFile {
38 commands: Vec<CommandHistoryEntry>,
39}
40
41impl CommandHistory {
42 pub fn new(max_entries: usize) -> Self {
44 Self {
45 entries: VecDeque::new(),
46 max_entries,
47 path: Self::default_path(),
48 dirty: false,
49 }
50 }
51
52 fn default_path() -> PathBuf {
54 Config::config_dir().join("command_history.yaml")
55 }
56
57 pub fn load(&mut self) {
59 if !self.path.exists() {
60 return;
61 }
62 match fs::read_to_string(&self.path) {
63 Ok(contents) => match serde_yaml_ng::from_str::<CommandHistoryFile>(&contents) {
64 Ok(file) => {
65 self.entries = file.commands.into();
67 self.truncate();
68 log::info!("Loaded {} command history entries", self.entries.len());
69 }
70 Err(e) => {
71 log::error!("Failed to parse command history: {}", e);
72 }
73 },
74 Err(e) => {
75 log::error!("Failed to read command history file: {}", e);
76 }
77 }
78 }
79
80 pub fn save(&mut self) {
87 if !self.dirty {
88 return;
89 }
90 let file = CommandHistoryFile {
91 commands: self.entries.iter().cloned().collect(),
92 };
93 match crate::atomic_save::save_yaml_atomic(&self.path, &file) {
94 Ok(()) => {
95 self.dirty = false;
96 log::debug!("Saved {} command history entries", self.entries.len());
97 }
98 Err(e) => log::error!("Failed to write command history: {:#}", e),
99 }
100 }
101
102 pub fn save_background(&mut self) {
105 if !self.dirty {
106 return;
107 }
108 let file = CommandHistoryFile {
109 commands: self.entries.iter().cloned().collect(),
110 };
111 let path = self.path.clone();
112 let spawned = std::thread::Builder::new()
113 .name("cmd-history-save".into())
114 .spawn(move || {
115 if let Err(e) = crate::atomic_save::save_yaml_atomic(&path, &file) {
116 log::error!("Failed to write command history: {:#}", e);
117 }
118 });
119
120 match spawned {
124 Ok(_) => self.dirty = false,
125 Err(e) => log::error!("Failed to spawn command history save thread: {}", e),
126 }
127 }
128
129 pub fn add(&mut self, command: String, exit_code: Option<i32>, duration_ms: Option<u64>) {
132 let trimmed = command.trim().to_string();
133 if trimmed.is_empty() {
134 return;
135 }
136
137 self.entries.retain(|e| e.command != trimmed);
139
140 let timestamp_ms = SystemTime::now()
141 .duration_since(UNIX_EPOCH)
142 .unwrap_or_default()
143 .as_millis() as u64;
144
145 self.entries.push_front(CommandHistoryEntry {
146 command: trimmed,
147 timestamp_ms,
148 exit_code,
149 duration_ms,
150 });
151
152 self.truncate();
153 self.dirty = true;
154 }
155
156 pub fn entries(&self) -> &VecDeque<CommandHistoryEntry> {
158 &self.entries
159 }
160
161 pub fn set_max_entries(&mut self, max: usize) {
163 self.max_entries = max;
164 self.truncate();
165 }
166
167 pub fn is_dirty(&self) -> bool {
169 self.dirty
170 }
171
172 pub fn update_exit_code_if_unknown(
181 &mut self,
182 command: &str,
183 exit_code: Option<i32>,
184 duration_ms: Option<u64>,
185 ) {
186 let trimmed = command.trim();
187 if let Some(entry) = self.entries.iter_mut().find(|e| e.command == trimmed)
188 && exit_code.is_some()
189 && entry.exit_code != exit_code
190 {
191 entry.exit_code = exit_code;
192 if entry.duration_ms.is_none() {
193 entry.duration_ms = duration_ms;
194 }
195 self.dirty = true;
196 }
197 }
198
199 pub fn len(&self) -> usize {
201 self.entries.len()
202 }
203
204 pub fn is_empty(&self) -> bool {
206 self.entries.is_empty()
207 }
208
209 fn truncate(&mut self) {
210 while self.entries.len() > self.max_entries {
211 self.entries.pop_back();
212 }
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn test_add_and_deduplicate() {
222 let mut history = CommandHistory::new(100);
223 history.add("ls -la".to_string(), Some(0), Some(10));
224 history.add("cd /tmp".to_string(), Some(0), Some(5));
225 history.add("ls -la".to_string(), Some(0), Some(15));
226
227 assert_eq!(history.len(), 2);
228 assert_eq!(history.entries()[0].command, "ls -la");
230 assert_eq!(history.entries()[1].command, "cd /tmp");
231 }
232
233 #[test]
234 fn test_max_entries() {
235 let mut history = CommandHistory::new(3);
236 history.add("cmd1".to_string(), None, None);
237 history.add("cmd2".to_string(), None, None);
238 history.add("cmd3".to_string(), None, None);
239 history.add("cmd4".to_string(), None, None);
240
241 assert_eq!(history.len(), 3);
242 assert_eq!(history.entries()[0].command, "cmd4");
243 assert_eq!(history.entries()[2].command, "cmd2");
244 }
245
246 #[test]
247 fn test_empty_command_ignored() {
248 let mut history = CommandHistory::new(100);
249 history.add("".to_string(), None, None);
250 history.add(" ".to_string(), None, None);
251 assert!(history.is_empty());
252 }
253
254 #[test]
255 fn test_whitespace_trimmed() {
256 let mut history = CommandHistory::new(100);
257 history.add(" ls -la ".to_string(), Some(0), None);
258 assert_eq!(history.entries()[0].command, "ls -la");
259 }
260
261 #[test]
262 fn test_save_and_load() {
263 let dir = tempfile::tempdir().unwrap();
264 let path = dir.path().join("command_history.yaml");
265
266 let mut history = CommandHistory::new(100);
267 history.path = path.clone();
268 history.add("echo hello".to_string(), Some(0), Some(100));
269 history.add("ls -la".to_string(), Some(0), Some(50));
270 history.save();
271
272 let mut loaded = CommandHistory::new(100);
273 loaded.path = path;
274 loaded.load();
275
276 assert_eq!(loaded.len(), 2);
277 assert_eq!(loaded.entries()[0].command, "ls -la");
278 assert_eq!(loaded.entries()[1].command, "echo hello");
279 }
280
281 #[cfg(unix)]
284 #[test]
285 fn test_saved_history_is_owner_only() {
286 use std::os::unix::fs::PermissionsExt;
287
288 let dir = tempfile::tempdir().unwrap();
289 let path = dir.path().join("command_history.yaml");
290 fs::write(&path, "commands: []").expect("seed history file");
291 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod");
292
293 let mut history = CommandHistory::new(100);
294 history.path = path.clone();
295 history.add("export API_KEY=secret".to_string(), Some(0), None);
296 history.save();
297
298 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
299 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
300 }
301
302 #[test]
303 fn test_set_max_entries_truncates() {
304 let mut history = CommandHistory::new(10);
305 for i in 0..10 {
306 history.add(format!("cmd{i}"), None, None);
307 }
308 assert_eq!(history.len(), 10);
309
310 history.set_max_entries(5);
311 assert_eq!(history.len(), 5);
312 assert_eq!(history.entries()[0].command, "cmd9");
314 }
315}