1use crate::{HistoryEntry, Shell};
10
11#[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#[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 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 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); 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 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 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}