atuin_client/import/
fish.rs

1// import old shell history!
2// automatically hoover up all that we can find
3
4use std::path::PathBuf;
5
6use async_trait::async_trait;
7use directories::BaseDirs;
8use eyre::{eyre, Result};
9use time::OffsetDateTime;
10
11use super::{unix_byte_lines, Importer, Loader};
12use crate::history::History;
13use crate::import::read_to_end;
14
15#[derive(Debug)]
16pub struct Fish {
17    bytes: Vec<u8>,
18}
19
20/// see https://fishshell.com/docs/current/interactive.html#searchable-command-history
21fn default_histpath() -> Result<PathBuf> {
22    let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?;
23    let data = std::env::var("XDG_DATA_HOME").map_or_else(
24        |_| base.home_dir().join(".local").join("share"),
25        PathBuf::from,
26    );
27
28    // fish supports multiple history sessions
29    // If `fish_history` var is missing, or set to `default`, use `fish` as the session
30    let session = std::env::var("fish_history").unwrap_or_else(|_| String::from("fish"));
31    let session = if session == "default" {
32        String::from("fish")
33    } else {
34        session
35    };
36
37    let mut histpath = data.join("fish");
38    histpath.push(format!("{session}_history"));
39
40    if histpath.exists() {
41        Ok(histpath)
42    } else {
43        Err(eyre!("Could not find history file."))
44    }
45}
46
47#[async_trait]
48impl Importer for Fish {
49    const NAME: &'static str = "fish";
50
51    async fn new() -> Result<Self> {
52        let bytes = read_to_end(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, loader: &mut impl Loader) -> Result<()> {
61        let now = OffsetDateTime::now_utc();
62        let mut time: Option<OffsetDateTime> = None;
63        let mut cmd: Option<String> = None;
64
65        for b in unix_byte_lines(&self.bytes) {
66            let s = match std::str::from_utf8(b) {
67                Ok(s) => s,
68                Err(_) => continue, // we can skip past things like invalid utf8
69            };
70
71            if let Some(c) = s.strip_prefix("- cmd: ") {
72                // first, we must deal with the prev cmd
73                if let Some(cmd) = cmd.take() {
74                    let time = time.unwrap_or(now);
75                    let entry = History::import().timestamp(time).command(cmd);
76
77                    loader.push(entry.build().into()).await?;
78                }
79
80                // using raw strings to avoid needing escaping.
81                // replaces double backslashes with single backslashes
82                let c = c.replace(r"\\", r"\");
83                // replaces escaped newlines
84                let c = c.replace(r"\n", "\n");
85                // TODO: any other escape characters?
86
87                cmd = Some(c);
88            } else if let Some(t) = s.strip_prefix("  when: ") {
89                // if t is not an int, just ignore this line
90                if let Ok(t) = t.parse::<i64>() {
91                    time = Some(OffsetDateTime::from_unix_timestamp(t)?);
92                }
93            } else {
94                // ... ignore paths lines
95            }
96        }
97
98        // we might have a trailing cmd
99        if let Some(cmd) = cmd.take() {
100            let time = time.unwrap_or(now);
101            let entry = History::import().timestamp(time).command(cmd);
102
103            loader.push(entry.build().into()).await?;
104        }
105
106        Ok(())
107    }
108}
109
110#[cfg(test)]
111mod test {
112
113    use crate::import::{tests::TestLoader, Importer};
114
115    use super::Fish;
116
117    #[tokio::test]
118    async fn parse_complex() {
119        // complicated input with varying contents and escaped strings.
120        let bytes = r#"- cmd: history --help
121  when: 1639162832
122- cmd: cat ~/.bash_history
123  when: 1639162851
124  paths:
125    - ~/.bash_history
126- cmd: ls ~/.local/share/fish/fish_history
127  when: 1639162890
128  paths:
129    - ~/.local/share/fish/fish_history
130- cmd: cat ~/.local/share/fish/fish_history
131  when: 1639162893
132  paths:
133    - ~/.local/share/fish/fish_history
134ERROR
135- CORRUPTED: ENTRY
136  CONTINUE:
137    - AS
138    - NORMAL
139- cmd: echo "foo" \\\n'bar' baz
140  when: 1639162933
141- cmd: cat ~/.local/share/fish/fish_history
142  when: 1639162939
143  paths:
144    - ~/.local/share/fish/fish_history
145- cmd: echo "\\"" \\\\ "\\\\"
146  when: 1639163063
147- cmd: cat ~/.local/share/fish/fish_history
148  when: 1639163066
149  paths:
150    - ~/.local/share/fish/fish_history
151"#
152        .as_bytes()
153        .to_owned();
154
155        let fish = Fish { bytes };
156
157        let mut loader = TestLoader::default();
158        fish.load(&mut loader).await.unwrap();
159        let mut history = loader.buf.into_iter();
160
161        // simple wrapper for fish history entry
162        macro_rules! fishtory {
163            ($timestamp:expr, $command:expr) => {
164                let h = history.next().expect("missing entry in history");
165                assert_eq!(h.command.as_str(), $command);
166                assert_eq!(h.timestamp.unix_timestamp(), $timestamp);
167            };
168        }
169
170        fishtory!(1639162832, "history --help");
171        fishtory!(1639162851, "cat ~/.bash_history");
172        fishtory!(1639162890, "ls ~/.local/share/fish/fish_history");
173        fishtory!(1639162893, "cat ~/.local/share/fish/fish_history");
174        fishtory!(1639162933, "echo \"foo\" \\\n'bar' baz");
175        fishtory!(1639162939, "cat ~/.local/share/fish/fish_history");
176        fishtory!(1639163063, r#"echo "\"" \\ "\\""#);
177        fishtory!(1639163066, "cat ~/.local/share/fish/fish_history");
178    }
179}