atuin_client/import/
bash.rs1use std::{path::PathBuf, str};
2
3use async_trait::async_trait;
4use directories::UserDirs;
5use eyre::{eyre, Result};
6use itertools::Itertools;
7use time::{Duration, OffsetDateTime};
8
9use super::{get_histpath, unix_byte_lines, Importer, Loader};
10use crate::history::History;
11use crate::import::read_to_end;
12
13#[derive(Debug)]
14pub struct Bash {
15 bytes: Vec<u8>,
16}
17
18fn default_histpath() -> Result<PathBuf> {
19 let user_dirs = UserDirs::new().ok_or_else(|| eyre!("could not find user directories"))?;
20 let home_dir = user_dirs.home_dir();
21
22 Ok(home_dir.join(".bash_history"))
23}
24
25#[async_trait]
26impl Importer for Bash {
27 const NAME: &'static str = "bash";
28
29 async fn new() -> Result<Self> {
30 let bytes = read_to_end(get_histpath(default_histpath)?)?;
31 Ok(Self { bytes })
32 }
33
34 async fn entries(&mut self) -> Result<usize> {
35 let count = unix_byte_lines(&self.bytes)
36 .map(LineType::from)
37 .filter(|line| matches!(line, LineType::Command(_)))
38 .count();
39 Ok(count)
40 }
41
42 async fn load(self, h: &mut impl Loader) -> Result<()> {
43 let lines = unix_byte_lines(&self.bytes)
44 .map(LineType::from)
45 .filter(|line| !matches!(line, LineType::NotUtf8)) .collect_vec();
47
48 let (commands_before_first_timestamp, first_timestamp) = lines
49 .iter()
50 .enumerate()
51 .find_map(|(i, line)| match line {
52 LineType::Timestamp(t) => Some((i, *t)),
53 _ => None,
54 })
55 .unwrap_or((lines.len(), OffsetDateTime::now_utc()));
57
58 let timestamp_increment = Duration::milliseconds(1);
64
65 let mut next_timestamp =
68 first_timestamp - timestamp_increment * commands_before_first_timestamp as i32;
69
70 for line in lines.into_iter() {
71 match line {
72 LineType::NotUtf8 => unreachable!(), LineType::Empty => {} LineType::Timestamp(t) => {
75 if t < next_timestamp {
76 warn!("Time reversal detected in Bash history! Commands may be ordered incorrectly.");
77 }
78 next_timestamp = t;
79 }
80 LineType::Command(c) => {
81 let imported = History::import().timestamp(next_timestamp).command(c);
82
83 h.push(imported.build().into()).await?;
84 next_timestamp += timestamp_increment;
85 }
86 }
87 }
88
89 Ok(())
90 }
91}
92
93#[derive(Debug, Clone)]
94enum LineType<'a> {
95 NotUtf8,
96 Empty,
98 Timestamp(OffsetDateTime),
101 Command(&'a str),
103}
104impl<'a> From<&'a [u8]> for LineType<'a> {
105 fn from(bytes: &'a [u8]) -> Self {
106 let Ok(line) = str::from_utf8(bytes) else {
107 return LineType::NotUtf8;
108 };
109 if line.is_empty() {
110 return LineType::Empty;
111 }
112 let parsed = match try_parse_line_as_timestamp(line) {
113 Some(time) => LineType::Timestamp(time),
114 None => LineType::Command(line),
115 };
116 parsed
117 }
118}
119
120fn try_parse_line_as_timestamp(line: &str) -> Option<OffsetDateTime> {
121 let seconds = line.strip_prefix('#')?.parse().ok()?;
122 OffsetDateTime::from_unix_timestamp(seconds).ok()
123}
124
125#[cfg(test)]
126mod test {
127 use std::cmp::Ordering;
128
129 use itertools::{assert_equal, Itertools};
130
131 use crate::import::{tests::TestLoader, Importer};
132
133 use super::Bash;
134
135 #[tokio::test]
136 async fn parse_no_timestamps() {
137 let bytes = r"cargo install atuin
138cargo update
139cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷
140"
141 .as_bytes()
142 .to_owned();
143
144 let mut bash = Bash { bytes };
145 assert_eq!(bash.entries().await.unwrap(), 3);
146
147 let mut loader = TestLoader::default();
148 bash.load(&mut loader).await.unwrap();
149
150 assert_equal(
151 loader.buf.iter().map(|h| h.command.as_str()),
152 [
153 "cargo install atuin",
154 "cargo update",
155 "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷",
156 ],
157 );
158 assert!(is_strictly_sorted(loader.buf.iter().map(|h| h.timestamp)))
159 }
160
161 #[tokio::test]
162 async fn parse_with_timestamps() {
163 let bytes = b"#1672918999
164git reset
165#1672919006
166git clean -dxf
167#1672919020
168cd ../
169"
170 .to_vec();
171
172 let mut bash = Bash { bytes };
173 assert_eq!(bash.entries().await.unwrap(), 3);
174
175 let mut loader = TestLoader::default();
176 bash.load(&mut loader).await.unwrap();
177
178 assert_equal(
179 loader.buf.iter().map(|h| h.command.as_str()),
180 ["git reset", "git clean -dxf", "cd ../"],
181 );
182 assert_equal(
183 loader.buf.iter().map(|h| h.timestamp.unix_timestamp()),
184 [1672918999, 1672919006, 1672919020],
185 )
186 }
187
188 #[tokio::test]
189 async fn parse_with_partial_timestamps() {
190 let bytes = b"git reset
191#1672919006
192git clean -dxf
193cd ../
194"
195 .to_vec();
196
197 let mut bash = Bash { bytes };
198 assert_eq!(bash.entries().await.unwrap(), 3);
199
200 let mut loader = TestLoader::default();
201 bash.load(&mut loader).await.unwrap();
202
203 assert_equal(
204 loader.buf.iter().map(|h| h.command.as_str()),
205 ["git reset", "git clean -dxf", "cd ../"],
206 );
207 assert!(is_strictly_sorted(loader.buf.iter().map(|h| h.timestamp)))
208 }
209
210 fn is_strictly_sorted<T>(iter: impl IntoIterator<Item = T>) -> bool
211 where
212 T: Clone + PartialOrd,
213 {
214 iter.into_iter()
215 .tuple_windows()
216 .all(|(a, b)| matches!(a.partial_cmp(&b), Some(Ordering::Less)))
217 }
218}