diff options
Diffstat (limited to 'crates/atuin-dotfiles/src')
| -rw-r--r-- | crates/atuin-dotfiles/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell.rs | 241 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/bash.rs | 68 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/fish.rs | 69 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/powershell.rs | 169 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/xonsh.rs | 68 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/shell/zsh.rs | 68 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/store.rs | 421 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/store/alias.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-dotfiles/src/store/var.rs | 542 |
10 files changed, 0 insertions, 1649 deletions
diff --git a/crates/atuin-dotfiles/src/lib.rs b/crates/atuin-dotfiles/src/lib.rs deleted file mode 100644 index 74daf8ef..00000000 --- a/crates/atuin-dotfiles/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod shell; -pub mod store; diff --git a/crates/atuin-dotfiles/src/shell.rs b/crates/atuin-dotfiles/src/shell.rs deleted file mode 100644 index 73a9ce8c..00000000 --- a/crates/atuin-dotfiles/src/shell.rs +++ /dev/null @@ -1,241 +0,0 @@ -use eyre::{Result, ensure, eyre}; -use rmp::{decode, encode}; -use serde::Serialize; - -use atuin_common::shell::{Shell, ShellError}; - -use crate::store::AliasStore; - -pub mod bash; -pub mod fish; -pub mod powershell; -pub mod xonsh; -pub mod zsh; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct Alias { - pub name: String, - pub value: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct Var { - pub name: String, - pub value: String, - - // False? This is a _shell var_ - // True? This is an _env var_ - pub export: bool, -} - -impl Var { - /// Serialize into the given vec - /// This is intended to be called by the store - pub fn serialize(&self, output: &mut Vec<u8>) -> Result<()> { - encode::write_array_len(output, 3)?; // 3 fields - - encode::write_str(output, self.name.as_str())?; - encode::write_str(output, self.value.as_str())?; - encode::write_bool(output, self.export)?; - - Ok(()) - } - - pub fn deserialize(bytes: &mut decode::Bytes) -> Result<Self> { - fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report { - eyre!("{err:?}") - } - - let nfields = decode::read_array_len(bytes).map_err(error_report)?; - - ensure!( - nfields == 3, - "too many entries in v0 dotfiles env create record, got {}, expected {}", - nfields, - 3 - ); - - let bytes = bytes.remaining_slice(); - - let (key, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - let (value, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - - let mut bytes = decode::Bytes::new(bytes); - let export = decode::read_bool(&mut bytes).map_err(error_report)?; - - ensure!( - bytes.remaining_slice().is_empty(), - "trailing bytes in encoded dotfiles env record, malformed" - ); - - Ok(Var { - name: key.to_owned(), - value: value.to_owned(), - export, - }) - } -} - -pub fn parse_alias(line: &str) -> Option<Alias> { - // consider the fact we might be importing a fish alias - // 'alias' output - // fish: alias foo bar - // posix: foo=bar - - let is_fish = line.split(' ').next().unwrap_or("") == "alias"; - - let parts: Vec<&str> = if is_fish { - line.split(' ') - .enumerate() - .filter_map(|(n, i)| if n == 0 { None } else { Some(i) }) - .collect() - } else { - line.split('=').collect() - }; - - if parts.len() <= 1 { - return None; - } - - let mut parts = parts.iter().map(|s| s.to_string()); - - let name = parts.next().unwrap(); - - let remaining = if is_fish { - parts.collect::<Vec<String>>().join(" ") - } else { - parts.collect::<Vec<String>>().join("=") - }; - - Some(Alias { - name, - value: remaining.trim().to_string(), - }) -} - -pub fn existing_aliases(shell: Option<Shell>) -> Result<Vec<Alias>, ShellError> { - let shell = if let Some(shell) = shell { - shell - } else { - Shell::current() - }; - - // this only supports posix-y shells atm - if !shell.is_posixish() { - return Err(ShellError::NotSupported); - } - - // This will return a list of aliases, each on its own line - // They will be in the form foo=bar - let aliases = shell.run_interactive(["alias"])?; - - let aliases: Vec<Alias> = aliases.lines().filter_map(parse_alias).collect(); - - Ok(aliases) -} - -/// Import aliases from the current shell -/// This will not import aliases already in the store -/// Returns aliases that were set -pub async fn import_aliases(store: &AliasStore) -> Result<Vec<Alias>> { - let shell_aliases = existing_aliases(None)?; - let store_aliases = store.aliases().await?; - - let mut res = Vec::new(); - - for alias in shell_aliases { - // O(n), but n is small, and imports infrequent - // can always make a map - if store_aliases.contains(&alias) { - continue; - } - - res.push(alias.clone()); - store.set(&alias.name, &alias.value).await?; - } - - Ok(res) -} - -#[cfg(test)] -mod tests { - use crate::shell::{Alias, parse_alias}; - - #[test] - fn test_parse_simple_alias() { - let alias = super::parse_alias("foo=bar").expect("failed to parse alias"); - assert_eq!(alias.name, "foo"); - assert_eq!(alias.value, "bar"); - } - - #[test] - fn test_parse_quoted_alias() { - let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw'") - .expect("failed to parse alias"); - - assert_eq!(alias.name, "emacs"); - assert_eq!(alias.value, "'TERM=xterm-24bits emacs -nw'"); - - 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"); - assert_eq!(git_alias.name, "gwip"); - 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]\"'" - ); - } - - #[test] - fn test_parse_quoted_alias_equals() { - let alias = super::parse_alias("emacs='TERM=xterm-24bits emacs -nw --foo=bar'") - .expect("failed to parse alias"); - assert_eq!(alias.name, "emacs"); - assert_eq!(alias.value, "'TERM=xterm-24bits emacs -nw --foo=bar'"); - } - - #[test] - fn test_parse_fish() { - let alias = super::parse_alias("alias foo bar").expect("failed to parse alias"); - assert_eq!(alias.name, "foo"); - assert_eq!(alias.value, "bar"); - - let alias = - super::parse_alias("alias x 'exa --icons --git --classify --group-directories-first'") - .expect("failed to parse alias"); - - assert_eq!(alias.name, "x"); - assert_eq!( - alias.value, - "'exa --icons --git --classify --group-directories-first'" - ); - } - - #[test] - fn test_parse_with_fortune() { - // Because we run the alias command in an interactive subshell - // there may be other output. - // Ensure that the parser can handle it - // Annoyingly not all aliases are picked up all the time if we use - // a non-interactive subshell. Boo. - let shell = " -/ In a consumer society there are \\ -| inevitably two kinds of slaves: the | -| prisoners of addiction and the | -\\ prisoners of envy. / - ------------------------------------- - \\ ^__^ - \\ (oo)\\_______ - (__)\\ )\\/\\ - ||----w | - || || -emacs='TERM=xterm-24bits emacs -nw --foo=bar' -k=kubectl -"; - - let aliases: Vec<Alias> = shell.lines().filter_map(parse_alias).collect(); - assert_eq!(aliases[0].name, "emacs"); - assert_eq!(aliases[0].value, "'TERM=xterm-24bits emacs -nw --foo=bar'"); - - assert_eq!(aliases[1].name, "k"); - assert_eq!(aliases[1].value, "kubectl"); - } -} diff --git a/crates/atuin-dotfiles/src/shell/bash.rs b/crates/atuin-dotfiles/src/shell/bash.rs deleted file mode 100644 index 2b9b4c88..00000000 --- a/crates/atuin-dotfiles/src/shell/bash.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::path::PathBuf; - -use crate::store::{AliasStore, var::VarStore}; - -async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(aliases) => aliases, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new aliases on the fly - - store.posix().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) - }) - } - } -} - -async fn cached_vars(path: PathBuf, store: &VarStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(vars) => vars, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new vars on the fly - - store.posix().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) - }) - } - } -} - -/// Return bash dotfile config -/// -/// Do not return an error. We should not prevent the shell from starting. -/// -/// In the worst case, Atuin should not function but the shell should start correctly. -/// -/// While currently this only returns aliases, it will be extended to also return other synced dotfiles -pub async fn alias_config(store: &AliasStore) -> String { - // First try to read the cached config - let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.bash"); - - if aliases.exists() { - return cached_aliases(aliases, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate aliases: {e}'"); - } - - cached_aliases(aliases, store).await -} - -pub async fn var_config(store: &VarStore) -> String { - // First try to read the cached config - let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.bash"); - - if vars.exists() { - return cached_vars(vars, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate vars: {e}'"); - } - - cached_vars(vars, store).await -} diff --git a/crates/atuin-dotfiles/src/shell/fish.rs b/crates/atuin-dotfiles/src/shell/fish.rs deleted file mode 100644 index 6d472f67..00000000 --- a/crates/atuin-dotfiles/src/shell/fish.rs +++ /dev/null @@ -1,69 +0,0 @@ -// Configuration for fish -use std::path::PathBuf; - -use crate::store::{AliasStore, var::VarStore}; - -async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(aliases) => aliases, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new aliases on the fly - - store.posix().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) - }) - } - } -} - -async fn cached_vars(path: PathBuf, store: &VarStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(vars) => vars, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new vars on the fly - - store.posix().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) - }) - } - } -} - -/// Return fish dotfile config -/// -/// Do not return an error. We should not prevent the shell from starting. -/// -/// In the worst case, Atuin should not function but the shell should start correctly. -/// -/// While currently this only returns aliases, it will be extended to also return other synced dotfiles -pub async fn alias_config(store: &AliasStore) -> String { - // First try to read the cached config - let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.fish"); - - if aliases.exists() { - return cached_aliases(aliases, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate aliases: {e}'"); - } - - cached_aliases(aliases, store).await -} - -pub async fn var_config(store: &VarStore) -> String { - // First try to read the cached config - let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.fish"); - - if vars.exists() { - return cached_vars(vars, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate vars: {e}'"); - } - - cached_vars(vars, store).await -} diff --git a/crates/atuin-dotfiles/src/shell/powershell.rs b/crates/atuin-dotfiles/src/shell/powershell.rs deleted file mode 100644 index 1daee28b..00000000 --- a/crates/atuin-dotfiles/src/shell/powershell.rs +++ /dev/null @@ -1,169 +0,0 @@ -use crate::shell::{Alias, Var}; -use crate::store::{AliasStore, var::VarStore}; -use std::path::PathBuf; - -async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(aliases) => aliases, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new aliases on the fly - - store.powershell().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) - }) - } - } -} - -async fn cached_vars(path: PathBuf, store: &VarStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(vars) => vars, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new vars on the fly - - store.powershell().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) - }) - } - } -} - -/// Return powershell dotfile config -/// -/// Do not return an error. We should not prevent the shell from starting. -/// -/// In the worst case, Atuin should not function but the shell should start correctly. -/// -/// While currently this only returns aliases, it will be extended to also return other synced dotfiles -pub async fn alias_config(store: &AliasStore) -> String { - // First try to read the cached config - let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.ps1"); - - if aliases.exists() { - return cached_aliases(aliases, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate aliases: {e}'"); - } - - cached_aliases(aliases, store).await -} - -pub async fn var_config(store: &VarStore) -> String { - // First try to read the cached config - let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.ps1"); - - if vars.exists() { - return cached_vars(vars, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate vars: {e}'"); - } - - cached_vars(vars, store).await -} - -pub fn format_alias(alias: &Alias) -> String { - // Set-Alias doesn't support adding implicit arguments, so use a function. - // See https://github.com/PowerShell/PowerShell/issues/12962 - - let mut result = secure_command(&format!( - "function {} {{\n {}{} @args\n}}", - alias.name, - if alias.value.starts_with(['"', '\'']) { - "& " - } else { - "" - }, - alias.value - )); - - // This makes the file layout prettier - result.insert(0, '\n'); - result -} - -pub fn format_var(var: &Var) -> String { - secure_command(&format!( - "${}{} = '{}'", - if var.export { "env:" } else { "" }, - var.name, - var.value.replace("'", "''") - )) -} - -/// Wraps the given command in an Invoke-Expression to ensure the outer script is not halted -/// if the inner command contains a syntax error. -fn secure_command(command: &str) -> String { - format!( - "Invoke-Expression -ErrorAction Continue -Command '{}'\n", - command.replace("'", "''") - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn aliases() { - assert_eq!( - format_alias(&Alias { - name: "gp".to_string(), - value: "git push".to_string(), - }), - "\n".to_string() - + &secure_command( - "function gp { - git push @args -}" - ) - ); - - assert_eq!( - format_alias(&Alias { - name: "spc".to_string(), - value: "\"path with spaces\" arg".to_string(), - }), - "\n".to_string() - + &secure_command( - "function spc { - & \"path with spaces\" arg @args -}" - ) - ); - } - - #[test] - fn vars() { - assert_eq!( - format_var(&Var { - name: "FOO".to_owned(), - value: "bar 'baz'".to_owned(), - export: true, - }), - secure_command("$env:FOO = 'bar ''baz'''") - ); - - assert_eq!( - format_var(&Var { - name: "TEST".to_owned(), - value: "1".to_owned(), - export: false, - }), - secure_command("$TEST = '1'") - ); - } - - #[test] - fn invoke_expression() { - assert_eq!( - secure_command("echo 'foo'"), - "Invoke-Expression -ErrorAction Continue -Command 'echo ''foo'''\n" - ) - } -} diff --git a/crates/atuin-dotfiles/src/shell/xonsh.rs b/crates/atuin-dotfiles/src/shell/xonsh.rs deleted file mode 100644 index 1e56fc1d..00000000 --- a/crates/atuin-dotfiles/src/shell/xonsh.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::path::PathBuf; - -use crate::store::{AliasStore, var::VarStore}; - -async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(aliases) => aliases, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new aliases on the fly - - store.xonsh().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) - }) - } - } -} - -async fn cached_vars(path: PathBuf, store: &VarStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(vars) => vars, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new vars on the fly - - store.xonsh().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate vars: \n{r}\n{e}'",) - }) - } - } -} - -/// Return xonsh dotfile config -/// -/// Do not return an error. We should not prevent the shell from starting. -/// -/// In the worst case, Atuin should not function but the shell should start correctly. -/// -/// While currently this only returns aliases, it will be extended to also return other synced dotfiles -pub async fn alias_config(store: &AliasStore) -> String { - // First try to read the cached config - let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.xsh"); - - if aliases.exists() { - return cached_aliases(aliases, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate aliases: {e}'"); - } - - cached_aliases(aliases, store).await -} - -pub async fn var_config(store: &VarStore) -> String { - // First try to read the cached config - let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.xsh"); - - if vars.exists() { - return cached_vars(vars, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate vars: {e}'"); - } - - cached_vars(vars, store).await -} diff --git a/crates/atuin-dotfiles/src/shell/zsh.rs b/crates/atuin-dotfiles/src/shell/zsh.rs deleted file mode 100644 index 117e9403..00000000 --- a/crates/atuin-dotfiles/src/shell/zsh.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::path::PathBuf; - -use crate::store::{AliasStore, var::VarStore}; - -async fn cached_aliases(path: PathBuf, store: &AliasStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(aliases) => aliases, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new aliases on the fly - - store.posix().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) - }) - } - } -} - -async fn cached_vars(path: PathBuf, store: &VarStore) -> String { - match tokio::fs::read_to_string(path).await { - Ok(aliases) => aliases, - Err(r) => { - // we failed to read the file for some reason, but the file does exist - // fallback to generating new vars on the fly - - store.posix().await.unwrap_or_else(|e| { - format!("echo 'Atuin: failed to read and generate aliases: \n{r}\n{e}'",) - }) - } - } -} - -/// Return zsh dotfile config -/// -/// Do not return an error. We should not prevent the shell from starting. -/// -/// In the worst case, Atuin should not function but the shell should start correctly. -/// -/// While currently this only returns aliases, it will be extended to also return other synced dotfiles -pub async fn alias_config(store: &AliasStore) -> String { - // First try to read the cached config - let aliases = atuin_common::utils::dotfiles_cache_dir().join("aliases.zsh"); - - if aliases.exists() { - return cached_aliases(aliases, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate aliases: {e}'"); - } - - cached_aliases(aliases, store).await -} - -pub async fn var_config(store: &VarStore) -> String { - // First try to read the cached config - let vars = atuin_common::utils::dotfiles_cache_dir().join("vars.zsh"); - - if vars.exists() { - return cached_vars(vars, store).await; - } - - if let Err(e) = store.build().await { - return format!("echo 'Atuin: failed to generate aliases: {e}'"); - } - - cached_vars(vars, store).await -} diff --git a/crates/atuin-dotfiles/src/store.rs b/crates/atuin-dotfiles/src/store.rs deleted file mode 100644 index 17597065..00000000 --- a/crates/atuin-dotfiles/src/store.rs +++ /dev/null @@ -1,421 +0,0 @@ -use std::collections::BTreeMap; - -use atuin_client::record::sqlite_store::SqliteStore; -// Sync aliases -// This will be noticeable similar to the kv store, though I expect the two shall diverge -// While we will support a range of shell config, I'd rather have a larger number of small records -// + stores, rather than one mega config store. -use atuin_common::record::{DecryptedData, Host, HostId}; -use atuin_common::utils::unquote; -use eyre::{Result, bail, ensure, eyre}; - -use atuin_client::record::encryption::PASETO_V4; -use atuin_client::record::store::Store; - -use crate::shell::Alias; - -const CONFIG_SHELL_ALIAS_VERSION: &str = "v0"; -const CONFIG_SHELL_ALIAS_TAG: &str = "config-shell-alias"; -const CONFIG_SHELL_ALIAS_FIELD_MAX_LEN: usize = 20000; // 20kb max total len, way more than should be needed. - -mod alias; -pub mod var; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AliasRecord { - Create(Alias), // create a full record - Delete(String), // delete by name -} - -impl AliasRecord { - pub fn serialize(&self) -> Result<DecryptedData> { - use rmp::encode; - - let mut output = vec![]; - - match self { - AliasRecord::Create(alias) => { - encode::write_u8(&mut output, 0)?; // create - encode::write_array_len(&mut output, 2)?; // 2 fields - - encode::write_str(&mut output, alias.name.as_str())?; - encode::write_str(&mut output, alias.value.as_str())?; - } - AliasRecord::Delete(name) => { - encode::write_u8(&mut output, 1)?; // delete - encode::write_array_len(&mut output, 1)?; // 1 field - - encode::write_str(&mut output, name.as_str())?; - } - } - - Ok(DecryptedData(output)) - } - - pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> { - use rmp::decode; - - fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report { - eyre!("{err:?}") - } - - match version { - CONFIG_SHELL_ALIAS_VERSION => { - let mut bytes = decode::Bytes::new(&data.0); - - let record_type = decode::read_u8(&mut bytes).map_err(error_report)?; - - match record_type { - // create - 0 => { - let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; - ensure!( - nfields == 2, - "too many entries in v0 shell alias create record" - ); - - let bytes = bytes.remaining_slice(); - - let (key, bytes) = - decode::read_str_from_slice(bytes).map_err(error_report)?; - let (value, bytes) = - decode::read_str_from_slice(bytes).map_err(error_report)?; - - if !bytes.is_empty() { - bail!("trailing bytes in encoded shell alias record. malformed") - } - - Ok(AliasRecord::Create(Alias { - name: key.to_owned(), - value: value.to_owned(), - })) - } - - // delete - 1 => { - let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; - ensure!( - nfields == 1, - "too many entries in v0 shell alias delete record" - ); - - let bytes = bytes.remaining_slice(); - - let (key, bytes) = - decode::read_str_from_slice(bytes).map_err(error_report)?; - - if !bytes.is_empty() { - bail!("trailing bytes in encoded shell alias record. malformed") - } - - Ok(AliasRecord::Delete(key.to_owned())) - } - - n => { - bail!("unknown AliasRecord type {n}") - } - } - } - _ => { - bail!("unknown version {version:?}") - } - } - } -} - -#[derive(Debug, Clone)] -pub struct AliasStore { - pub store: SqliteStore, - pub host_id: HostId, - pub encryption_key: [u8; 32], -} - -impl AliasStore { - // will want to init the actual kv store when that is done - pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> AliasStore { - AliasStore { - store, - host_id, - encryption_key, - } - } - - pub async fn posix(&self) -> Result<String> { - let aliases = self.aliases().await?; - Ok(Self::format_posix(&aliases)) - } - - pub async fn xonsh(&self) -> Result<String> { - let aliases = self.aliases().await?; - Ok(Self::format_xonsh(&aliases)) - } - - pub async fn powershell(&self) -> Result<String> { - let aliases = self.aliases().await?; - Ok(Self::format_powershell(&aliases)) - } - - fn format_posix(aliases: &[Alias]) -> String { - let mut config = String::new(); - - for alias in aliases { - // If it's quoted, remove the quotes. If it's not quoted, do nothing. - let value = unquote(alias.value.as_str()).unwrap_or(alias.value.clone()); - - // we're about to quote it ourselves anyway! - config.push_str(&format!("alias {}='{}'\n", alias.name, value)); - } - - config - } - - fn format_xonsh(aliases: &[Alias]) -> String { - let mut config = String::new(); - - for alias in aliases { - config.push_str(&format!("aliases['{}'] ='{}'\n", alias.name, alias.value)); - } - - config - } - - fn format_powershell(aliases: &[Alias]) -> String { - let mut config = String::new(); - - for alias in aliases { - config.push_str(&crate::shell::powershell::format_alias(alias)); - } - - config - } - - pub async fn build(&self) -> Result<()> { - let dir = atuin_common::utils::dotfiles_cache_dir(); - tokio::fs::create_dir_all(dir.clone()).await?; - - let aliases = self.aliases().await?; - - // Build for all supported shells - let posix = Self::format_posix(&aliases); - let xonsh = Self::format_xonsh(&aliases); - let powershell = Self::format_powershell(&aliases); - - // All the same contents, maybe optimize in the future or perhaps there will be quirks - // per-shell - // I'd prefer separation atm - let zsh = dir.join("aliases.zsh"); - let bash = dir.join("aliases.bash"); - let fish = dir.join("aliases.fish"); - let xsh = dir.join("aliases.xsh"); - let ps1 = dir.join("aliases.ps1"); - - tokio::fs::write(zsh, &posix).await?; - tokio::fs::write(bash, &posix).await?; - tokio::fs::write(fish, &posix).await?; - tokio::fs::write(xsh, &xonsh).await?; - tokio::fs::write(ps1, &powershell).await?; - - Ok(()) - } - - pub async fn set(&self, name: &str, value: &str) -> Result<()> { - if name.len() + value.len() > CONFIG_SHELL_ALIAS_FIELD_MAX_LEN { - return Err(eyre!( - "alias record too large: max len {} bytes", - CONFIG_SHELL_ALIAS_FIELD_MAX_LEN - )); - } - - let record = AliasRecord::Create(Alias { - name: name.to_string(), - value: value.to_string(), - }); - - let bytes = record.serialize()?; - - let idx = self - .store - .last(self.host_id, CONFIG_SHELL_ALIAS_TAG) - .await? - .map_or(0, |entry| entry.idx + 1); - - let record = atuin_common::record::Record::builder() - .host(Host::new(self.host_id)) - .version(CONFIG_SHELL_ALIAS_VERSION.to_string()) - .tag(CONFIG_SHELL_ALIAS_TAG.to_string()) - .idx(idx) - .data(bytes) - .build(); - - self.store - .push(&record.encrypt::<PASETO_V4>(&self.encryption_key)) - .await?; - - // set mutates shell config, so build again - self.build().await?; - - Ok(()) - } - - pub async fn delete(&self, name: &str) -> Result<()> { - if name.len() > CONFIG_SHELL_ALIAS_FIELD_MAX_LEN { - return Err(eyre!( - "alias record too large: max len {} bytes", - CONFIG_SHELL_ALIAS_FIELD_MAX_LEN - )); - } - - let record = AliasRecord::Delete(name.to_string()); - - let bytes = record.serialize()?; - - let idx = self - .store - .last(self.host_id, CONFIG_SHELL_ALIAS_TAG) - .await? - .map_or(0, |entry| entry.idx + 1); - - let record = atuin_common::record::Record::builder() - .host(Host::new(self.host_id)) - .version(CONFIG_SHELL_ALIAS_VERSION.to_string()) - .tag(CONFIG_SHELL_ALIAS_TAG.to_string()) - .idx(idx) - .data(bytes) - .build(); - - self.store - .push(&record.encrypt::<PASETO_V4>(&self.encryption_key)) - .await?; - - // delete mutates shell config, so build again - self.build().await?; - - Ok(()) - } - - pub async fn aliases(&self) -> Result<Vec<Alias>> { - let mut build = BTreeMap::new(); - - // this is sorted, oldest to newest - let tagged = self.store.all_tagged(CONFIG_SHELL_ALIAS_TAG).await?; - - for record in tagged { - let version = record.version.clone(); - - let decrypted = match version.as_str() { - CONFIG_SHELL_ALIAS_VERSION => record.decrypt::<PASETO_V4>(&self.encryption_key)?, - version => bail!("unknown version {version:?}"), - }; - - let ar = AliasRecord::deserialize(&decrypted.data, version.as_str())?; - - match ar { - AliasRecord::Create(a) => { - build.insert(a.name.clone(), a); - } - AliasRecord::Delete(d) => { - build.remove(&d); - } - } - } - - Ok(build.into_values().collect()) - } -} - -#[cfg(test)] -pub(crate) fn test_local_timeout() -> f64 { - std::env::var("ATUIN_TEST_LOCAL_TIMEOUT") - .ok() - .and_then(|x| x.parse().ok()) - // this hardcoded value should be replaced by a simple way to get the - // default local_timeout of Settings if possible - .unwrap_or(2.0) -} - -#[cfg(test)] -mod tests { - use rand::rngs::OsRng; - - use atuin_client::record::sqlite_store::SqliteStore; - - use crate::shell::Alias; - - use super::{AliasRecord, AliasStore, CONFIG_SHELL_ALIAS_VERSION, test_local_timeout}; - use crypto_secretbox::{KeyInit, XSalsa20Poly1305}; - - #[test] - fn encode_decode() { - let record = Alias { - name: "k".to_owned(), - value: "kubectl".to_owned(), - }; - let record = AliasRecord::Create(record); - - let snapshot = [204, 0, 146, 161, 107, 167, 107, 117, 98, 101, 99, 116, 108]; - - let encoded = record.serialize().unwrap(); - let decoded = AliasRecord::deserialize(&encoded, CONFIG_SHELL_ALIAS_VERSION).unwrap(); - - assert_eq!(encoded.0, &snapshot); - assert_eq!(decoded, record); - } - - #[tokio::test] - async fn build_aliases() { - let store = SqliteStore::new(":memory:", test_local_timeout()) - .await - .unwrap(); - let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into(); - let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7()); - - let alias = AliasStore::new(store, host_id, key); - - alias.set("k", "kubectl").await.unwrap(); - alias.set("gp", "git push").await.unwrap(); - alias - .set("kgap", "'kubectl get pods --all-namespaces'") - .await - .unwrap(); - - let mut aliases = alias.aliases().await.unwrap(); - - aliases.sort_by_key(|a| a.name.clone()); - - assert_eq!(aliases.len(), 3); - - assert_eq!( - aliases[0], - Alias { - name: String::from("gp"), - value: String::from("git push") - } - ); - - assert_eq!( - aliases[1], - Alias { - name: String::from("k"), - value: String::from("kubectl") - } - ); - - assert_eq!( - aliases[2], - Alias { - name: String::from("kgap"), - value: String::from("'kubectl get pods --all-namespaces'") - } - ); - - let build = alias.posix().await.expect("failed to build aliases"); - - assert_eq!( - build, - "alias gp='git push' -alias k='kubectl' -alias kgap='kubectl get pods --all-namespaces' -" - ) - } -} diff --git a/crates/atuin-dotfiles/src/store/alias.rs b/crates/atuin-dotfiles/src/store/alias.rs deleted file mode 100644 index 8b137891..00000000 --- a/crates/atuin-dotfiles/src/store/alias.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/atuin-dotfiles/src/store/var.rs b/crates/atuin-dotfiles/src/store/var.rs deleted file mode 100644 index 9d25b85d..00000000 --- a/crates/atuin-dotfiles/src/store/var.rs +++ /dev/null @@ -1,542 +0,0 @@ -/// Store for shell vars -/// I should abstract this and reuse code between the alias/env stores -/// This is easier for now -/// Once I have two implementations, building a common base is much easier. -use std::collections::BTreeMap; - -use atuin_client::record::sqlite_store::SqliteStore; -use atuin_common::record::{DecryptedData, Host, HostId}; -use eyre::{Result, bail, ensure, eyre}; - -use atuin_client::record::encryption::PASETO_V4; -use atuin_client::record::store::Store; - -use crate::shell::Var; - -const DOTFILES_VAR_VERSION: &str = "v0"; -const DOTFILES_VAR_TAG: &str = "dotfiles-var"; -const DOTFILES_VAR_LEN: usize = 20000; // 20kb max total len, way more than should be needed. - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VarRecord { - Create(Var), // create a full record - Delete(String), // delete by name -} - -impl VarRecord { - pub fn serialize(&self) -> Result<DecryptedData> { - use rmp::encode; - - let mut output = vec![]; - - match self { - VarRecord::Create(env) => { - encode::write_u8(&mut output, 0)?; // create - - env.serialize(&mut output)?; - } - VarRecord::Delete(env) => { - encode::write_u8(&mut output, 1)?; // delete - encode::write_array_len(&mut output, 1)?; // 1 field - - encode::write_str(&mut output, env.as_str())?; - } - } - - Ok(DecryptedData(output)) - } - - pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> { - use rmp::decode; - - fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report { - eyre!("{err:?}") - } - - match version { - DOTFILES_VAR_VERSION => { - let mut bytes = decode::Bytes::new(&data.0); - - let record_type = decode::read_u8(&mut bytes).map_err(error_report)?; - - match record_type { - // create - 0 => { - let env = Var::deserialize(&mut bytes)?; - Ok(VarRecord::Create(env)) - } - - // delete - 1 => { - let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; - ensure!( - nfields == 1, - "too many entries in v0 dotfiles var delete record" - ); - - let bytes = bytes.remaining_slice(); - - let (key, bytes) = - decode::read_str_from_slice(bytes).map_err(error_report)?; - - if !bytes.is_empty() { - bail!("trailing bytes in encoded dotfiles var record. malformed") - } - - Ok(VarRecord::Delete(key.to_owned())) - } - - n => { - bail!("unknown Dotfiles var record type {n}") - } - } - } - _ => { - bail!("unknown version {version:?}") - } - } - } -} - -#[derive(Debug, Clone)] -pub struct VarStore { - pub store: SqliteStore, - pub host_id: HostId, - pub encryption_key: [u8; 32], -} - -impl VarStore { - // will want to init the actual kv store when that is done - pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> VarStore { - VarStore { - store, - host_id, - encryption_key, - } - } - - /// Escape a value for use in POSIX shells (bash, zsh) - /// This adds double quotes around the value and escapes any embedded double quotes - fn escape_posix_value(value: &str) -> String { - // If the value contains no special characters, we can use it unquoted - if value - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.') - { - value.to_string() - } else { - // Otherwise, wrap in double quotes and escape any special characters - format!( - "\"{}\"", - value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('$', "\\$") - .replace('`', "\\`") - ) - } - } - - /// Escape a value for use in fish shell - /// Fish uses single quotes for literal strings, but we need to handle embedded single quotes - fn escape_fish_value(value: &str) -> String { - // If the value contains no special characters, we can use it unquoted - if value - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.') - { - value.to_string() - } else { - // Use single quotes and escape any embedded single quotes - format!("'{}'", value.replace('\'', "\\'")) - } - } - - /// Escape a value for use in xonsh - /// Xonsh uses Python-style string literals - fn escape_xonsh_value(value: &str) -> String { - // If the value contains no special characters, we can use it unquoted - if value - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.') - { - value.to_string() - } else { - // Use double quotes and escape appropriately for Python strings - format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) - } - } - - pub async fn xonsh(&self) -> Result<String> { - let env = self.vars().await?; - Ok(Self::format_xonsh(&env)) - } - - pub async fn fish(&self) -> Result<String> { - let env = self.vars().await?; - Ok(Self::format_fish(&env)) - } - - pub async fn posix(&self) -> Result<String> { - let env = self.vars().await?; - Ok(Self::format_posix(&env)) - } - - pub async fn powershell(&self) -> Result<String> { - let env = self.vars().await?; - Ok(Self::format_powershell(&env)) - } - - fn format_xonsh(env: &[Var]) -> String { - let mut config = String::new(); - - for env in env { - let escaped_value = Self::escape_xonsh_value(&env.value); - config.push_str(&format!("${}={}\n", env.name, escaped_value)); - } - - config - } - - fn format_fish(env: &[Var]) -> String { - let mut config = String::new(); - - for env in env { - let escaped_value = Self::escape_fish_value(&env.value); - config.push_str(&format!("set -gx {} {}\n", env.name, escaped_value)); - } - - config - } - - fn format_posix(env: &[Var]) -> String { - let mut config = String::new(); - - for env in env { - let escaped_value = Self::escape_posix_value(&env.value); - if env.export { - config.push_str(&format!("export {}={}\n", env.name, escaped_value)); - } else { - config.push_str(&format!("{}={}\n", env.name, escaped_value)); - } - } - - config - } - - fn format_powershell(env: &[Var]) -> String { - let mut config = String::new(); - - for var in env { - config.push_str(&crate::shell::powershell::format_var(var)); - } - - config - } - - pub async fn build(&self) -> Result<()> { - let dir = atuin_common::utils::dotfiles_cache_dir(); - tokio::fs::create_dir_all(dir.clone()).await?; - - let env = self.vars().await?; - - // Build for all supported shells - let posix = Self::format_posix(&env); - let xonsh = Self::format_xonsh(&env); - let fsh = Self::format_fish(&env); - let powershell = Self::format_powershell(&env); - - // All the same contents, maybe optimize in the future or perhaps there will be quirks - // per-shell - // I'd prefer separation atm - let zsh = dir.join("vars.zsh"); - let bash = dir.join("vars.bash"); - let fish = dir.join("vars.fish"); - let xsh = dir.join("vars.xsh"); - let ps1 = dir.join("vars.ps1"); - - tokio::fs::write(zsh, &posix).await?; - tokio::fs::write(bash, &posix).await?; - tokio::fs::write(fish, &fsh).await?; - tokio::fs::write(xsh, &xonsh).await?; - tokio::fs::write(ps1, &powershell).await?; - - Ok(()) - } - - pub async fn set(&self, name: &str, value: &str, export: bool) -> Result<()> { - if name.len() + value.len() > DOTFILES_VAR_LEN { - return Err(eyre!( - "var record too large: max len {} bytes", - DOTFILES_VAR_LEN - )); - } - - let record = VarRecord::Create(Var { - name: name.to_string(), - value: value.to_string(), - export, - }); - - let bytes = record.serialize()?; - - let idx = self - .store - .last(self.host_id, DOTFILES_VAR_TAG) - .await? - .map_or(0, |entry| entry.idx + 1); - - let record = atuin_common::record::Record::builder() - .host(Host::new(self.host_id)) - .version(DOTFILES_VAR_VERSION.to_string()) - .tag(DOTFILES_VAR_TAG.to_string()) - .idx(idx) - .data(bytes) - .build(); - - self.store - .push(&record.encrypt::<PASETO_V4>(&self.encryption_key)) - .await?; - - // set mutates shell config, so build again - self.build().await?; - - Ok(()) - } - - pub async fn delete(&self, name: &str) -> Result<()> { - if name.len() > DOTFILES_VAR_LEN { - return Err(eyre!( - "var record too large: max len {} bytes", - DOTFILES_VAR_LEN, - )); - } - - let record = VarRecord::Delete(name.to_string()); - - let bytes = record.serialize()?; - - let idx = self - .store - .last(self.host_id, DOTFILES_VAR_TAG) - .await? - .map_or(0, |entry| entry.idx + 1); - - let record = atuin_common::record::Record::builder() - .host(Host::new(self.host_id)) - .version(DOTFILES_VAR_VERSION.to_string()) - .tag(DOTFILES_VAR_TAG.to_string()) - .idx(idx) - .data(bytes) - .build(); - - self.store - .push(&record.encrypt::<PASETO_V4>(&self.encryption_key)) - .await?; - - // delete mutates shell config, so build again - self.build().await?; - - Ok(()) - } - - pub async fn vars(&self) -> Result<Vec<Var>> { - let mut build = BTreeMap::new(); - - // this is sorted, oldest to newest - let tagged = self.store.all_tagged(DOTFILES_VAR_TAG).await?; - - for record in tagged { - let version = record.version.clone(); - - let decrypted = match version.as_str() { - DOTFILES_VAR_VERSION => record.decrypt::<PASETO_V4>(&self.encryption_key)?, - version => bail!("unknown version {version:?}"), - }; - - let ar = VarRecord::deserialize(&decrypted.data, version.as_str())?; - - match ar { - VarRecord::Create(a) => { - build.insert(a.name.clone(), a); - } - VarRecord::Delete(d) => { - build.remove(&d); - } - } - } - - Ok(build.into_values().collect()) - } -} - -#[cfg(test)] -mod tests { - use rand::rngs::OsRng; - - use atuin_client::record::sqlite_store::SqliteStore; - - use crate::{shell::Var, store::test_local_timeout}; - - use super::{DOTFILES_VAR_VERSION, VarRecord, VarStore}; - use crypto_secretbox::{KeyInit, XSalsa20Poly1305}; - - #[test] - fn encode_decode() { - let record = Var { - name: "BEEP".to_owned(), - value: "boop".to_owned(), - export: false, - }; - let record = VarRecord::Create(record); - - let snapshot = [ - 204, 0, 147, 164, 66, 69, 69, 80, 164, 98, 111, 111, 112, 194, - ]; - - let encoded = record.serialize().unwrap(); - let decoded = VarRecord::deserialize(&encoded, DOTFILES_VAR_VERSION).unwrap(); - - assert_eq!(encoded.0, &snapshot); - assert_eq!(decoded, record); - } - - #[test] - fn test_escape_posix_value() { - // Simple values should not be quoted - assert_eq!(VarStore::escape_posix_value("simple"), "simple"); - assert_eq!(VarStore::escape_posix_value("path/to/file"), "path/to/file"); - assert_eq!( - VarStore::escape_posix_value("value_with_underscores"), - "value_with_underscores" - ); - - // Values with spaces should be quoted - assert_eq!( - VarStore::escape_posix_value("hello world"), - "\"hello world\"" - ); - assert_eq!(VarStore::escape_posix_value("bar baz"), "\"bar baz\""); - - // Values with special characters should be quoted and escaped - assert_eq!( - VarStore::escape_posix_value("say \"hello\""), - "\"say \\\"hello\\\"\"" - ); - assert_eq!( - VarStore::escape_posix_value("path\\with\\backslashes"), - "\"path\\\\with\\\\backslashes\"" - ); - assert_eq!( - VarStore::escape_posix_value("say $hello"), - "\"say \\$hello\"" - ); - assert_eq!( - VarStore::escape_posix_value("see `example.md`"), - "\"see \\`example.md\\`\"" - ); - } - - #[test] - fn test_escape_fish_value() { - // Simple values should not be quoted - assert_eq!(VarStore::escape_fish_value("simple"), "simple"); - assert_eq!(VarStore::escape_fish_value("path/to/file"), "path/to/file"); - - // Values with spaces should be single-quoted - assert_eq!(VarStore::escape_fish_value("hello world"), "'hello world'"); - assert_eq!(VarStore::escape_fish_value("bar baz"), "'bar baz'"); - - // Values with single quotes should be escaped - assert_eq!(VarStore::escape_fish_value("don't"), "'don\\'t'"); - } - - #[test] - fn test_escape_xonsh_value() { - // Simple values should not be quoted - assert_eq!(VarStore::escape_xonsh_value("simple"), "simple"); - assert_eq!(VarStore::escape_xonsh_value("path/to/file"), "path/to/file"); - - // Values with spaces should be quoted - assert_eq!( - VarStore::escape_xonsh_value("hello world"), - "\"hello world\"" - ); - assert_eq!(VarStore::escape_xonsh_value("bar baz"), "\"bar baz\""); - - // Values with special characters should be quoted and escaped - assert_eq!( - VarStore::escape_xonsh_value("say \"hello\""), - "\"say \\\"hello\\\"\"" - ); - assert_eq!( - VarStore::escape_xonsh_value("path\\with\\backslashes"), - "\"path\\\\with\\\\backslashes\"" - ); - } - - #[tokio::test] - async fn build_vars() { - let store = SqliteStore::new(":memory:", test_local_timeout()) - .await - .unwrap(); - let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into(); - let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7()); - - let env = VarStore::new(store, host_id, key); - - env.set("BEEP", "boop", false).await.unwrap(); - env.set("HOMEBREW_NO_AUTO_UPDATE", "1", true).await.unwrap(); - - let mut env_vars = env.vars().await.unwrap(); - - env_vars.sort_by_key(|a| a.name.clone()); - - assert_eq!(env_vars.len(), 2); - - assert_eq!( - env_vars[0], - Var { - name: String::from("BEEP"), - value: String::from("boop"), - export: false, - } - ); - - assert_eq!( - env_vars[1], - Var { - name: String::from("HOMEBREW_NO_AUTO_UPDATE"), - value: String::from("1"), - export: true, - } - ); - } - - #[tokio::test] - async fn test_var_generation_with_spaces() { - let store = SqliteStore::new(":memory:", test_local_timeout()) - .await - .unwrap(); - let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into(); - let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7()); - - let env = VarStore::new(store, host_id, key); - - // Test the exact scenario from the bug report - env.set("FOO", "bar baz", true).await.unwrap(); - - let posix_output = env.posix().await.unwrap(); - let fish_output = env.fish().await.unwrap(); - let xonsh_output = env.xonsh().await.unwrap(); - - // POSIX should quote the value - assert_eq!(posix_output, "export FOO=\"bar baz\"\n"); - - // Fish should quote the value - assert_eq!(fish_output, "set -gx FOO 'bar baz'\n"); - - // Xonsh should quote the value - assert_eq!(xonsh_output, "$FOO=\"bar baz\"\n"); - } -} |
