aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-dotfiles/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-dotfiles/src')
-rw-r--r--crates/atuin-dotfiles/src/lib.rs2
-rw-r--r--crates/atuin-dotfiles/src/shell.rs241
-rw-r--r--crates/atuin-dotfiles/src/shell/bash.rs68
-rw-r--r--crates/atuin-dotfiles/src/shell/fish.rs69
-rw-r--r--crates/atuin-dotfiles/src/shell/powershell.rs169
-rw-r--r--crates/atuin-dotfiles/src/shell/xonsh.rs68
-rw-r--r--crates/atuin-dotfiles/src/shell/zsh.rs68
-rw-r--r--crates/atuin-dotfiles/src/store.rs421
-rw-r--r--crates/atuin-dotfiles/src/store/alias.rs1
-rw-r--r--crates/atuin-dotfiles/src/store/var.rs542
10 files changed, 1649 insertions, 0 deletions
diff --git a/crates/atuin-dotfiles/src/lib.rs b/crates/atuin-dotfiles/src/lib.rs
new file mode 100644
index 00000000..74daf8ef
--- /dev/null
+++ b/crates/atuin-dotfiles/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod shell;
+pub mod store;
diff --git a/crates/atuin-dotfiles/src/shell.rs b/crates/atuin-dotfiles/src/shell.rs
new file mode 100644
index 00000000..73a9ce8c
--- /dev/null
+++ b/crates/atuin-dotfiles/src/shell.rs
@@ -0,0 +1,241 @@
+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
new file mode 100644
index 00000000..2b9b4c88
--- /dev/null
+++ b/crates/atuin-dotfiles/src/shell/bash.rs
@@ -0,0 +1,68 @@
+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
new file mode 100644
index 00000000..6d472f67
--- /dev/null
+++ b/crates/atuin-dotfiles/src/shell/fish.rs
@@ -0,0 +1,69 @@
+// 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
new file mode 100644
index 00000000..1daee28b
--- /dev/null
+++ b/crates/atuin-dotfiles/src/shell/powershell.rs
@@ -0,0 +1,169 @@
+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
new file mode 100644
index 00000000..1e56fc1d
--- /dev/null
+++ b/crates/atuin-dotfiles/src/shell/xonsh.rs
@@ -0,0 +1,68 @@
+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
new file mode 100644
index 00000000..117e9403
--- /dev/null
+++ b/crates/atuin-dotfiles/src/shell/zsh.rs
@@ -0,0 +1,68 @@
+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
new file mode 100644
index 00000000..17597065
--- /dev/null
+++ b/crates/atuin-dotfiles/src/store.rs
@@ -0,0 +1,421 @@
+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
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/crates/atuin-dotfiles/src/store/alias.rs
@@ -0,0 +1 @@
+
diff --git a/crates/atuin-dotfiles/src/store/var.rs b/crates/atuin-dotfiles/src/store/var.rs
new file mode 100644
index 00000000..9d25b85d
--- /dev/null
+++ b/crates/atuin-dotfiles/src/store/var.rs
@@ -0,0 +1,542 @@
+/// 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");
+ }
+}