atuin_client/import/
xonsh.rs

1use std::env;
2use std::fs::{self, File};
3use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use directories::BaseDirs;
7use eyre::{eyre, Result};
8use serde::Deserialize;
9use time::OffsetDateTime;
10use uuid::timestamp::{context::NoContext, Timestamp};
11use uuid::Uuid;
12
13use super::{get_histpath, Importer, Loader};
14use crate::history::History;
15use crate::utils::get_host_user;
16
17// Note: both HistoryFile and HistoryData have other keys present in the JSON, we don't
18// care about them so we leave them unspecified so as to avoid deserializing unnecessarily.
19#[derive(Debug, Deserialize)]
20struct HistoryFile {
21    data: HistoryData,
22}
23
24#[derive(Debug, Deserialize)]
25struct HistoryData {
26    sessionid: String,
27    cmds: Vec<HistoryCmd>,
28}
29
30#[derive(Debug, Deserialize)]
31struct HistoryCmd {
32    cwd: String,
33    inp: String,
34    rtn: Option<i64>,
35    ts: (f64, f64),
36}
37
38#[derive(Debug)]
39pub struct Xonsh {
40    // history is stored as a bunch of json files, one per session
41    sessions: Vec<HistoryData>,
42    hostname: String,
43}
44
45fn xonsh_hist_dir(xonsh_data_dir: Option<String>) -> Result<PathBuf> {
46    // if running within xonsh, this will be available
47    if let Some(d) = xonsh_data_dir {
48        let mut path = PathBuf::from(d);
49        path.push("history_json");
50        return Ok(path);
51    }
52
53    // otherwise, fall back to default
54    let base = BaseDirs::new().ok_or_else(|| eyre!("Could not determine home directory"))?;
55
56    let hist_dir = base.data_dir().join("xonsh/history_json");
57    if hist_dir.exists() || cfg!(test) {
58        Ok(hist_dir)
59    } else {
60        Err(eyre!("Could not find xonsh history files"))
61    }
62}
63
64fn load_sessions(hist_dir: &Path) -> Result<Vec<HistoryData>> {
65    let mut sessions = vec![];
66    for entry in fs::read_dir(hist_dir)? {
67        let p = entry?.path();
68        let ext = p.extension().and_then(|e| e.to_str());
69        if p.is_file() && ext == Some("json") {
70            if let Some(data) = load_session(&p)? {
71                sessions.push(data);
72            }
73        }
74    }
75    Ok(sessions)
76}
77
78fn load_session(path: &Path) -> Result<Option<HistoryData>> {
79    let file = File::open(path)?;
80    // empty files are not valid json, so we can't deserialize them
81    if file.metadata()?.len() == 0 {
82        return Ok(None);
83    }
84
85    let mut hist_file: HistoryFile = serde_json::from_reader(file)?;
86
87    // if there are commands in this session, replace the existing UUIDv4
88    // with a UUIDv7 generated from the timestamp of the first command
89    if let Some(cmd) = hist_file.data.cmds.first() {
90        let seconds = cmd.ts.0.trunc() as u64;
91        let nanos = (cmd.ts.0.fract() * 1_000_000_000_f64) as u32;
92        let ts = Timestamp::from_unix(NoContext, seconds, nanos);
93        hist_file.data.sessionid = Uuid::new_v7(ts).to_string();
94    }
95    Ok(Some(hist_file.data))
96}
97
98#[async_trait]
99impl Importer for Xonsh {
100    const NAME: &'static str = "xonsh";
101
102    async fn new() -> Result<Self> {
103        // wrap xonsh-specific path resolver in general one so that it respects $HISTPATH
104        let xonsh_data_dir = env::var("XONSH_DATA_DIR").ok();
105        let hist_dir = get_histpath(|| xonsh_hist_dir(xonsh_data_dir))?;
106        let sessions = load_sessions(&hist_dir)?;
107        let hostname = get_host_user();
108        Ok(Xonsh { sessions, hostname })
109    }
110
111    async fn entries(&mut self) -> Result<usize> {
112        let total = self.sessions.iter().map(|s| s.cmds.len()).sum();
113        Ok(total)
114    }
115
116    async fn load(self, loader: &mut impl Loader) -> Result<()> {
117        for session in self.sessions {
118            for cmd in session.cmds {
119                let (start, end) = cmd.ts;
120                let ts_nanos = (start * 1_000_000_000_f64) as i128;
121                let timestamp = OffsetDateTime::from_unix_timestamp_nanos(ts_nanos)?;
122
123                let duration = (end - start) * 1_000_000_000_f64;
124
125                match cmd.rtn {
126                    Some(exit) => {
127                        let entry = History::import()
128                            .timestamp(timestamp)
129                            .duration(duration.trunc() as i64)
130                            .exit(exit)
131                            .command(cmd.inp.trim())
132                            .cwd(cmd.cwd)
133                            .session(session.sessionid.clone())
134                            .hostname(self.hostname.clone());
135                        loader.push(entry.build().into()).await?;
136                    }
137                    None => {
138                        let entry = History::import()
139                            .timestamp(timestamp)
140                            .duration(duration.trunc() as i64)
141                            .command(cmd.inp.trim())
142                            .cwd(cmd.cwd)
143                            .session(session.sessionid.clone())
144                            .hostname(self.hostname.clone());
145                        loader.push(entry.build().into()).await?;
146                    }
147                }
148            }
149        }
150        Ok(())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use time::macros::datetime;
157
158    use super::*;
159
160    use crate::history::History;
161    use crate::import::tests::TestLoader;
162
163    #[test]
164    fn test_hist_dir_xonsh() {
165        let hist_dir = xonsh_hist_dir(Some("/home/user/xonsh_data".to_string())).unwrap();
166        assert_eq!(
167            hist_dir,
168            PathBuf::from("/home/user/xonsh_data/history_json")
169        );
170    }
171
172    #[tokio::test]
173    async fn test_import() {
174        let dir = PathBuf::from("tests/data/xonsh");
175        let sessions = load_sessions(&dir).unwrap();
176        let hostname = "box:user".to_string();
177        let xonsh = Xonsh { sessions, hostname };
178
179        let mut loader = TestLoader::default();
180        xonsh.load(&mut loader).await.unwrap();
181        // order in buf will depend on filenames, so sort by timestamp for consistency
182        loader.buf.sort_by_key(|h| h.timestamp);
183        for (actual, expected) in loader.buf.iter().zip(expected_hist_entries().iter()) {
184            assert_eq!(actual.timestamp, expected.timestamp);
185            assert_eq!(actual.command, expected.command);
186            assert_eq!(actual.cwd, expected.cwd);
187            assert_eq!(actual.exit, expected.exit);
188            assert_eq!(actual.duration, expected.duration);
189            assert_eq!(actual.hostname, expected.hostname);
190        }
191    }
192
193    fn expected_hist_entries() -> [History; 4] {
194        [
195            History::import()
196                .timestamp(datetime!(2024-02-6 04:17:59.478272256 +00:00:00))
197                .command("echo hello world!".to_string())
198                .cwd("/home/user/Documents/code/atuin".to_string())
199                .exit(0)
200                .duration(4651069)
201                .hostname("box:user".to_string())
202                .build()
203                .into(),
204            History::import()
205                .timestamp(datetime!(2024-02-06 04:18:01.70632832 +00:00:00))
206                .command("ls -l".to_string())
207                .cwd("/home/user/Documents/code/atuin".to_string())
208                .exit(0)
209                .duration(21288633)
210                .hostname("box:user".to_string())
211                .build()
212                .into(),
213            History::import()
214                .timestamp(datetime!(2024-02-06 17:41:31.142515968 +00:00:00))
215                .command("false".to_string())
216                .cwd("/home/user/Documents/code/atuin/atuin-client".to_string())
217                .exit(1)
218                .duration(10269403)
219                .hostname("box:user".to_string())
220                .build()
221                .into(),
222            History::import()
223                .timestamp(datetime!(2024-02-06 17:41:32.271584 +00:00:00))
224                .command("exit".to_string())
225                .cwd("/home/user/Documents/code/atuin/atuin-client".to_string())
226                .exit(0)
227                .duration(4259347)
228                .hostname("box:user".to_string())
229                .build()
230                .into(),
231        ]
232    }
233}