atuin_client/import/
zsh.rs

1// import old shell history!
2// automatically hoover up all that we can find
3
4use std::borrow::Cow;
5use std::path::PathBuf;
6
7use async_trait::async_trait;
8use directories::UserDirs;
9use eyre::{Result, eyre};
10use time::OffsetDateTime;
11
12use super::{Importer, Loader, get_histfile_path, unix_byte_lines};
13use crate::history::History;
14use crate::import::read_to_end;
15
16#[derive(Debug)]
17pub struct Zsh {
18    bytes: Vec<u8>,
19}
20
21fn default_histpath() -> Result<PathBuf> {
22    // oh-my-zsh sets HISTFILE=~/.zhistory
23    // zsh has no default value for this var, but uses ~/.zhistory.
24    // zsh-newuser-install propose as default .histfile https://github.com/zsh-users/zsh/blob/master/Functions/Newuser/zsh-newuser-install#L794
25    // we could maybe be smarter about this in the future :)
26    let user_dirs = UserDirs::new().ok_or_else(|| eyre!("could not find user directories"))?;
27    let home_dir = user_dirs.home_dir();
28
29    let mut candidates = [".zhistory", ".zsh_history", ".histfile"].iter();
30    loop {
31        match candidates.next() {
32            Some(candidate) => {
33                let histpath = home_dir.join(candidate);
34                if histpath.exists() {
35                    break Ok(histpath);
36                }
37            }
38            None => {
39                break Err(eyre!(
40                    "Could not find history file. Try setting and exporting $HISTFILE"
41                ));
42            }
43        }
44    }
45}
46
47#[async_trait]
48impl Importer for Zsh {
49    const NAME: &'static str = "zsh";
50
51    async fn new() -> Result<Self> {
52        let bytes = read_to_end(get_histfile_path(default_histpath)?)?;
53        Ok(Self { bytes })
54    }
55
56    async fn entries(&mut self) -> Result<usize> {
57        Ok(super::count_lines(&self.bytes))
58    }
59
60    async fn load(self, h: &mut impl Loader) -> Result<()> {
61        let now = OffsetDateTime::now_utc();
62        let mut line = String::new();
63
64        let mut counter = 0;
65        for b in unix_byte_lines(&self.bytes) {
66            let s = match unmetafy(b) {
67                Some(s) => s,
68                _ => continue, // we can skip past things like invalid utf8
69            };
70
71            if let Some(s) = s.strip_suffix('\\') {
72                line.push_str(s);
73                line.push_str("\\\n");
74            } else {
75                line.push_str(&s);
76                let command = std::mem::take(&mut line);
77
78                if let Some(command) = command.strip_prefix(": ") {
79                    counter += 1;
80                    h.push(parse_extended(command, counter)).await?;
81                } else {
82                    let offset = time::Duration::seconds(counter);
83                    counter += 1;
84
85                    let imported = History::import()
86                        // preserve ordering
87                        .timestamp(now - offset)
88                        .command(command.trim_end().to_string());
89
90                    h.push(imported.build().into()).await?;
91                }
92            }
93        }
94
95        Ok(())
96    }
97}
98
99fn parse_extended(line: &str, counter: i64) -> History {
100    let (time, duration) = line.split_once(':').unwrap();
101    let (duration, command) = duration.split_once(';').unwrap();
102
103    let time = time
104        .parse::<i64>()
105        .ok()
106        .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())
107        .unwrap_or_else(OffsetDateTime::now_utc)
108        + time::Duration::milliseconds(counter);
109
110    // use nanos, because why the hell not? we won't display them.
111    let duration = duration.parse::<i64>().map_or(-1, |t| t * 1_000_000_000);
112
113    let imported = History::import()
114        .timestamp(time)
115        .command(command.trim_end().to_string())
116        .duration(duration);
117
118    imported.build().into()
119}
120
121fn unmetafy(line: &[u8]) -> Option<Cow<str>> {
122    if line.contains(&0x83) {
123        let mut s = Vec::with_capacity(line.len());
124        let mut is_meta = false;
125        for ch in line {
126            if *ch == 0x83 {
127                is_meta = true;
128            } else if is_meta {
129                is_meta = false;
130                s.push(*ch ^ 32);
131            } else {
132                s.push(*ch)
133            }
134        }
135        String::from_utf8(s).ok().map(Cow::Owned)
136    } else {
137        std::str::from_utf8(line).ok().map(Cow::Borrowed)
138    }
139}
140
141#[cfg(test)]
142mod test {
143    use itertools::assert_equal;
144
145    use crate::import::tests::TestLoader;
146
147    use super::*;
148
149    #[test]
150    fn test_parse_extended_simple() {
151        let parsed = parse_extended("1613322469:0;cargo install atuin", 0);
152
153        assert_eq!(parsed.command, "cargo install atuin");
154        assert_eq!(parsed.duration, 0);
155        assert_eq!(
156            parsed.timestamp,
157            OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap()
158        );
159
160        let parsed = parse_extended("1613322469:10;cargo install atuin;cargo update", 0);
161
162        assert_eq!(parsed.command, "cargo install atuin;cargo update");
163        assert_eq!(parsed.duration, 10_000_000_000);
164        assert_eq!(
165            parsed.timestamp,
166            OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap()
167        );
168
169        let parsed = parse_extended("1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷", 0);
170
171        assert_eq!(parsed.command, "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷");
172        assert_eq!(parsed.duration, 10_000_000_000);
173        assert_eq!(
174            parsed.timestamp,
175            OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap()
176        );
177
178        let parsed = parse_extended("1613322469:10;cargo install \\n atuin\n", 0);
179
180        assert_eq!(parsed.command, "cargo install \\n atuin");
181        assert_eq!(parsed.duration, 10_000_000_000);
182        assert_eq!(
183            parsed.timestamp,
184            OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap()
185        );
186    }
187
188    #[tokio::test]
189    async fn test_parse_file() {
190        let bytes = r": 1613322469:0;cargo install atuin
191: 1613322469:10;cargo install atuin; \
192cargo update
193: 1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷
194"
195        .as_bytes()
196        .to_owned();
197
198        let mut zsh = Zsh { bytes };
199        assert_eq!(zsh.entries().await.unwrap(), 4);
200
201        let mut loader = TestLoader::default();
202        zsh.load(&mut loader).await.unwrap();
203
204        assert_equal(
205            loader.buf.iter().map(|h| h.command.as_str()),
206            [
207                "cargo install atuin",
208                "cargo install atuin; \\\ncargo update",
209                "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷",
210            ],
211        );
212    }
213
214    #[tokio::test]
215    async fn test_parse_metafied() {
216        let bytes =
217            b"echo \xe4\xbd\x83\x80\xe5\xa5\xbd\nls ~/\xe9\x83\xbf\xb3\xe4\xb9\x83\xb0\n".to_vec();
218
219        let mut zsh = Zsh { bytes };
220        assert_eq!(zsh.entries().await.unwrap(), 2);
221
222        let mut loader = TestLoader::default();
223        zsh.load(&mut loader).await.unwrap();
224
225        assert_equal(
226            loader.buf.iter().map(|h| h.command.as_str()),
227            ["echo 你好", "ls ~/音乐"],
228        );
229    }
230}