atuin_common/
utils.rs

1use std::borrow::Cow;
2use std::env;
3use std::path::PathBuf;
4
5use eyre::{eyre, Result};
6
7use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
8use getrandom::getrandom;
9use uuid::Uuid;
10
11/// Generate N random bytes, using a cryptographically secure source
12pub fn crypto_random_bytes<const N: usize>() -> [u8; N] {
13    // rand say they are in principle safe for crypto purposes, but that it is perhaps a better
14    // idea to use getrandom for things such as passwords.
15    let mut ret = [0u8; N];
16
17    getrandom(&mut ret).expect("Failed to generate random bytes!");
18
19    ret
20}
21
22/// Generate N random bytes using a cryptographically secure source, return encoded as a string
23pub fn crypto_random_string<const N: usize>() -> String {
24    let bytes = crypto_random_bytes::<N>();
25
26    // We only use this to create a random string, and won't be reversing it to find the original
27    // data - no padding is OK there. It may be in URLs.
28    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
46// detect if any parent dir has a git repo in it
47// I really don't want to bring in libgit for something simple like this
48// If we start to do anything more advanced, then perhaps
49pub fn in_git_repo(path: &str) -> Option<PathBuf> {
50    let mut gitdir = PathBuf::from(path);
51
52    while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) {
53        gitdir.pop();
54    }
55
56    // No parent? then we hit root, finding no git
57    if gitdir.parent().is_some() {
58        return Some(gitdir);
59    }
60
61    None
62}
63
64// TODO: more reliable, more tested
65// I don't want to use ProjectDirs, it puts config in awkward places on
66// mac. Data too. Seems to be more intended for GUI apps.
67
68#[cfg(not(target_os = "windows"))]
69pub fn home_dir() -> PathBuf {
70    let home = std::env::var("HOME").expect("$HOME not found");
71    PathBuf::from(home)
72}
73
74#[cfg(target_os = "windows")]
75pub fn home_dir() -> PathBuf {
76    let home = std::env::var("USERPROFILE").expect("%userprofile% not found");
77    PathBuf::from(home)
78}
79
80pub fn config_dir() -> PathBuf {
81    let config_dir =
82        std::env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from);
83    config_dir.join("atuin")
84}
85
86pub fn data_dir() -> PathBuf {
87    let data_dir = std::env::var("XDG_DATA_HOME")
88        .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
89
90    data_dir.join("atuin")
91}
92
93pub fn runtime_dir() -> PathBuf {
94    std::env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
95}
96
97pub fn dotfiles_cache_dir() -> PathBuf {
98    // In most cases, this will be  ~/.local/share/atuin/dotfiles/cache
99    let data_dir = std::env::var("XDG_DATA_HOME")
100        .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
101
102    data_dir.join("atuin").join("dotfiles").join("cache")
103}
104
105pub fn get_current_dir() -> String {
106    // Prefer PWD environment variable over cwd if available to better support symbolic links
107    match env::var("PWD") {
108        Ok(v) => v,
109        Err(_) => match env::current_dir() {
110            Ok(dir) => dir.display().to_string(),
111            Err(_) => String::from(""),
112        },
113    }
114}
115
116pub fn is_zsh() -> bool {
117    // only set on zsh
118    env::var("ATUIN_SHELL_ZSH").is_ok()
119}
120
121pub fn is_fish() -> bool {
122    // only set on fish
123    env::var("ATUIN_SHELL_FISH").is_ok()
124}
125
126pub fn is_bash() -> bool {
127    // only set on bash
128    env::var("ATUIN_SHELL_BASH").is_ok()
129}
130
131pub fn is_xonsh() -> bool {
132    // only set on xonsh
133    env::var("ATUIN_SHELL_XONSH").is_ok()
134}
135
136/// Extension trait for anything that can behave like a string to make it easy to escape control
137/// characters.
138///
139/// Intended to help prevent control characters being printed and interpreted by the terminal when
140/// printing history as well as to ensure the commands that appear in the interactive search
141/// reflect the actual command run rather than just the printable characters.
142pub trait Escapable: AsRef<str> {
143    fn escape_control(&self) -> Cow<str> {
144        if !self.as_ref().contains(|c: char| c.is_ascii_control()) {
145            self.as_ref().into()
146        } else {
147            let mut remaining = self.as_ref();
148            // Not a perfect way to reserve space but should reduce the allocations
149            let mut buf = String::with_capacity(remaining.as_bytes().len());
150            while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {
151                // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char
152                buf.push_str(&remaining[..i]);
153                buf.push('^');
154                buf.push(match remaining.as_bytes()[i] {
155                    0x7F => '?',
156                    code => char::from_u32(u32::from(code) + 64).unwrap(),
157                });
158                remaining = &remaining[i + 1..];
159            }
160            buf.push_str(remaining);
161            buf.into()
162        }
163    }
164}
165
166pub fn unquote(s: &str) -> Result<String> {
167    if s.chars().count() < 2 {
168        return Err(eyre!("not enough chars"));
169    }
170
171    let quote = s.chars().next().unwrap();
172
173    // not quoted, do nothing
174    if quote != '"' && quote != '\'' && quote != '`' {
175        return Ok(s.to_string());
176    }
177
178    if s.chars().last().unwrap() != quote {
179        return Err(eyre!("unexpected eof, quotes do not match"));
180    }
181
182    // removes quote characters
183    // the sanity checks performed above ensure that the quotes will be ASCII and this will not
184    // panic
185    let s = &s[1..s.len() - 1];
186
187    Ok(s.to_string())
188}
189
190impl<T: AsRef<str>> Escapable for T {}
191
192#[cfg(test)]
193mod tests {
194    use pretty_assertions::assert_ne;
195    use time::Month;
196
197    use super::*;
198    use std::env;
199
200    use std::collections::HashSet;
201
202    #[cfg(not(windows))]
203    #[test]
204    fn test_dirs() {
205        // these tests need to be run sequentially to prevent race condition
206        test_config_dir_xdg();
207        test_config_dir();
208        test_data_dir_xdg();
209        test_data_dir();
210    }
211
212    fn test_config_dir_xdg() {
213        env::remove_var("HOME");
214        env::set_var("XDG_CONFIG_HOME", "/home/user/custom_config");
215        assert_eq!(
216            config_dir(),
217            PathBuf::from("/home/user/custom_config/atuin")
218        );
219        env::remove_var("XDG_CONFIG_HOME");
220    }
221
222    fn test_config_dir() {
223        env::set_var("HOME", "/home/user");
224        env::remove_var("XDG_CONFIG_HOME");
225
226        assert_eq!(config_dir(), PathBuf::from("/home/user/.config/atuin"));
227
228        env::remove_var("HOME");
229    }
230
231    fn test_data_dir_xdg() {
232        env::remove_var("HOME");
233        env::set_var("XDG_DATA_HOME", "/home/user/custom_data");
234        assert_eq!(data_dir(), PathBuf::from("/home/user/custom_data/atuin"));
235        env::remove_var("XDG_DATA_HOME");
236    }
237
238    fn test_data_dir() {
239        env::set_var("HOME", "/home/user");
240        env::remove_var("XDG_DATA_HOME");
241        assert_eq!(data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
242        env::remove_var("HOME");
243    }
244
245    #[test]
246    fn days_from_month() {
247        assert_eq!(time::util::days_in_year_month(2023, Month::January), 31);
248        assert_eq!(time::util::days_in_year_month(2023, Month::February), 28);
249        assert_eq!(time::util::days_in_year_month(2023, Month::March), 31);
250        assert_eq!(time::util::days_in_year_month(2023, Month::April), 30);
251        assert_eq!(time::util::days_in_year_month(2023, Month::May), 31);
252        assert_eq!(time::util::days_in_year_month(2023, Month::June), 30);
253        assert_eq!(time::util::days_in_year_month(2023, Month::July), 31);
254        assert_eq!(time::util::days_in_year_month(2023, Month::August), 31);
255        assert_eq!(time::util::days_in_year_month(2023, Month::September), 30);
256        assert_eq!(time::util::days_in_year_month(2023, Month::October), 31);
257        assert_eq!(time::util::days_in_year_month(2023, Month::November), 30);
258        assert_eq!(time::util::days_in_year_month(2023, Month::December), 31);
259
260        // leap years
261        assert_eq!(time::util::days_in_year_month(2024, Month::February), 29);
262    }
263
264    #[test]
265    fn uuid_is_unique() {
266        let how_many: usize = 1000000;
267
268        // for peace of mind
269        let mut uuids: HashSet<Uuid> = HashSet::with_capacity(how_many);
270
271        // there will be many in the same millisecond
272        for _ in 0..how_many {
273            let uuid = uuid_v7();
274            uuids.insert(uuid);
275        }
276
277        assert_eq!(uuids.len(), how_many);
278    }
279
280    #[test]
281    fn escape_control_characters() {
282        use super::Escapable;
283        // CSI colour sequence
284        assert_eq!("\x1b[31mfoo".escape_control(), "^[[31mfoo");
285
286        // Tabs count as control chars
287        assert_eq!("foo\tbar".escape_control(), "foo^Ibar");
288
289        // space is in control char range but should be excluded
290        assert_eq!("two words".escape_control(), "two words");
291
292        // unicode multi-byte characters
293        let s = "🐢\x1b[32m🦀";
294        assert_eq!(s.escape_control(), s.replace("\x1b", "^["));
295    }
296
297    #[test]
298    fn escape_no_control_characters() {
299        use super::Escapable as _;
300        assert!(matches!(
301            "no control characters".escape_control(),
302            Cow::Borrowed(_)
303        ));
304        assert!(matches!(
305            "with \x1b[31mcontrol\x1b[0m characters".escape_control(),
306            Cow::Owned(_)
307        ));
308    }
309
310    #[test]
311    fn dumb_random_test() {
312        // Obviously not a test of randomness, but make sure we haven't made some
313        // catastrophic error
314
315        assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());
316        assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());
317        assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());
318        assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());
319        assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());
320        assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());
321    }
322}