atuin_dotfiles/
shell.rs

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