use std::env;
use std::path::{Path, PathBuf};
use eyre::{Result, eyre};
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine};
use getrandom::fill;
use uuid::Uuid;
pub fn crypto_random_bytes<const N: usize>() -> [u8; N] {
let mut ret = [0u8; N];
fill(&mut ret).expect("Failed to generate random bytes!");
ret
}
pub fn crypto_random_string<const N: usize>() -> String {
let bytes = crypto_random_bytes::<N>();
BASE64_URL_SAFE_NO_PAD.encode(bytes)
}
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()
}
fn resolve_git_worktree(path: &Path) -> Option<PathBuf> {
let git_path = path.join(".git");
if !git_path.is_file() {
return None;
}
let contents = std::fs::read_to_string(&git_path).ok()?;
let gitdir_str = contents.strip_prefix("gitdir: ")?.trim();
let gitdir = PathBuf::from(gitdir_str);
let gitdir = if gitdir.is_absolute() {
gitdir
} else {
path.join(gitdir_str)
};
let mut candidate = gitdir.as_path();
while let Some(parent) = candidate.parent() {
if parent.join(".git").is_dir() {
return Some(parent.to_path_buf());
}
candidate = parent;
}
None
}
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();
}
if gitdir.parent().is_some() {
if let Some(main_repo) = resolve_git_worktree(&gitdir) {
return Some(main_repo);
}
return Some(gitdir);
}
None
}
pub fn home_dir() -> PathBuf {
directories::BaseDirs::new()
.map(|d| d.home_dir().to_path_buf())
.expect("could not determine home directory")
}
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 runtime_dir() -> PathBuf {
std::env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
}
pub fn logs_dir() -> PathBuf {
home_dir().join(".atuin").join("logs")
}
pub fn dotfiles_cache_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").join("dotfiles").join("cache")
}
pub fn get_current_dir() -> String {
match env::var("PWD") {
Ok(v) => v,
Err(_) => match env::current_dir() {
Ok(dir) => dir.display().to_string(),
Err(_) => String::from(""),
},
}
}
pub fn broken_symlink<P: Into<PathBuf>>(path: P) -> bool {
let path = path.into();
path.is_symlink() && !path.exists()
}
pub fn unquote(s: &str) -> Result<String> {
if s.chars().count() < 2 {
return Err(eyre!("not enough chars"));
}
let quote = s.chars().next().unwrap();
if quote != '"' && quote != '\'' && quote != '`' {
return Ok(s.to_string());
}
if s.chars().last().unwrap() != quote {
return Err(eyre!("unexpected eof, quotes do not match"));
}
let s = &s[1..s.len() - 1];
Ok(s.to_string())
}
pub fn normalize_optional_string<T>(string: T) -> Option<String>
where
T: Into<Option<String>>,
{
let mut string = string.into()?;
string.truncate(string.trim_end().len());
string.drain(0..(string.len() - string.trim_start().len()));
if string.is_empty() {
None
} else {
Some(string)
}
}
#[allow(unsafe_code)]
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_ne;
use rstest::rstest;
#[cfg(not(windows))]
#[test]
fn test_dirs() {
test_config_dir_xdg();
test_config_dir();
test_data_dir_xdg();
test_data_dir();
}
#[cfg(not(windows))]
fn test_config_dir_xdg() {
unsafe { env::remove_var("HOME") };
unsafe { env::set_var("XDG_CONFIG_HOME", "/home/user/custom_config") };
assert_eq!(
config_dir(),
PathBuf::from("/home/user/custom_config/atuin")
);
unsafe { env::remove_var("XDG_CONFIG_HOME") };
}
#[cfg(not(windows))]
fn test_config_dir() {
unsafe { env::set_var("HOME", "/home/user") };
unsafe { env::remove_var("XDG_CONFIG_HOME") };
assert_eq!(config_dir(), PathBuf::from("/home/user/.config/atuin"));
unsafe { env::remove_var("HOME") };
}
#[cfg(not(windows))]
fn test_data_dir_xdg() {
unsafe { env::remove_var("HOME") };
unsafe { env::set_var("XDG_DATA_HOME", "/home/user/custom_data") };
assert_eq!(data_dir(), PathBuf::from("/home/user/custom_data/atuin"));
unsafe { env::remove_var("XDG_DATA_HOME") };
}
#[cfg(not(windows))]
fn test_data_dir() {
unsafe { env::set_var("HOME", "/home/user") };
unsafe { env::remove_var("XDG_DATA_HOME") };
assert_eq!(data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
unsafe { env::remove_var("HOME") };
}
#[cfg(not(windows))]
#[test]
fn in_git_repo_regular() {
let tmp = std::env::temp_dir().join("atuin-test-regular-git");
let _ = std::fs::remove_dir_all(&tmp);
let subdir = tmp.join("src").join("deep");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::create_dir_all(tmp.join(".git")).unwrap();
let result = in_git_repo(subdir.to_str().unwrap());
assert_eq!(result, Some(tmp.clone()));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[cfg(not(windows))]
#[test]
fn in_git_repo_worktree_resolves_to_main_repo() {
let tmp = std::env::temp_dir().join("atuin-test-worktree-git");
let _ = std::fs::remove_dir_all(&tmp);
let main_repo = tmp.join("main");
let worktree_git_dir = main_repo.join(".git").join("worktrees").join("feature");
std::fs::create_dir_all(&worktree_git_dir).unwrap();
let worktree = tmp.join("worktree");
let worktree_subdir = worktree.join("src");
std::fs::create_dir_all(&worktree_subdir).unwrap();
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}", worktree_git_dir.to_str().unwrap()),
)
.unwrap();
let result = in_git_repo(worktree_subdir.to_str().unwrap());
assert_eq!(result, Some(main_repo));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[rstest]
fn dumb_random_test<const N: usize>(#[values([(); 8], [(); 16], [(); 32])] _n: [(); N]) {
assert_ne!(crypto_random_string::<N>(), crypto_random_string::<N>());
}
}