Skip to main content

shellhist_core/
bash.rs

1//! Bash history (`.bash_history`).
2//!
3//! Plain one-command-per-line by default. With `HISTTIMEFORMAT` set, each entry
4//! is preceded by a `#<unix_epoch>` line (Bash manual, *Bash History Builtins*).
5//! Multi-line commands are stored with literal embedded newlines (`cmdhist` on,
6//! default), so a `#<digits>` line is the only reliable entry boundary — the
7//! lines that follow it accumulate into one command until the next boundary.
8
9use crate::{HistoryEntry, Shell};
10
11/// If `line` is a bash timestamp line (`#` immediately followed by ≥1 digits and
12/// nothing else), return the epoch. Bash writes only the raw integer.
13#[must_use]
14pub fn parse_timestamp_line(line: &str) -> Option<i64> {
15    let rest = line.strip_prefix('#')?;
16    if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) {
17        return None;
18    }
19    rest.parse::<i64>().ok()
20}
21
22/// Parse bash history bytes into entries.
23#[must_use]
24pub fn parse(data: &[u8]) -> Vec<HistoryEntry> {
25    let text = String::from_utf8_lossy(crate::strip_bom(data));
26    let mut entries = Vec::new();
27
28    // `accumulating` is true once we have seen a `#<epoch>` marker: subsequent
29    // physical lines belong to that one (possibly multi-line) command until the
30    // next marker. Before any marker, the file is plain one-command-per-line.
31    let mut pending_ts: Option<i64> = None;
32    let mut accumulating = false;
33    let mut cmd_lines: Vec<&str> = Vec::new();
34
35    let flush = |entries: &mut Vec<HistoryEntry>, ts: Option<i64>, lines: &mut Vec<&str>| {
36        if lines.is_empty() {
37            return;
38        }
39        let command = lines.join("\n");
40        lines.clear();
41        if command.is_empty() {
42            return;
43        }
44        entries.push(HistoryEntry {
45            shell: Shell::Bash,
46            command,
47            timestamp: ts,
48            elapsed: None,
49            paths: Vec::new(),
50        });
51    };
52
53    for line in text.lines() {
54        if let Some(ts) = parse_timestamp_line(line) {
55            flush(&mut entries, pending_ts, &mut cmd_lines);
56            pending_ts = Some(ts);
57            accumulating = true;
58        } else if accumulating {
59            cmd_lines.push(line);
60        } else if !line.is_empty() {
61            // Plain mode: each line is its own command.
62            entries.push(HistoryEntry::plain(Shell::Bash, line));
63        }
64    }
65    flush(&mut entries, pending_ts, &mut cmd_lines);
66
67    entries
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn timestamp_line_requires_hash_then_only_digits() {
76        assert_eq!(parse_timestamp_line("#1700000000"), Some(1_700_000_000));
77        assert_eq!(parse_timestamp_line("#0"), Some(0));
78        assert_eq!(parse_timestamp_line("# 1700"), None); // space after #
79        assert_eq!(parse_timestamp_line("#abc"), None);
80        assert_eq!(parse_timestamp_line("echo #1700"), None);
81        assert_eq!(parse_timestamp_line("#"), None);
82    }
83
84    #[test]
85    fn plain_history_is_one_command_per_line() {
86        let e = parse(b"ls -la\ncd /tmp\nwhoami\n");
87        assert_eq!(e.len(), 3);
88        assert_eq!(e[0].command, "ls -la");
89        assert!(e.iter().all(|x| x.timestamp.is_none()));
90    }
91
92    #[test]
93    fn timestamped_history_pairs_each_command_with_its_epoch() {
94        // The on-disk shape bash writes with HISTTIMEFORMAT set.
95        let e = parse(b"#1700000000\nls\n#1700000005\nwhoami\n");
96        assert_eq!(e.len(), 2);
97        assert_eq!(e[0].timestamp, Some(1_700_000_000));
98        assert_eq!(e[0].command, "ls");
99        assert_eq!(e[1].timestamp, Some(1_700_000_005));
100        assert_eq!(e[1].command, "whoami");
101    }
102
103    #[test]
104    fn multiline_command_keeps_embedded_newlines_under_one_timestamp() {
105        // A `for` loop is stored with literal newlines (lithist/cmdhist).
106        let e = parse(b"#1700000000\nfor i in 1 2\ndo echo $i\ndone\n#1700000009\nls\n");
107        assert_eq!(e.len(), 2);
108        assert_eq!(e[0].command, "for i in 1 2\ndo echo $i\ndone");
109        assert_eq!(e[0].timestamp, Some(1_700_000_000));
110        assert_eq!(e[1].command, "ls");
111    }
112
113    #[test]
114    fn empty_input_yields_no_entries() {
115        assert!(parse(b"").is_empty());
116        assert!(parse(b"\n\n").is_empty());
117    }
118}