Skip to main content

par_term/
command_history.rs

1//! Persistent command history for fuzzy search.
2//!
3//! Tracks commands captured via OSC 133 shell integration markers and persists
4//! them across sessions to `~/.config/par-term/command_history.yaml`.
5
6use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::fs;
9use std::path::PathBuf;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12/// A single command history entry persisted across sessions.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct CommandHistoryEntry {
15    /// The command text
16    pub command: String,
17    /// Timestamp in milliseconds since epoch
18    pub timestamp_ms: u64,
19    /// Exit code (if known)
20    pub exit_code: Option<i32>,
21    /// Duration in milliseconds (if known)
22    pub duration_ms: Option<u64>,
23}
24
25/// Manages a persistent, deduplicated command history with a configurable max size.
26#[derive(Debug)]
27pub struct CommandHistory {
28    entries: VecDeque<CommandHistoryEntry>,
29    max_entries: usize,
30    path: PathBuf,
31    dirty: bool,
32}
33
34/// YAML wrapper for serialization
35#[derive(Debug, Serialize, Deserialize)]
36struct CommandHistoryFile {
37    commands: Vec<CommandHistoryEntry>,
38}
39
40impl CommandHistory {
41    /// Create a new command history with the given max entries and persistence path.
42    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    /// Get the default persistence path.
52    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    /// Load history from disk, merging with any existing in-memory entries.
60    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                    // Load entries, newest first (file stores newest first)
68                    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    /// Save history to disk.
83    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    /// Serialize history and spawn a background thread to write it to disk.
112    /// Used during shutdown to avoid blocking the main thread.
113    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    /// Add a command to history, deduplicating by command text.
145    /// If the command already exists, it is moved to the front with updated metadata.
146    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        // Remove existing duplicate (we'll re-add it at the front)
153        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    /// Get all entries (newest first).
172    pub fn entries(&self) -> &VecDeque<CommandHistoryEntry> {
173        &self.entries
174    }
175
176    /// Update max entries and truncate if needed.
177    pub fn set_max_entries(&mut self, max: usize) {
178        self.max_entries = max;
179        self.truncate();
180    }
181
182    /// Whether the history has been modified since last save.
183    pub fn is_dirty(&self) -> bool {
184        self.dirty
185    }
186
187    /// Get number of entries.
188    pub fn len(&self) -> usize {
189        self.entries.len()
190    }
191
192    /// Check if empty.
193    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        // Most recent should be first
217        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        // Newest entries should remain
280        assert_eq!(history.entries()[0].command, "cmd9");
281    }
282}