1use std::borrow::Cow;
2use std::env;
3use std::path::{Path, PathBuf};
4
5use eyre::{Result, eyre};
6
7use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine};
8use getrandom::getrandom;
9use uuid::Uuid;
10
11pub fn crypto_random_bytes<const N: usize>() -> [u8; N] {
13 let mut ret = [0u8; N];
16
17 getrandom(&mut ret).expect("Failed to generate random bytes!");
18
19 ret
20}
21
22pub fn crypto_random_string<const N: usize>() -> String {
24 let bytes = crypto_random_bytes::<N>();
25
26 BASE64_URL_SAFE_NO_PAD.encode(bytes)
29}
30
31pub fn uuid_v7() -> Uuid {
32 Uuid::now_v7()
33}
34
35pub fn uuid_v4() -> String {
36 Uuid::new_v4().as_simple().to_string()
37}
38
39pub fn has_git_dir(path: &str) -> bool {
40 let mut gitdir = PathBuf::from(path);
41 gitdir.push(".git");
42
43 gitdir.exists()
44}
45
46fn resolve_git_worktree(path: &Path) -> Option<PathBuf> {
50 let git_path = path.join(".git");
51
52 if !git_path.is_file() {
53 return None;
54 }
55
56 let contents = std::fs::read_to_string(&git_path).ok()?;
57 let gitdir_str = contents.strip_prefix("gitdir: ")?.trim();
58
59 let gitdir = PathBuf::from(gitdir_str);
60 let gitdir = if gitdir.is_absolute() {
61 gitdir
62 } else {
63 path.join(gitdir_str)
64 };
65
66 let mut candidate = gitdir.as_path();
68 while let Some(parent) = candidate.parent() {
69 if parent.join(".git").is_dir() {
70 return Some(parent.to_path_buf());
71 }
72 candidate = parent;
73 }
74
75 None
76}
77
78pub fn in_git_repo(path: &str) -> Option<PathBuf> {
82 let mut gitdir = PathBuf::from(path);
83
84 while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) {
85 gitdir.pop();
86 }
87
88 if gitdir.parent().is_some() {
90 if let Some(main_repo) = resolve_git_worktree(&gitdir) {
92 return Some(main_repo);
93 }
94 return Some(gitdir);
95 }
96
97 None
98}
99
100pub fn home_dir() -> PathBuf {
105 directories::BaseDirs::new()
106 .map(|d| d.home_dir().to_path_buf())
107 .expect("could not determine home directory")
108}
109
110pub fn config_dir() -> PathBuf {
111 let config_dir =
112 std::env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from);
113 config_dir.join("atuin")
114}
115
116pub fn data_dir() -> PathBuf {
117 let data_dir = std::env::var("XDG_DATA_HOME")
118 .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
119
120 data_dir.join("atuin")
121}
122
123pub fn runtime_dir() -> PathBuf {
124 std::env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
125}
126
127pub fn logs_dir() -> PathBuf {
128 home_dir().join(".atuin").join("logs")
129}
130
131pub fn dotfiles_cache_dir() -> PathBuf {
132 let data_dir = std::env::var("XDG_DATA_HOME")
134 .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
135
136 data_dir.join("atuin").join("dotfiles").join("cache")
137}
138
139pub fn get_current_dir() -> String {
140 match env::var("PWD") {
142 Ok(v) => v,
143 Err(_) => match env::current_dir() {
144 Ok(dir) => dir.display().to_string(),
145 Err(_) => String::from(""),
146 },
147 }
148}
149
150pub fn broken_symlink<P: Into<PathBuf>>(path: P) -> bool {
151 let path = path.into();
152 path.is_symlink() && !path.exists()
153}
154
155pub trait Escapable: AsRef<str> {
162 fn escape_control(&self) -> Cow<'_, str> {
163 if !self.as_ref().contains(|c: char| c.is_ascii_control()) {
164 self.as_ref().into()
165 } else {
166 let mut remaining = self.as_ref();
167 let mut buf = String::with_capacity(remaining.len());
169 while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {
170 buf.push_str(&remaining[..i]);
172 buf.push('^');
173 buf.push(match remaining.as_bytes()[i] {
174 0x7F => '?',
175 code => char::from_u32(u32::from(code) + 64).unwrap(),
176 });
177 remaining = &remaining[i + 1..];
178 }
179 buf.push_str(remaining);
180 buf.into()
181 }
182 }
183}
184
185pub fn unquote(s: &str) -> Result<String> {
186 if s.chars().count() < 2 {
187 return Err(eyre!("not enough chars"));
188 }
189
190 let quote = s.chars().next().unwrap();
191
192 if quote != '"' && quote != '\'' && quote != '`' {
194 return Ok(s.to_string());
195 }
196
197 if s.chars().last().unwrap() != quote {
198 return Err(eyre!("unexpected eof, quotes do not match"));
199 }
200
201 let s = &s[1..s.len() - 1];
205
206 Ok(s.to_string())
207}
208
209impl<T: AsRef<str>> Escapable for T {}
210
211#[allow(unsafe_code)]
212#[cfg(test)]
213mod tests {
214 use pretty_assertions::assert_ne;
215
216 use super::*;
217
218 use std::collections::HashSet;
219
220 #[cfg(not(windows))]
221 #[test]
222 fn test_dirs() {
223 test_config_dir_xdg();
225 test_config_dir();
226 test_data_dir_xdg();
227 test_data_dir();
228 }
229
230 #[cfg(not(windows))]
231 fn test_config_dir_xdg() {
232 unsafe { env::remove_var("HOME") };
234 unsafe { env::set_var("XDG_CONFIG_HOME", "/home/user/custom_config") };
236 assert_eq!(
237 config_dir(),
238 PathBuf::from("/home/user/custom_config/atuin")
239 );
240 unsafe { env::remove_var("XDG_CONFIG_HOME") };
242 }
243
244 #[cfg(not(windows))]
245 fn test_config_dir() {
246 unsafe { env::set_var("HOME", "/home/user") };
248 unsafe { env::remove_var("XDG_CONFIG_HOME") };
250
251 assert_eq!(config_dir(), PathBuf::from("/home/user/.config/atuin"));
252
253 unsafe { env::remove_var("HOME") };
255 }
256
257 #[cfg(not(windows))]
258 fn test_data_dir_xdg() {
259 unsafe { env::remove_var("HOME") };
261 unsafe { env::set_var("XDG_DATA_HOME", "/home/user/custom_data") };
263 assert_eq!(data_dir(), PathBuf::from("/home/user/custom_data/atuin"));
264 unsafe { env::remove_var("XDG_DATA_HOME") };
266 }
267
268 #[cfg(not(windows))]
269 fn test_data_dir() {
270 unsafe { env::set_var("HOME", "/home/user") };
272 unsafe { env::remove_var("XDG_DATA_HOME") };
274 assert_eq!(data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
275 unsafe { env::remove_var("HOME") };
277 }
278
279 #[test]
280 fn uuid_is_unique() {
281 let how_many: usize = 1000000;
282
283 let mut uuids: HashSet<Uuid> = HashSet::with_capacity(how_many);
285
286 for _ in 0..how_many {
288 let uuid = uuid_v7();
289 uuids.insert(uuid);
290 }
291
292 assert_eq!(uuids.len(), how_many);
293 }
294
295 #[test]
296 fn escape_control_characters() {
297 use super::Escapable;
298 assert_eq!("\x1b[31mfoo".escape_control(), "^[[31mfoo");
300
301 assert_eq!("foo\tbar".escape_control(), "foo^Ibar");
303
304 assert_eq!("two words".escape_control(), "two words");
306
307 let s = "🐢\x1b[32m🦀";
309 assert_eq!(s.escape_control(), s.replace("\x1b", "^["));
310 }
311
312 #[test]
313 fn escape_no_control_characters() {
314 use super::Escapable as _;
315 assert!(matches!(
316 "no control characters".escape_control(),
317 Cow::Borrowed(_)
318 ));
319 assert!(matches!(
320 "with \x1b[31mcontrol\x1b[0m characters".escape_control(),
321 Cow::Owned(_)
322 ));
323 }
324
325 #[cfg(not(windows))]
326 #[test]
327 fn in_git_repo_regular() {
328 let tmp = std::env::temp_dir().join("atuin-test-regular-git");
330 let _ = std::fs::remove_dir_all(&tmp);
331 let subdir = tmp.join("src").join("deep");
332 std::fs::create_dir_all(&subdir).unwrap();
333 std::fs::create_dir_all(tmp.join(".git")).unwrap();
334
335 let result = in_git_repo(subdir.to_str().unwrap());
336 assert_eq!(result, Some(tmp.clone()));
337
338 std::fs::remove_dir_all(&tmp).unwrap();
339 }
340
341 #[cfg(not(windows))]
342 #[test]
343 fn in_git_repo_worktree_resolves_to_main_repo() {
344 let tmp = std::env::temp_dir().join("atuin-test-worktree-git");
347 let _ = std::fs::remove_dir_all(&tmp);
348
349 let main_repo = tmp.join("main");
351 let worktree_git_dir = main_repo.join(".git").join("worktrees").join("feature");
352 std::fs::create_dir_all(&worktree_git_dir).unwrap();
353
354 let worktree = tmp.join("worktree");
356 let worktree_subdir = worktree.join("src");
357 std::fs::create_dir_all(&worktree_subdir).unwrap();
358 std::fs::write(
359 worktree.join(".git"),
360 format!("gitdir: {}", worktree_git_dir.to_str().unwrap()),
361 )
362 .unwrap();
363
364 let result = in_git_repo(worktree_subdir.to_str().unwrap());
366 assert_eq!(result, Some(main_repo.clone()));
367
368 std::fs::remove_dir_all(&tmp).unwrap();
369 }
370
371 #[test]
372 fn dumb_random_test() {
373 assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());
377 assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());
378 assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());
379 assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());
380 assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());
381 assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());
382 }
383}