summaryrefslogtreecommitdiffstats
path: root/crates/atuin-common/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-common/src')
-rw-r--r--crates/atuin-common/src/api.rs122
-rw-r--r--crates/atuin-common/src/calendar.rs16
-rw-r--r--crates/atuin-common/src/lib.rs58
-rw-r--r--crates/atuin-common/src/record.rs426
-rw-r--r--crates/atuin-common/src/shell.rs147
-rw-r--r--crates/atuin-common/src/utils.rs265
6 files changed, 1034 insertions, 0 deletions
diff --git a/crates/atuin-common/src/api.rs b/crates/atuin-common/src/api.rs
new file mode 100644
index 00000000..99b57cec
--- /dev/null
+++ b/crates/atuin-common/src/api.rs
@@ -0,0 +1,122 @@
+use lazy_static::lazy_static;
+use semver::Version;
+use serde::{Deserialize, Serialize};
+use std::borrow::Cow;
+use time::OffsetDateTime;
+
+// the usage of X- has been deprecated for quite along time, it turns out
+pub static ATUIN_HEADER_VERSION: &str = "Atuin-Version";
+pub static ATUIN_CARGO_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+lazy_static! {
+ pub static ref ATUIN_VERSION: Version =
+ Version::parse(ATUIN_CARGO_VERSION).expect("failed to parse self semver");
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct UserResponse {
+ pub username: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct RegisterRequest {
+ pub email: String,
+ pub username: String,
+ pub password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct RegisterResponse {
+ pub session: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct DeleteUserResponse {}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ChangePasswordRequest {
+ pub current_password: String,
+ pub new_password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ChangePasswordResponse {}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LoginRequest {
+ pub username: String,
+ pub password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LoginResponse {
+ pub session: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct AddHistoryRequest {
+ pub id: String,
+ #[serde(with = "time::serde::rfc3339")]
+ pub timestamp: OffsetDateTime,
+ pub data: String,
+ pub hostname: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct CountResponse {
+ pub count: i64,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SyncHistoryRequest {
+ #[serde(with = "time::serde::rfc3339")]
+ pub sync_ts: OffsetDateTime,
+ #[serde(with = "time::serde::rfc3339")]
+ pub history_ts: OffsetDateTime,
+ pub host: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SyncHistoryResponse {
+ pub history: Vec<String>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ErrorResponse<'a> {
+ pub reason: Cow<'a, str>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct IndexResponse {
+ pub homage: String,
+ pub version: String,
+ pub total_history: i64,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct StatusResponse {
+ pub count: i64,
+ pub username: String,
+ pub deleted: Vec<String>,
+
+ // These could/should also go on the index of the server
+ // However, we do not request the server index as a part of normal sync
+ // I'd rather slightly increase the size of this response, than add an extra HTTP request
+ pub page_size: i64, // max page size supported by the server
+ pub version: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct DeleteHistoryRequest {
+ pub client_id: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct MessageResponse {
+ pub message: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct MeResponse {
+ pub username: String,
+}
diff --git a/crates/atuin-common/src/calendar.rs b/crates/atuin-common/src/calendar.rs
new file mode 100644
index 00000000..d3b1d921
--- /dev/null
+++ b/crates/atuin-common/src/calendar.rs
@@ -0,0 +1,16 @@
+// Calendar data
+use serde::{Serialize, Deserialize};
+
+pub enum TimePeriod {
+ YEAR,
+ MONTH,
+ DAY,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TimePeriodInfo {
+ pub count: u64,
+
+ // TODO: Use this for merkle tree magic
+ pub hash: String,
+}
diff --git a/crates/atuin-common/src/lib.rs b/crates/atuin-common/src/lib.rs
new file mode 100644
index 00000000..2d848f6f
--- /dev/null
+++ b/crates/atuin-common/src/lib.rs
@@ -0,0 +1,58 @@
+#![forbid(unsafe_code)]
+
+/// Defines a new UUID type wrapper
+macro_rules! new_uuid {
+ ($name:ident) => {
+ #[derive(
+ Debug,
+ Copy,
+ Clone,
+ PartialEq,
+ Eq,
+ Hash,
+ PartialOrd,
+ Ord,
+ serde::Serialize,
+ serde::Deserialize,
+ )]
+ #[serde(transparent)]
+ pub struct $name(pub Uuid);
+
+ impl<DB: sqlx::Database> sqlx::Type<DB> for $name
+ where
+ Uuid: sqlx::Type<DB>,
+ {
+ fn type_info() -> <DB as sqlx::Database>::TypeInfo {
+ Uuid::type_info()
+ }
+ }
+
+ impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for $name
+ where
+ Uuid: sqlx::Decode<'r, DB>,
+ {
+ fn decode(
+ value: <DB as sqlx::database::HasValueRef<'r>>::ValueRef,
+ ) -> std::result::Result<Self, sqlx::error::BoxDynError> {
+ Uuid::decode(value).map(Self)
+ }
+ }
+
+ impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for $name
+ where
+ Uuid: sqlx::Encode<'q, DB>,
+ {
+ fn encode_by_ref(
+ &self,
+ buf: &mut <DB as sqlx::database::HasArguments<'q>>::ArgumentBuffer,
+ ) -> sqlx::encode::IsNull {
+ self.0.encode_by_ref(buf)
+ }
+ }
+ };
+}
+
+pub mod api;
+pub mod record;
+pub mod shell;
+pub mod utils;
diff --git a/crates/atuin-common/src/record.rs b/crates/atuin-common/src/record.rs
new file mode 100644
index 00000000..e6ce2647
--- /dev/null
+++ b/crates/atuin-common/src/record.rs
@@ -0,0 +1,426 @@
+use std::collections::HashMap;
+
+use eyre::Result;
+use serde::{Deserialize, Serialize};
+use typed_builder::TypedBuilder;
+use uuid::Uuid;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct DecryptedData(pub Vec<u8>);
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct EncryptedData {
+ pub data: String,
+ pub content_encryption_key: String,
+}
+
+#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)]
+pub struct Diff {
+ pub host: HostId,
+ pub tag: String,
+ pub local: Option<RecordIdx>,
+ pub remote: Option<RecordIdx>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+pub struct Host {
+ pub id: HostId,
+ pub name: String,
+}
+
+impl Host {
+ pub fn new(id: HostId) -> Self {
+ Host {
+ id,
+ name: String::new(),
+ }
+ }
+}
+
+new_uuid!(RecordId);
+new_uuid!(HostId);
+
+pub type RecordIdx = u64;
+
+/// A single record stored inside of our local database
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TypedBuilder)]
+pub struct Record<Data> {
+ /// a unique ID
+ #[builder(default = RecordId(crate::utils::uuid_v7()))]
+ pub id: RecordId,
+
+ /// The integer record ID. This is only unique per (host, tag).
+ pub idx: RecordIdx,
+
+ /// The unique ID of the host.
+ // TODO(ellie): Optimize the storage here. We use a bunch of IDs, and currently store
+ // as strings. I would rather avoid normalization, so store as UUID binary instead of
+ // encoding to a string and wasting much more storage.
+ pub host: Host,
+
+ /// The creation time in nanoseconds since unix epoch
+ #[builder(default = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as u64)]
+ pub timestamp: u64,
+
+ /// The version the data in the entry conforms to
+ // However we want to track versions for this tag, eg v2
+ pub version: String,
+
+ /// The type of data we are storing here. Eg, "history"
+ pub tag: String,
+
+ /// Some data. This can be anything you wish to store. Use the tag field to know how to handle it.
+ pub data: Data,
+}
+
+/// Extra data from the record that should be encoded in the data
+#[derive(Debug, Copy, Clone)]
+pub struct AdditionalData<'a> {
+ pub id: &'a RecordId,
+ pub idx: &'a u64,
+ pub version: &'a str,
+ pub tag: &'a str,
+ pub host: &'a HostId,
+}
+
+impl<Data> Record<Data> {
+ pub fn append(&self, data: Vec<u8>) -> Record<DecryptedData> {
+ Record::builder()
+ .host(self.host.clone())
+ .version(self.version.clone())
+ .idx(self.idx + 1)
+ .tag(self.tag.clone())
+ .data(DecryptedData(data))
+ .build()
+ }
+}
+
+/// An index representing the current state of the record stores
+/// This can be both remote, or local, and compared in either direction
+#[derive(Debug, Serialize, Deserialize)]
+pub struct RecordStatus {
+ // A map of host -> tag -> max(idx)
+ pub hosts: HashMap<HostId, HashMap<String, RecordIdx>>,
+}
+
+impl Default for RecordStatus {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Extend<(HostId, String, RecordIdx)> for RecordStatus {
+ fn extend<T: IntoIterator<Item = (HostId, String, RecordIdx)>>(&mut self, iter: T) {
+ for (host, tag, tail_idx) in iter {
+ self.set_raw(host, tag, tail_idx);
+ }
+ }
+}
+
+impl RecordStatus {
+ pub fn new() -> RecordStatus {
+ RecordStatus {
+ hosts: HashMap::new(),
+ }
+ }
+
+ /// Insert a new tail record into the store
+ pub fn set(&mut self, tail: Record<DecryptedData>) {
+ self.set_raw(tail.host.id, tail.tag, tail.idx)
+ }
+
+ pub fn set_raw(&mut self, host: HostId, tag: String, tail_id: RecordIdx) {
+ self.hosts.entry(host).or_default().insert(tag, tail_id);
+ }
+
+ pub fn get(&self, host: HostId, tag: String) -> Option<RecordIdx> {
+ self.hosts.get(&host).and_then(|v| v.get(&tag)).cloned()
+ }
+
+ /// Diff this index with another, likely remote index.
+ /// The two diffs can then be reconciled, and the optimal change set calculated
+ /// Returns a tuple, with (host, tag, Option(OTHER))
+ /// OTHER is set to the value of the idx on the other machine. If it is greater than our index,
+ /// then we need to do some downloading. If it is smaller, then we need to do some uploading
+ /// Note that we cannot upload if we are not the owner of the record store - hosts can only
+ /// write to their own store.
+ pub fn diff(&self, other: &Self) -> Vec<Diff> {
+ let mut ret = Vec::new();
+
+ // First, we check if other has everything that self has
+ for (host, tag_map) in self.hosts.iter() {
+ for (tag, idx) in tag_map.iter() {
+ match other.get(*host, tag.clone()) {
+ // The other store is all up to date! No diff.
+ Some(t) if t.eq(idx) => continue,
+
+ // The other store does exist, and it is either ahead or behind us. A diff regardless
+ Some(t) => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ local: Some(*idx),
+ remote: Some(t),
+ }),
+
+ // The other store does not exist :O
+ None => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ local: Some(*idx),
+ remote: None,
+ }),
+ };
+ }
+ }
+
+ // At this point, there is a single case we have not yet considered.
+ // If the other store knows of a tag that we are not yet aware of, then the diff will be missed
+
+ // account for that!
+ for (host, tag_map) in other.hosts.iter() {
+ for (tag, idx) in tag_map.iter() {
+ match self.get(*host, tag.clone()) {
+ // If we have this host/tag combo, the comparison and diff will have already happened above
+ Some(_) => continue,
+
+ None => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ remote: Some(*idx),
+ local: None,
+ }),
+ };
+ }
+ }
+
+ // Stability is a nice property to have
+ ret.sort();
+ ret
+ }
+}
+
+pub trait Encryption {
+ fn re_encrypt(
+ data: EncryptedData,
+ ad: AdditionalData,
+ old_key: &[u8; 32],
+ new_key: &[u8; 32],
+ ) -> Result<EncryptedData> {
+ let data = Self::decrypt(data, ad, old_key)?;
+ Ok(Self::encrypt(data, ad, new_key))
+ }
+ fn encrypt(data: DecryptedData, ad: AdditionalData, key: &[u8; 32]) -> EncryptedData;
+ fn decrypt(data: EncryptedData, ad: AdditionalData, key: &[u8; 32]) -> Result<DecryptedData>;
+}
+
+impl Record<DecryptedData> {
+ pub fn encrypt<E: Encryption>(self, key: &[u8; 32]) -> Record<EncryptedData> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Record {
+ data: E::encrypt(self.data, ad, key),
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ }
+ }
+}
+
+impl Record<EncryptedData> {
+ pub fn decrypt<E: Encryption>(self, key: &[u8; 32]) -> Result<Record<DecryptedData>> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Ok(Record {
+ data: E::decrypt(self.data, ad, key)?,
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ })
+ }
+
+ pub fn re_encrypt<E: Encryption>(
+ self,
+ old_key: &[u8; 32],
+ new_key: &[u8; 32],
+ ) -> Result<Record<EncryptedData>> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Ok(Record {
+ data: E::re_encrypt(self.data, ad, old_key, new_key)?,
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::record::{Host, HostId};
+
+ use super::{DecryptedData, Diff, Record, RecordStatus};
+ use pretty_assertions::assert_eq;
+
+ fn test_record() -> Record<DecryptedData> {
+ Record::builder()
+ .host(Host::new(HostId(crate::utils::uuid_v7())))
+ .version("v1".into())
+ .tag(crate::utils::uuid_v7().simple().to_string())
+ .data(DecryptedData(vec![0, 1, 2, 3]))
+ .idx(0)
+ .build()
+ }
+
+ #[test]
+ fn record_index() {
+ let mut index = RecordStatus::new();
+ let record = test_record();
+
+ index.set(record.clone());
+
+ let tail = index.get(record.host.id, record.tag);
+
+ assert_eq!(
+ record.idx,
+ tail.expect("tail not in store"),
+ "tail in store did not match"
+ );
+ }
+
+ #[test]
+ fn record_index_overwrite() {
+ let mut index = RecordStatus::new();
+ let record = test_record();
+ let child = record.append(vec![1, 2, 3]);
+
+ index.set(record.clone());
+ index.set(child.clone());
+
+ let tail = index.get(record.host.id, record.tag);
+
+ assert_eq!(
+ child.idx,
+ tail.expect("tail not in store"),
+ "tail in store did not match"
+ );
+ }
+
+ #[test]
+ fn record_index_no_diff() {
+ // Here, they both have the same version and should have no diff
+
+ let mut index1 = RecordStatus::new();
+ let mut index2 = RecordStatus::new();
+
+ let record1 = test_record();
+
+ index1.set(record1.clone());
+ index2.set(record1);
+
+ let diff = index1.diff(&index2);
+
+ assert_eq!(0, diff.len(), "expected empty diff");
+ }
+
+ #[test]
+ fn record_index_single_diff() {
+ // Here, they both have the same stores, but one is ahead by a single record
+
+ let mut index1 = RecordStatus::new();
+ let mut index2 = RecordStatus::new();
+
+ let record1 = test_record();
+ let record2 = record1.append(vec![1, 2, 3]);
+
+ index1.set(record1);
+ index2.set(record2.clone());
+
+ let diff = index1.diff(&index2);
+
+ assert_eq!(1, diff.len(), "expected single diff");
+ assert_eq!(
+ diff[0],
+ Diff {
+ host: record2.host.id,
+ tag: record2.tag,
+ remote: Some(1),
+ local: Some(0)
+ }
+ );
+ }
+
+ #[test]
+ fn record_index_multi_diff() {
+ // A much more complex case, with a bunch more checks
+ let mut index1 = RecordStatus::new();
+ let mut index2 = RecordStatus::new();
+
+ let store1record1 = test_record();
+ let store1record2 = store1record1.append(vec![1, 2, 3]);
+
+ let store2record1 = test_record();
+ let store2record2 = store2record1.append(vec![1, 2, 3]);
+
+ let store3record1 = test_record();
+
+ let store4record1 = test_record();
+
+ // index1 only knows about the first two entries of the first two stores
+ index1.set(store1record1);
+ index1.set(store2record1);
+
+ // index2 is fully up to date with the first two stores, and knows of a third
+ index2.set(store1record2);
+ index2.set(store2record2);
+ index2.set(store3record1);
+
+ // index1 knows of a 4th store
+ index1.set(store4record1);
+
+ let diff1 = index1.diff(&index2);
+ let diff2 = index2.diff(&index1);
+
+ // both diffs the same length
+ assert_eq!(4, diff1.len());
+ assert_eq!(4, diff2.len());
+
+ dbg!(&diff1, &diff2);
+
+ // both diffs should be ALMOST the same. They will agree on which hosts and tags
+ // require updating, but the "other" value will not be the same.
+ let smol_diff_1: Vec<(HostId, String)> =
+ diff1.iter().map(|v| (v.host, v.tag.clone())).collect();
+ let smol_diff_2: Vec<(HostId, String)> =
+ diff1.iter().map(|v| (v.host, v.tag.clone())).collect();
+
+ assert_eq!(smol_diff_1, smol_diff_2);
+
+ // diffing with yourself = no diff
+ assert_eq!(index1.diff(&index1).len(), 0);
+ assert_eq!(index2.diff(&index2).len(), 0);
+ }
+}
diff --git a/crates/atuin-common/src/shell.rs b/crates/atuin-common/src/shell.rs
new file mode 100644
index 00000000..42e32f72
--- /dev/null
+++ b/crates/atuin-common/src/shell.rs
@@ -0,0 +1,147 @@
+use std::{ffi::OsStr, path::Path, process::Command};
+
+use serde::Serialize;
+use sysinfo::{get_current_pid, Process, System};
+use thiserror::Error;
+
+pub enum Shell {
+ Sh,
+ Bash,
+ Fish,
+ Zsh,
+ Xonsh,
+ Nu,
+
+ Unknown,
+}
+
+impl std::fmt::Display for Shell {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let shell = match self {
+ Shell::Bash => "bash",
+ Shell::Fish => "fish",
+ Shell::Zsh => "zsh",
+ Shell::Nu => "nu",
+ Shell::Xonsh => "xonsh",
+ Shell::Sh => "sh",
+
+ Shell::Unknown => "unknown",
+ };
+
+ write!(f, "{}", shell)
+ }
+}
+
+#[derive(Debug, Error, Serialize)]
+pub enum ShellError {
+ #[error("shell not supported")]
+ NotSupported,
+
+ #[error("failed to execute shell command: {0}")]
+ ExecError(String),
+}
+
+impl Shell {
+ pub fn current() -> Shell {
+ let sys = System::new_all();
+
+ let process = sys
+ .process(get_current_pid().expect("Failed to get current PID"))
+ .expect("Process with current pid does not exist");
+
+ let parent = sys
+ .process(process.parent().expect("Atuin running with no parent!"))
+ .expect("Process with parent pid does not exist");
+
+ let shell = parent.name().trim().to_lowercase();
+ let shell = shell.strip_prefix('-').unwrap_or(&shell);
+
+ Shell::from_string(shell.to_string())
+ }
+
+ /// Best-effort attempt to determine the default shell
+ /// This implementation will be different across different platforms
+ /// Caller should ensure to handle Shell::Unknown correctly
+ pub fn default_shell() -> Result<Shell, ShellError> {
+ let sys = System::name().unwrap_or("".to_string()).to_lowercase();
+
+ // TODO: Support Linux
+ // I'm pretty sure we can use /etc/passwd there, though there will probably be some issues
+ if sys.contains("darwin") {
+ // This works in my testing so far
+ let path = Shell::Sh.run_interactive([
+ "dscl localhost -read \"/Local/Default/Users/$USER\" shell | awk '{print $2}'",
+ ])?;
+
+ let path = Path::new(path.trim());
+
+ let shell = path.file_name();
+
+ if shell.is_none() {
+ return Err(ShellError::NotSupported);
+ }
+
+ Ok(Shell::from_string(
+ shell.unwrap().to_string_lossy().to_string(),
+ ))
+ } else {
+ Err(ShellError::NotSupported)
+ }
+ }
+
+ pub fn from_string(name: String) -> Shell {
+ match name.as_str() {
+ "bash" => Shell::Bash,
+ "fish" => Shell::Fish,
+ "zsh" => Shell::Zsh,
+ "xonsh" => Shell::Xonsh,
+ "nu" => Shell::Nu,
+ "sh" => Shell::Sh,
+
+ _ => Shell::Unknown,
+ }
+ }
+
+ /// Returns true if the shell is posix-like
+ /// Note that while fish is not posix compliant, it behaves well enough for our current
+ /// featureset that this does not matter.
+ pub fn is_posixish(&self) -> bool {
+ matches!(self, Shell::Bash | Shell::Fish | Shell::Zsh)
+ }
+
+ pub fn run_interactive<I, S>(&self, args: I) -> Result<String, ShellError>
+ where
+ I: IntoIterator<Item = S>,
+ S: AsRef<OsStr>,
+ {
+ let shell = self.to_string();
+
+ let output = Command::new(shell)
+ .arg("-ic")
+ .args(args)
+ .output()
+ .map_err(|e| ShellError::ExecError(e.to_string()))?;
+
+ Ok(String::from_utf8(output.stdout).unwrap())
+ }
+}
+
+pub fn shell_name(parent: Option<&Process>) -> String {
+ let sys = System::new_all();
+
+ let parent = if let Some(parent) = parent {
+ parent
+ } else {
+ let process = sys
+ .process(get_current_pid().expect("Failed to get current PID"))
+ .expect("Process with current pid does not exist");
+
+ sys.process(process.parent().expect("Atuin running with no parent!"))
+ .expect("Process with parent pid does not exist")
+ };
+
+ let shell = parent.name().trim().to_lowercase();
+ let shell = shell.strip_prefix('-').unwrap_or(&shell);
+
+ shell.to_string()
+}
diff --git a/crates/atuin-common/src/utils.rs b/crates/atuin-common/src/utils.rs
new file mode 100644
index 00000000..7c533663
--- /dev/null
+++ b/crates/atuin-common/src/utils.rs
@@ -0,0 +1,265 @@
+use std::borrow::Cow;
+use std::env;
+use std::path::PathBuf;
+
+use rand::RngCore;
+use uuid::Uuid;
+
+pub fn random_bytes<const N: usize>() -> [u8; N] {
+ let mut ret = [0u8; N];
+
+ rand::thread_rng().fill_bytes(&mut ret);
+
+ ret
+}
+
+pub fn uuid_v7() -> Uuid {
+ Uuid::now_v7()
+}
+
+pub fn uuid_v4() -> String {
+ Uuid::new_v4().as_simple().to_string()
+}
+
+pub fn has_git_dir(path: &str) -> bool {
+ let mut gitdir = PathBuf::from(path);
+ gitdir.push(".git");
+
+ gitdir.exists()
+}
+
+// detect if any parent dir has a git repo in it
+// I really don't want to bring in libgit for something simple like this
+// If we start to do anything more advanced, then perhaps
+pub fn in_git_repo(path: &str) -> Option<PathBuf> {
+ let mut gitdir = PathBuf::from(path);
+
+ while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) {
+ gitdir.pop();
+ }
+
+ // No parent? then we hit root, finding no git
+ if gitdir.parent().is_some() {
+ return Some(gitdir);
+ }
+
+ None
+}
+
+// TODO: more reliable, more tested
+// I don't want to use ProjectDirs, it puts config in awkward places on
+// mac. Data too. Seems to be more intended for GUI apps.
+
+#[cfg(not(target_os = "windows"))]
+pub fn home_dir() -> PathBuf {
+ let home = std::env::var("HOME").expect("$HOME not found");
+ PathBuf::from(home)
+}
+
+#[cfg(target_os = "windows")]
+pub fn home_dir() -> PathBuf {
+ let home = std::env::var("USERPROFILE").expect("%userprofile% not found");
+ PathBuf::from(home)
+}
+
+pub fn config_dir() -> PathBuf {
+ let config_dir =
+ std::env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from);
+ config_dir.join("atuin")
+}
+
+pub fn data_dir() -> PathBuf {
+ let data_dir = std::env::var("XDG_DATA_HOME")
+ .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
+
+ data_dir.join("atuin")
+}
+
+pub fn dotfiles_cache_dir() -> PathBuf {
+ // In most cases, this will be ~/.local/share/atuin/dotfiles/cache
+ let data_dir = std::env::var("XDG_DATA_HOME")
+ .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
+
+ data_dir.join("atuin").join("dotfiles").join("cache")
+}
+
+pub fn get_current_dir() -> String {
+ // Prefer PWD environment variable over cwd if available to better support symbolic links
+ match env::var("PWD") {
+ Ok(v) => v,
+ Err(_) => match env::current_dir() {
+ Ok(dir) => dir.display().to_string(),
+ Err(_) => String::from(""),
+ },
+ }
+}
+
+pub fn is_zsh() -> bool {
+ // only set on zsh
+ env::var("ATUIN_SHELL_ZSH").is_ok()
+}
+
+pub fn is_fish() -> bool {
+ // only set on fish
+ env::var("ATUIN_SHELL_FISH").is_ok()
+}
+
+pub fn is_bash() -> bool {
+ // only set on bash
+ env::var("ATUIN_SHELL_BASH").is_ok()
+}
+
+pub fn is_xonsh() -> bool {
+ // only set on xonsh
+ env::var("ATUIN_SHELL_XONSH").is_ok()
+}
+
+/// Extension trait for anything that can behave like a string to make it easy to escape control
+/// characters.
+///
+/// Intended to help prevent control characters being printed and interpreted by the terminal when
+/// printing history as well as to ensure the commands that appear in the interactive search
+/// reflect the actual command run rather than just the printable characters.
+pub trait Escapable: AsRef<str> {
+ fn escape_control(&self) -> Cow<str> {
+ if !self.as_ref().contains(|c: char| c.is_ascii_control()) {
+ self.as_ref().into()
+ } else {
+ let mut remaining = self.as_ref();
+ // Not a perfect way to reserve space but should reduce the allocations
+ let mut buf = String::with_capacity(remaining.as_bytes().len());
+ while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {
+ // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char
+ buf.push_str(&remaining[..i]);
+ buf.push('^');
+ buf.push(match remaining.as_bytes()[i] {
+ 0x7F => '?',
+ code => char::from_u32(u32::from(code) + 64).unwrap(),
+ });
+ remaining = &remaining[i + 1..];
+ }
+ buf.push_str(remaining);
+ buf.into()
+ }
+ }
+}
+
+impl<T: AsRef<str>> Escapable for T {}
+
+#[cfg(test)]
+mod tests {
+ use time::Month;
+
+ use super::*;
+ use std::env;
+
+ use std::collections::HashSet;
+
+ #[cfg(not(windows))]
+ #[test]
+ fn test_dirs() {
+ // these tests need to be run sequentially to prevent race condition
+ test_config_dir_xdg();
+ test_config_dir();
+ test_data_dir_xdg();
+ test_data_dir();
+ }
+
+ fn test_config_dir_xdg() {
+ env::remove_var("HOME");
+ env::set_var("XDG_CONFIG_HOME", "/home/user/custom_config");
+ assert_eq!(
+ config_dir(),
+ PathBuf::from("/home/user/custom_config/atuin")
+ );
+ env::remove_var("XDG_CONFIG_HOME");
+ }
+
+ fn test_config_dir() {
+ env::set_var("HOME", "/home/user");
+ env::remove_var("XDG_CONFIG_HOME");
+
+ assert_eq!(config_dir(), PathBuf::from("/home/user/.config/atuin"));
+
+ env::remove_var("HOME");
+ }
+
+ fn test_data_dir_xdg() {
+ env::remove_var("HOME");
+ env::set_var("XDG_DATA_HOME", "/home/user/custom_data");
+ assert_eq!(data_dir(), PathBuf::from("/home/user/custom_data/atuin"));
+ env::remove_var("XDG_DATA_HOME");
+ }
+
+ fn test_data_dir() {
+ env::set_var("HOME", "/home/user");
+ env::remove_var("XDG_DATA_HOME");
+ assert_eq!(data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
+ env::remove_var("HOME");
+ }
+
+ #[test]
+ fn days_from_month() {
+ assert_eq!(time::util::days_in_year_month(2023, Month::January), 31);
+ assert_eq!(time::util::days_in_year_month(2023, Month::February), 28);
+ assert_eq!(time::util::days_in_year_month(2023, Month::March), 31);
+ assert_eq!(time::util::days_in_year_month(2023, Month::April), 30);
+ assert_eq!(time::util::days_in_year_month(2023, Month::May), 31);
+ assert_eq!(time::util::days_in_year_month(2023, Month::June), 30);
+ assert_eq!(time::util::days_in_year_month(2023, Month::July), 31);
+ assert_eq!(time::util::days_in_year_month(2023, Month::August), 31);
+ assert_eq!(time::util::days_in_year_month(2023, Month::September), 30);
+ assert_eq!(time::util::days_in_year_month(2023, Month::October), 31);
+ assert_eq!(time::util::days_in_year_month(2023, Month::November), 30);
+ assert_eq!(time::util::days_in_year_month(2023, Month::December), 31);