Skip to main content

atuin_client/import/
powershell.rs

1use async_trait::async_trait;
2use directories::BaseDirs;
3use eyre::{Result, eyre};
4use std::path::PathBuf;
5use time::{Duration, OffsetDateTime};
6
7use super::{Importer, Loader, count_lines, unix_byte_lines};
8use crate::history::History;
9use crate::import::read_to_end;
10
11#[derive(Debug)]
12pub struct PowerShell {
13    bytes: Vec<u8>,
14    line_count: Option<usize>,
15}
16
17fn get_history_path() -> Result<PathBuf> {
18    let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?;
19
20    // The command line history in PowerShell is maintained by the PSReadLine module:
21    // https://learn.microsoft.com/en-us/powershell/module/psreadline/about/about_psreadline#command-history
22    //
23    // > PSReadLine maintains a history file containing all the commands and data you've entered from the command line.
24    // > The history files are a file named `$($Host.Name)_history.txt`.
25    // > On Windows systems the history file is stored at `$Env:APPDATA\Microsoft\Windows\PowerShell\PSReadLine`.
26    // > On non-Windows systems, the history files are stored at `$Env:XDG_DATA_HOME/powershell/PSReadLine`
27    // > or `$Env:HOME/.local/share/powershell/PSReadLine`.
28
29    let dir = if cfg!(windows) {
30        base.data_dir()
31            .join("Microsoft")
32            .join("Windows")
33            .join("PowerShell")
34            .join("PSReadLine")
35    } else {
36        std::env::var("XDG_DATA_HOME")
37            .map_or_else(
38                |_| base.home_dir().join(".local").join("share"),
39                PathBuf::from,
40            )
41            .join("powershell")
42            .join("PSReadLine")
43    };
44
45    // The history is stored in a file named `$($Host.Name)_history.txt`.
46    // For the default console host shipped by Microsoft,`$Host.Name` is `ConsoleHost`:
47    // https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.host.pshost.name#remarks
48
49    let file = dir.join("ConsoleHost_history.txt");
50
51    if file.is_file() {
52        Ok(file)
53    } else {
54        Err(eyre!("Could not find history file: {}", file.display()))
55    }
56}
57
58#[async_trait]
59impl Importer for PowerShell {
60    const NAME: &'static str = "PowerShell";
61
62    async fn new() -> Result<Self> {
63        let bytes = read_to_end(get_history_path()?)?;
64        Ok(Self {
65            bytes,
66            line_count: None,
67        })
68    }
69
70    async fn entries(&mut self) -> Result<usize> {
71        // Commands can be split over multiple lines,
72        // but this is only used for a progress bar, and multi-line commands
73        // should be quite rare, so this is not an issue in practice.
74        if self.line_count.is_none() {
75            self.line_count = Some(count_lines(&self.bytes));
76        }
77        Ok(self.line_count.unwrap())
78    }
79
80    async fn load(mut self, h: &mut impl Loader) -> Result<()> {
81        let line_count = self.entries().await?;
82        let start = OffsetDateTime::now_utc() - Duration::milliseconds(line_count as i64);
83
84        let mut counter = 0;
85        let mut iter = unix_byte_lines(&self.bytes);
86
87        while let Some(s) = iter.next() {
88            let Ok(s) = read_line(s) else {
89                continue; // We can skip past things like invalid utf8
90            };
91
92            let mut cmd = s.to_string();
93
94            // Multi-line commands end with a backtick, append the following lines.
95            while cmd.ends_with('`') {
96                cmd.pop();
97
98                let Some(next) = iter.next() else {
99                    break;
100                };
101                let Ok(next) = read_line(next) else {
102                    break;
103                };
104
105                cmd.push('\n');
106                cmd.push_str(next);
107            }
108
109            if cmd.is_empty() {
110                continue;
111            }
112
113            let offset = Duration::milliseconds(counter);
114            counter += 1;
115
116            let entry = History::import().timestamp(start + offset).command(cmd);
117            h.push(entry.build().into()).await?;
118        }
119
120        Ok(())
121    }
122}
123
124fn read_line(s: &[u8]) -> Result<&str> {
125    let s = str::from_utf8(s)?;
126
127    // History is stored in CRLF on Windows, normalize the input to LF on all platforms.
128    let s = s.strip_suffix('\r').unwrap_or(s);
129
130    Ok(s)
131}
132
133#[cfg(test)]
134mod test {
135    use super::*;
136    use crate::import::tests::TestLoader;
137    use itertools::assert_equal;
138
139    const INPUT: &str = r#"cargo install atuin
140cargo update
141echo "first line`
142second line`
143`
144last line"
145echo foo
146
147echo bar
148echo baz
149"#;
150
151    const EXPECTED: &[&str] = &[
152        "cargo install atuin",
153        "cargo update",
154        "echo \"first line\nsecond line\n\nlast line\"",
155        "echo foo",
156        "echo bar",
157        "echo baz",
158    ];
159
160    #[tokio::test]
161    async fn test_import() {
162        let loader = import(INPUT).await;
163
164        let actual = loader.buf.iter().map(|h| h.command.clone());
165        let expected = EXPECTED.iter().map(|s| s.to_string());
166
167        assert_equal(actual, expected);
168    }
169
170    #[tokio::test]
171    async fn test_crlf() {
172        let input = INPUT.replace("\n", "\r\n");
173        let loader = import(input.as_str()).await;
174
175        let actual = loader.buf.iter().map(|h| h.command.clone());
176        let expected = EXPECTED.iter().map(|s| s.to_string());
177
178        assert_equal(actual, expected);
179    }
180
181    #[tokio::test]
182    async fn test_timestamps() {
183        let loader = import(INPUT).await;
184
185        let mut prev = loader.buf.first().unwrap().timestamp;
186        for current in loader.buf.iter().skip(1).map(|h| h.timestamp) {
187            assert!(current > prev);
188            prev = current;
189        }
190    }
191
192    async fn import(input: &str) -> TestLoader {
193        let powershell = PowerShell {
194            bytes: input.as_bytes().to_vec(),
195            line_count: None,
196        };
197
198        let mut loader = TestLoader::default();
199        powershell.load(&mut loader).await.unwrap();
200        loader
201    }
202}