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 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/// A single command history entry persisted across sessions.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct CommandHistoryEntry {
16    /// The command text
17    pub command: String,
18    /// Timestamp in milliseconds since epoch
19    pub timestamp_ms: u64,
20    /// Exit code (if known)
21    pub exit_code: Option<i32>,
22    /// Duration in milliseconds (if known)
23    pub duration_ms: Option<u64>,
24}
25
26/// Manages a persistent, deduplicated command history with a configurable max size.
27#[derive(Debug)]
28pub struct CommandHistory {
29    entries: VecDeque<CommandHistoryEntry>,
30    max_entries: usize,
31    path: PathBuf,
32    dirty: bool,
33}
34
35/// YAML wrapper for serialization
36#[derive(Debug, Serialize, Deserialize)]
37struct CommandHistoryFile {
38    commands: Vec<CommandHistoryEntry>,
39}
40
41impl CommandHistory {
42    /// Create a new command history with the given max entries and persistence path.
43    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    /// Get the default persistence path.
53    fn default_path() -> PathBuf {
54        Config::config_dir().join("command_history.yaml")
55    }
56
57    /// Load history from disk, merging with any existing in-memory entries.
58    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                    // Load entries, newest first (file stores newest first)
66                    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    /// Save history to disk.
81    ///
82    /// SEC-021: written through [`crate::atomic_save`] at mode `0o600`. Command
83    /// history routinely captures secrets typed on the command line (tokens in
84    /// `curl -H`, `export API_KEY=…`), so it must not be readable by other
85    /// users; and a truncated file parses as an error, discarding the history.
86    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    /// Serialize history and spawn a background thread to write it to disk.
103    /// Used during shutdown to avoid blocking the main thread.
104    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        // Only clear `dirty` once the write is actually under way. Clearing it
121        // first meant a failed spawn discarded the session's history with nothing
122        // left to signal that a retry was needed.
123        match spawned {
124            Ok(_) => self.dirty = false,
125            Err(e) => log::error!("Failed to spawn command history save thread: {}", e),
126        }
127    }
128
129    /// Add a command to history, deduplicating by command text.
130    /// If the command already exists, it is moved to the front with updated metadata.
131    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        // Remove existing duplicate (we'll re-add it at the front)
138        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    /// Get all entries (newest first).
157    pub fn entries(&self) -> &VecDeque<CommandHistoryEntry> {
158        &self.entries
159    }
160
161    /// Update max entries and truncate if needed.
162    pub fn set_max_entries(&mut self, max: usize) {
163        self.max_entries = max;
164        self.truncate();
165    }
166
167    /// Whether the history has been modified since last save.
168    pub fn is_dirty(&self) -> bool {
169        self.dirty
170    }
171
172    /// Update the exit code of an existing entry when a newer value is available.
173    ///
174    /// Scrollback marks are returned oldest-first, so the same command may appear
175    /// multiple times (one per execution). `synced_commands` prevents `add()` from
176    /// being called more than once per command per session, so subsequent marks (which
177    /// may have a more recent exit code) arrive here instead. We update whenever the
178    /// new exit code is `Some` and differs from what is stored, so the most-recently-
179    /// executed instance always wins.
180    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    /// Get number of entries.
200    pub fn len(&self) -> usize {
201        self.entries.len()
202    }
203
204    /// Check if empty.
205    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        // Most recent should be first
229        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    /// SEC-021: command lines routinely carry tokens and passwords, so the
282    /// history file must not be readable by other users on the machine.
283    #[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        // Newest entries should remain
313        assert_eq!(history.entries()[0].command, "cmd9");
314    }
315}