Skip to main content

atuin_dotfiles/
shell.rs

1use eyre::{Result, ensure, eyre};
2use rmp::{decode, encode};
3use serde::Serialize;
4
5use atuin_common::shell::{Shell, ShellError};
6
7use crate::store::AliasStore;
8
9pub mod bash;
10pub mod fish;
11pub mod powershell;
12pub mod xonsh;
13pub mod zsh;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16pub struct Alias {
17    pub name: String,
18    pub value: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct Var {
23    pub name: String,
24    pub value: String,
25
26    // False? This is a _shell var_
27    // True? This is an _env var_
28    pub export: bool,
29}
30
31impl Var {
32    /// Serialize into the given vec
33    /// This is intended to be called by the store
34    pub fn serialize(&self, output: &mut Vec<u8>) -> Result<()> {
35        encode::write_array_len(output, 3)?; // 3 fields
36
37        encode::write_str(output, self.name.as_str())?;
38        encode::write_str(output, self.value.as_str())?;
39        encode::write_bool(output, self.export)?;
40
41        Ok(())
42    }
43
44    pub fn deserialize(bytes: &mut decode::Bytes) -> Result<Self> {
45        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
46            eyre!("{err:?}")
47        }
48
49        let nfields = decode::read_array_len(bytes).map_err(error_report)?;
50
51        ensure!(
52            nfields == 3,
53            "too many entries in v0 dotfiles env create record, got {}, expected {}",
54            nfields,
55            3
56        );
57
58        let bytes = bytes.remaining_slice();
59
60        let (key, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
61        let (value, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
62
63        let mut bytes = decode::Bytes::new(bytes);
64        let export = decode::read_bool(&mut bytes).map_err(error_report)?;
65
66        ensure!(
67            bytes.remaining_slice().is_empty(),
68            "trailing bytes in encoded dotfiles env record, malformed"
69        );
70
71        Ok(Var {
72            name: key.to_owned(),
73            value: value.to_owned(),
74            export,
75        })
76    }
77}
78
79pub fn parse_alias(line: &str) -> Option<Alias> {
80    // consider the fact we might be importing a fish alias
81    // 'alias' output
82    // fish: alias foo bar
83    // posix: foo=bar
84
85    let is_fish = line.split(' ').next().unwrap_or("") == "alias";
86
87    let parts: Vec<&str> = if is_fish {
88        line.split(' ')
89            .enumerate()
90            .filter_map(|(n, i)| if n == 0 { None } else { Some(i) })
91            .collect()
92    } else {
93        line.split('=').collect()
94    };
95
96    if parts.len() <= 1 {
97        return None;
98    }
99
100    let mut parts = parts.iter().map(|s| s.to_string());
101
102    let name = parts.next().unwrap();
103
104    let remaining = if is_fish {
105        parts.collect::<Vec<String>>().join(" ")
106    } else {
107        parts.collect::<Vec<String>>().join("=")
108    };
109
110    Some(Alias {
111        name,
112        value: remaining.trim().to_string(),
113    })
114}
115
116pub fn existing_aliases(shell: Option<Shell>) -> Result<Vec<Alias>, ShellError> {
117    let shell = if let Some(shell) = shell {
118        shell
119    } else {
120        Shell::current()
121    };
122
123    // this only supports posix-y shells atm
124    if !shell.is_posixish() {
125        return Err(ShellError::NotSupported);
126    }
127
128    // This will return a list of aliases, each on its own line
129    // They will be in the form foo=bar
130    let aliases = shell.run_interactive(["alias"])?;
131
132    let aliases: Vec<Alias> = aliases.lines().filter_map(parse_alias).collect();
133
134    Ok(aliases)
135}
136
137/// Import aliases from the current shell
138/// This will not import aliases already in the store
139/// Returns aliases that were set
140pub async fn import_aliases(store: &AliasStore) -> Result<Vec<Alias>> {
141    let shell_aliases = existing_aliases(None)?;
142    let store_aliases = store.aliases().await?;
143
144    let mut res = Vec::new();
145
146    for alias in shell_aliases {
147        // O(n), but n is small, and imports infrequent
148        // can always make a map
149        if store_aliases.contains(&alias) {
150            continue;
151        }
152
153        res.push(alias.clone());
154        store.set(&alias.name, &alias.value).await?;
155    }
156
157    Ok(res)
158}
159
160#[cfg(test)]
161mod tests {
162    use crate::shell::{Alias, parse_alias};
163
164    #[test]
165    fn test_parse_simple_alias() {
166        let alias = super::parse_alias("foo=bar").expect("failed to parse alias");
167        assert_eq!(alias.name, "foo");
168        assert_eq!(alias.value, "bar");
169    }
170
171    #[test]
172    fn test_parse_quoted_alias() {
173        let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw'")
174            .expect("failed to parse alias");
175
176        assert_eq!(alias.name, "emacs");
177        assert_eq!(alias.value, "'TERM=xterm-24bits emacs -nw'");
178
179        let git_alias = super::parse_alias("gwip='git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \"--wip-- [skip ci]\"'").expect("failed to parse alias");
180        assert_eq!(git_alias.name, "gwip");
181        assert_eq!(
182            git_alias.value,
183            "'git add -A; git rm $(git ls-files --deleted) 2> /dev/null; git commit --no-verify --no-gpg-sign --message \"--wip-- [skip ci]\"'"
184        );
185    }
186
187    #[test]
188    fn test_parse_quoted_alias_equals() {
189        let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw --foo=bar'")
190            .expect("failed to parse alias");
191        assert_eq!(alias.name, "emacs");
192        assert_eq!(alias.value, "'TERM=xterm-24bits emacs -nw --foo=bar'");
193    }
194
195    #[test]
196    fn test_parse_fish() {
197        let alias = super::parse_alias("alias foo bar").expect("failed to parse alias");
198        assert_eq!(alias.name, "foo");
199        assert_eq!(alias.value, "bar");
200
201        let alias =
202            super::parse_alias("alias x 'exa --icons --git --classify --group-directories-first'")
203                .expect("failed to parse alias");
204
205        assert_eq!(alias.name, "x");
206        assert_eq!(
207            alias.value,
208            "'exa --icons --git --classify --group-directories-first'"
209        );
210    }
211
212    #[test]
213    fn test_parse_with_fortune() {
214        // Because we run the alias command in an interactive subshell
215        // there may be other output.
216        // Ensure that the parser can handle it
217        // Annoyingly not all aliases are picked up all the time if we use
218        // a non-interactive subshell. Boo.
219        let shell = "
220/ In a consumer society there are     \\
221| inevitably two kinds of slaves: the |
222| prisoners of addiction and the      |
223\\ prisoners of envy.                  /
224 -------------------------------------
225        \\   ^__^
226         \\  (oo)\\_______
227            (__)\\       )\\/\\
228                ||----w |
229                ||     ||
230emacs='TERM=xterm-24bits emacs -nw --foo=bar'
231k=kubectl
232";
233
234        let aliases: Vec<Alias> = shell.lines().filter_map(parse_alias).collect();
235        assert_eq!(aliases[0].name, "emacs");
236        assert_eq!(aliases[0].value, "'TERM=xterm-24bits emacs -nw --foo=bar'");
237
238        assert_eq!(aliases[1].name, "k");
239        assert_eq!(aliases[1].value, "kubectl");
240    }
241}