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::{Result, eyre};
8use serde::Deserialize;
9use time::OffsetDateTime;
10use uuid::Uuid;
11use uuid::timestamp::{Timestamp, context::NoContext};
12
13use super::{Importer, Loader, get_histdir_path};
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()
70            && ext == Some("json")
71            && let Some(data) = load_session(&p)?
72        {
73            sessions.push(data);
74        }
75    }
76    Ok(sessions)
77}
78
79fn load_session(path: &Path) -> Result<Option<HistoryData>> {
80    let file = File::open(path)?;
81    // empty files are not valid json, so we can't deserialize them
82    if file.metadata()?.len() == 0 {
83        return Ok(None);
84    }
85
86    let mut hist_file: HistoryFile = serde_json::from_reader(file)?;
87
88    // if there are commands in this session, replace the existing UUIDv4
89    // with a UUIDv7 generated from the timestamp of the first command
90    if let Some(cmd) = hist_file.data.cmds.first() {
91        let seconds = cmd.ts.0.trunc() as u64;
92        let nanos = (cmd.ts.0.fract() * 1_000_000_000_f64) as u32;
93        let ts = Timestamp::from_unix(NoContext, seconds, nanos);
94        hist_file.data.sessionid = Uuid::new_v7(ts).to_string();
95    }
96    Ok(Some(hist_file.data))
97}
98
99#[async_trait]
100impl Importer for Xonsh {
101    const NAME: &'static str = "xonsh";
102
103    async fn new() -> Result<Self> {
104        // wrap xonsh-specific path resolver in general one so that it respects $HISTPATH
105        let xonsh_data_dir = env::var("XONSH_DATA_DIR").ok();
106        let hist_dir = get_histdir_path(|| xonsh_hist_dir(xonsh_data_dir))?;
107        let sessions = load_sessions(&hist_dir)?;
108        let hostname = get_host_user();
109        Ok(Xonsh { sessions, hostname })
110    }
111
112    async fn entries(&mut self) -> Result<usize> {
113        let total = self.sessions.iter().map(|s| s.cmds.len()).sum();
114        Ok(total)
115    }
116
117    async fn load(self, loader: &mut impl Loader) -> Result<()> {
118        for session in self.sessions {
119            for cmd in session.cmds {
120                let (start, end) = cmd.ts;
121                let ts_nanos = (start * 1_000_000_000_f64) as i128;
122                let timestamp = OffsetDateTime::from_unix_timestamp_nanos(ts_nanos)?;
123
124                let duration = (end - start) * 1_000_000_000_f64;
125
126                match cmd.rtn {
127                    Some(exit) => {
128                        let entry = History::import()
129                            .timestamp(timestamp)
130                            .duration(duration.trunc() as i64)
131                            .exit(exit)
132                            .command(cmd.inp.trim())
133                            .cwd(cmd.cwd)
134                            .session(session.sessionid.clone())
135                            .hostname(self.hostname.clone());
136                        loader.push(entry.build().into()).await?;
137                    }
138                    None => {
139                        let entry = History::import()
140                            .timestamp(timestamp)
141                            .duration(duration.trunc() as i64)
142                            .command(cmd.inp.trim())
143                            .cwd(cmd.cwd)
144                            .session(session.sessionid.clone())
145                            .hostname(self.hostname.clone());
146                        loader.push(entry.build().into()).await?;
147                    }
148                }
149            }
150        }
151        Ok(())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use time::macros::datetime;
158
159    use super::*;
160
161    use crate::history::History;
162    use crate::import::tests::TestLoader;
163
164    #[test]
165    fn test_hist_dir_xonsh() {
166        let hist_dir = xonsh_hist_dir(Some("/home/user/xonsh_data".to_string())).unwrap();
167        assert_eq!(
168            hist_dir,
169            PathBuf::from("/home/user/xonsh_data/history_json")
170        );
171    }
172
173    #[tokio::test]
174    async fn test_import() {
175        let dir = PathBuf::from("tests/data/xonsh");
176        let sessions = load_sessions(&dir).unwrap();
177        let hostname = "box:user".to_string();
178        let xonsh = Xonsh { sessions, hostname };
179
180        let mut loader = TestLoader::default();
181        xonsh.load(&mut loader).await.unwrap();
182        // order in buf will depend on filenames, so sort by timestamp for consistency
183        loader.buf.sort_by_key(|h| h.timestamp);
184        for (actual, expected) in loader.buf.iter().zip(expected_hist_entries().iter()) {
185            assert_eq!(actual.timestamp, expected.timestamp);
186            assert_eq!(actual.command, expected.command);
187            assert_eq!(actual.cwd, expected.cwd);
188            assert_eq!(actual.exit, expected.exit);
189            assert_eq!(actual.duration, expected.duration);
190            assert_eq!(actual.hostname, expected.hostname);
191        }
192    }
193
194    fn expected_hist_entries() -> [History; 4] {
195        [
196            History::import()
197                .timestamp(datetime!(2024-02-6 04:17:59.478272256 +00:00:00))
198                .command("echo hello world!".to_string())
199                .cwd("/home/user/Documents/code/atuin".to_string())
200                .exit(0)
201                .duration(4651069)
202                .hostname("box:user".to_string())
203                .build()
204                .into(),
205            History::import()
206                .timestamp(datetime!(2024-02-06 04:18:01.70632832 +00:00:00))
207                .command("ls -l".to_string())
208                .cwd("/home/user/Documents/code/atuin".to_string())
209                .exit(0)
210                .duration(21288633)
211                .hostname("box:user".to_string())
212                .build()
213                .into(),
214            History::import()
215                .timestamp(datetime!(2024-02-06 17:41:31.142515968 +00:00:00))
216                .command("false".to_string())
217                .cwd("/home/user/Documents/code/atuin/atuin-client".to_string())
218                .exit(1)
219                .duration(10269403)
220                .hostname("box:user".to_string())
221                .build()
222                .into(),
223            History::import()
224                .timestamp(datetime!(2024-02-06 17:41:32.271584 +00:00:00))
225                .command("exit".to_string())
226                .cwd("/home/user/Documents/code/atuin/atuin-client".to_string())
227                .exit(0)
228                .duration(4259347)
229                .hostname("box:user".to_string())
230                .build()
231                .into(),
232        ]
233    }
234}