1use crate::{HistoryEntry, Shell};
10
11#[must_use]
14pub fn is_extended_line(line: &str) -> bool {
15 parse_extended_prefix(line).is_some()
16}
17
18fn parse_extended_prefix(line: &str) -> Option<((i64, i64), &str)> {
20 let rest = line.strip_prefix(": ")?;
21 let (meta, command) = rest.split_once(';')?;
22 let (start, elapsed) = meta.split_once(':')?;
23 if start.is_empty() || !start.bytes().all(|b| b.is_ascii_digit()) {
24 return None;
25 }
26 if elapsed.is_empty() || !elapsed.bytes().all(|b| b.is_ascii_digit()) {
27 return None;
28 }
29 Some((
30 start.parse::<i64>().ok().zip(elapsed.parse::<i64>().ok())?,
31 command,
32 ))
33}
34
35fn ends_with_odd_backslashes(line: &str) -> bool {
37 line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
38}
39
40fn logical_lines(text: &str) -> Vec<String> {
43 let mut out = Vec::new();
44 let mut current = String::new();
45 let mut continuing = false;
46 for line in text.split('\n') {
47 if continuing {
48 current.push('\n');
49 current.push_str(line);
50 } else {
51 current = line.to_string();
52 }
53 if ends_with_odd_backslashes(line) {
54 current.pop();
56 continuing = true;
57 } else {
58 out.push(std::mem::take(&mut current));
59 continuing = false;
60 }
61 }
62 if continuing {
63 out.push(current);
64 }
65 out
66}
67
68#[must_use]
70pub fn parse(data: &[u8]) -> Vec<HistoryEntry> {
71 let text = String::from_utf8_lossy(crate::strip_bom(data));
72 let mut entries = Vec::new();
73 for ll in logical_lines(&text) {
74 if ll.is_empty() {
75 continue;
76 }
77 if let Some(((start, elapsed), command)) = parse_extended_prefix(&ll) {
78 entries.push(HistoryEntry {
79 shell: Shell::Zsh,
80 command: command.to_string(),
81 timestamp: Some(start),
82 elapsed: Some(elapsed),
83 paths: Vec::new(),
84 });
85 } else {
86 entries.push(HistoryEntry::plain(Shell::Zsh, ll));
87 }
88 }
89 entries
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn extended_prefix_is_recognized() {
98 assert!(is_extended_line(": 1700000000:3;sleep 3"));
99 assert!(!is_extended_line("plain command"));
100 assert!(!is_extended_line(": notanumber:0;x"));
101 }
102
103 #[test]
104 fn extended_entry_carries_start_and_elapsed() {
105 let e = parse(b": 1700000000:3;sleep 3\n: 1700000010:0;ls\n");
106 assert_eq!(e.len(), 2);
107 assert_eq!(e[0].timestamp, Some(1_700_000_000));
108 assert_eq!(e[0].elapsed, Some(3));
109 assert_eq!(e[0].command, "sleep 3");
110 assert_eq!(e[1].elapsed, Some(0));
111 }
112
113 #[test]
114 fn plain_zsh_history_has_no_timestamps() {
115 let e = parse(b"ls\ncd /tmp\n");
116 assert_eq!(e.len(), 2);
117 assert!(e
118 .iter()
119 .all(|x| x.timestamp.is_none() && x.elapsed.is_none()));
120 }
121
122 #[test]
123 fn backslash_continuation_rejoins_a_multiline_command() {
124 let e = parse(b": 1700000000:0;echo a\\\nb\n: 1700000001:0;ls\n");
126 assert_eq!(e.len(), 2);
127 assert_eq!(e[0].command, "echo a\nb");
128 assert_eq!(e[1].command, "ls");
129 }
130
131 #[test]
132 fn a_command_containing_a_semicolon_keeps_it() {
133 let e = parse(b": 1700000000:0;echo a; echo b\n");
134 assert_eq!(e.len(), 1);
135 assert_eq!(e[0].command, "echo a; echo b");
136 }
137
138 #[test]
139 fn empty_input_yields_no_entries() {
140 assert!(parse(b"").is_empty());
141 }
142}