Skip to main content

soar_utils/
path.rs

1use std::{env, path::PathBuf};
2
3use crate::{
4    error::{PathError, PathResult},
5    system::get_username,
6};
7
8/// Resolves a path string that may contain environment variables
9///
10/// This method expands environment variables in the format `$VAR` or `${VAR}`, resolves tilde
11/// (`~`) to the user's home directory when it appears at the start of the path, and converts
12/// relative paths to absolute paths based on the current working directory.
13///
14/// # Arguments
15///
16/// * `path` - The path string that may contain environment variables and tilde expansion
17///
18/// # Returns
19///
20/// Returns an absolute [`PathBuf`] with all variables expanded, or a [`PathError`] if the path
21/// is invalid or variables cannot be resolved.
22///
23/// # Errors
24///
25/// * [`PathError::Empty`] if the path is empty
26/// * [`PathError::CurrentDir`] if the current directory cannot be determined
27/// * [`PathError::MissingEnvVar`] if the environment variables are undefined
28///
29/// # Example
30///
31/// ```
32/// use soar_utils::error::PathResult;
33/// use soar_utils::path::resolve_path;
34///
35/// fn main() -> PathResult<()> {
36///     let resolved = resolve_path("$HOME/path/to/file")?;
37///     println!("Resolved path is {:#?}", resolved);
38///     Ok(())
39/// }
40/// ```
41pub fn resolve_path(path: &str) -> PathResult<PathBuf> {
42    let path = path.trim();
43
44    if path.is_empty() {
45        return Err(PathError::Empty);
46    }
47
48    let resolved = expand_variables(path)?;
49    let path_buf = PathBuf::from(resolved);
50
51    if path_buf.is_absolute() {
52        Ok(path_buf)
53    } else {
54        env::current_dir()
55            .map(|cwd| cwd.join(path_buf))
56            .map_err(|err| {
57                PathError::FailedToGetCurrentDir {
58                    source: err,
59                }
60            })
61    }
62}
63
64/// Returns the user's home directory
65///
66/// This method first checks the `HOME` environment variables. If not set, it falls back to
67/// constructing the path `/home/{username}` where username is obtained from the system.
68///
69/// # Example
70///
71/// ```
72/// use soar_utils::path::home_dir;
73///
74/// let home = home_dir();
75/// println!("Home dir is {:#?}", home);
76/// ```
77pub fn home_dir() -> PathBuf {
78    env::var("HOME")
79        .map(PathBuf::from)
80        .unwrap_or_else(|_| PathBuf::from(format!("/home/{}", get_username())))
81}
82
83/// Returns the user's config directory following XDG Base Directory Specification
84///
85/// This method checks the `XDG_CONFIG_HOME` environment variable. If not set, it defaults to
86/// `$HOME/.config`
87///
88/// # Example
89///
90/// ```
91/// use soar_utils::path::xdg_config_home;
92///
93/// let config = xdg_config_home();
94/// println!("Config dir is {:#?}", config);
95/// ```
96pub fn xdg_config_home() -> PathBuf {
97    env::var("XDG_CONFIG_HOME")
98        .map(PathBuf::from)
99        .unwrap_or_else(|_| home_dir().join(".config"))
100}
101
102/// Returns the user's data directory following XDG Base Directory Specification
103///
104/// This method checks the `XDG_DATA_HOME` environment variable. If not set, it defaults to
105/// `$HOME/.local/share`
106///
107/// # Example
108///
109/// ```
110/// use soar_utils::path::xdg_data_home;
111///
112/// let data = xdg_data_home();
113/// println!("Data dir is {:#?}", data);
114/// ```
115pub fn xdg_data_home() -> PathBuf {
116    env::var("XDG_DATA_HOME")
117        .map(PathBuf::from)
118        .unwrap_or_else(|_| home_dir().join(".local/share"))
119}
120
121/// Returns the user's cache directory following XDG Base Directory Specification
122///
123/// This method checks the `XDG_CACHE_HOME` environment variable. If not set, it defaults to
124/// `$HOME/.cache`
125///
126/// # Example
127///
128/// ```
129/// use soar_utils::path::xdg_cache_home;
130///
131/// let cache = xdg_cache_home();
132/// println!("Cache dir is {:#?}", cache);
133/// ```
134pub fn xdg_cache_home() -> PathBuf {
135    env::var("XDG_CACHE_HOME")
136        .map(PathBuf::from)
137        .unwrap_or_else(|_| home_dir().join(".cache"))
138}
139
140/// Returns the desktop directory
141pub fn desktop_dir(system: bool) -> PathBuf {
142    if system {
143        PathBuf::from("/usr/local/share/applications")
144    } else {
145        xdg_data_home().join("applications")
146    }
147}
148
149/// Returns the icons directory
150pub fn icons_dir(system: bool) -> PathBuf {
151    if system {
152        PathBuf::from("/usr/local/share/icons/hicolor")
153    } else {
154        xdg_data_home().join("icons/hicolor")
155    }
156}
157
158fn expand_variables(path: &str) -> PathResult<String> {
159    let mut result = String::with_capacity(path.len());
160    let mut chars = path.chars().peekable();
161
162    while let Some(c) = chars.next() {
163        match c {
164            '$' => {
165                if chars.peek() == Some(&'{') {
166                    chars.next();
167                    let var_name = consume_until(&mut chars, '}')?;
168                    expand_env_var(&var_name, &mut result, path)?;
169                } else {
170                    let var_name = consume_var_name(&mut chars);
171                    if var_name.is_empty() {
172                        result.push('$');
173                    } else {
174                        expand_env_var(&var_name, &mut result, path)?;
175                    }
176                }
177            }
178            '~' if result.is_empty() => result.push_str(&home_dir().to_string_lossy()),
179            _ => result.push(c),
180        }
181    }
182
183    Ok(result)
184}
185
186fn consume_until(
187    chars: &mut std::iter::Peekable<std::str::Chars>,
188    delimiter: char,
189) -> PathResult<String> {
190    let mut var_name = String::new();
191
192    for c in chars.by_ref() {
193        if c == delimiter {
194            return Ok(var_name);
195        }
196        var_name.push(c);
197    }
198
199    Err(PathError::UnclosedVariable {
200        input: format!("${{{var_name}"),
201    })
202}
203
204fn consume_var_name(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
205    let mut var_name = String::new();
206
207    while let Some(&c) = chars.peek() {
208        if c.is_alphanumeric() || c == '_' {
209            var_name.push(chars.next().unwrap());
210        } else {
211            break;
212        }
213    }
214
215    var_name
216}
217
218fn expand_env_var(var_name: &str, result: &mut String, original: &str) -> PathResult<()> {
219    match var_name {
220        "HOME" => result.push_str(&home_dir().to_string_lossy()),
221        "XDG_CONFIG_HOME" => result.push_str(&xdg_config_home().to_string_lossy()),
222        "XDG_DATA_HOME" => result.push_str(&xdg_data_home().to_string_lossy()),
223        "XDG_CACHE_HOME" => result.push_str(&xdg_cache_home().to_string_lossy()),
224        _ => {
225            let value = env::var(var_name).map_err(|_| {
226                PathError::MissingEnvVar {
227                    input: original.into(),
228                    var: var_name.into(),
229                }
230            })?;
231            result.push_str(&value);
232        }
233    }
234    Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239    use std::env;
240
241    use serial_test::serial;
242
243    use super::*;
244
245    #[test]
246    fn test_expand_variables_simple() {
247        env::set_var("TEST_VAR", "test_value");
248
249        let result = expand_variables("$TEST_VAR/path").unwrap();
250        assert_eq!(result, "test_value/path");
251
252        env::remove_var("TEST_VAR");
253    }
254
255    #[test]
256    fn test_expand_variables_braces() {
257        env::set_var("TEST_VAR_BRACES", "test_value");
258
259        let result = expand_variables("${TEST_VAR_BRACES}/path").unwrap();
260        assert_eq!(result, "test_value/path");
261
262        env::remove_var("TEST_VAR_BRACES");
263    }
264
265    #[test]
266    fn test_expand_variables_missing_braces() {
267        env::set_var("TEST_VAR_MISSING_BRACES", "test_value");
268
269        let result = expand_variables("${TEST_VAR_MISSING_BRACES");
270        assert!(result.is_err());
271
272        env::remove_var("TEST_VAR_MISSING_BRACES");
273    }
274
275    #[test]
276    fn test_expand_variables_missing_var() {
277        let result = expand_variables("$THIS_VAR_DOESNT_EXIST");
278        assert!(result.is_err());
279    }
280
281    #[test]
282    fn test_consume_var_name() {
283        let mut chars = "VAR_NAME_123/extra".chars().peekable();
284        let var_name = consume_var_name(&mut chars);
285        assert_eq!(var_name, "VAR_NAME_123");
286    }
287
288    #[test]
289    #[serial]
290    fn test_xdg_directories() {
291        // We need to set HOME to have a predictable home directory for the test
292        env::set_var("HOME", "/tmp/home");
293        let home = home_dir();
294        assert_eq!(home, PathBuf::from("/tmp/home"));
295
296        // Test without XDG variables set
297        env::remove_var("XDG_CONFIG_HOME");
298        env::remove_var("XDG_DATA_HOME");
299        env::remove_var("XDG_CACHE_HOME");
300
301        let config = xdg_config_home();
302        let data = xdg_data_home();
303        let cache = xdg_cache_home();
304
305        assert_eq!(config, home.join(".config"));
306        assert_eq!(data, home.join(".local/share"));
307        assert_eq!(cache, home.join(".cache"));
308        assert!(config.is_absolute());
309        assert!(data.is_absolute());
310        assert!(cache.is_absolute());
311
312        // Test with XDG variables set
313        env::set_var("XDG_CONFIG_HOME", "/tmp/config");
314        env::set_var("XDG_DATA_HOME", "/tmp/data");
315        env::set_var("XDG_CACHE_HOME", "/tmp/cache");
316
317        assert_eq!(xdg_config_home(), PathBuf::from("/tmp/config"));
318        assert_eq!(xdg_data_home(), PathBuf::from("/tmp/data"));
319        assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/cache"));
320
321        env::remove_var("XDG_CONFIG_HOME");
322        env::remove_var("XDG_DATA_HOME");
323        env::remove_var("XDG_CACHE_HOME");
324        env::remove_var("HOME");
325    }
326
327    #[test]
328    #[serial]
329    fn test_resolve_path() {
330        env::set_var("HOME", "/tmp/home");
331
332        assert!(resolve_path("").is_err());
333
334        // Absolute path
335        assert_eq!(
336            resolve_path("/absolute/path").unwrap(),
337            PathBuf::from("/absolute/path")
338        );
339
340        // Relative path
341        let expected_relative = env::current_dir().unwrap().join("relative/path");
342        assert_eq!(resolve_path("relative/path").unwrap(), expected_relative);
343
344        // Tilde path
345        let home = home_dir();
346        assert_eq!(resolve_path("~/path").unwrap(), home.join("path"));
347        assert_eq!(resolve_path("~").unwrap(), home);
348
349        // Tilde not at start
350        let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start");
351        assert_eq!(
352            resolve_path("not/at/~/start").unwrap(),
353            expected_tilde_middle
354        );
355        env::remove_var("HOME");
356
357        // Unclosed variable
358        let result = resolve_path("${VAR");
359        assert!(result.is_err());
360
361        // Missing variable
362        let result = resolve_path("${VAR}");
363        assert!(result.is_err());
364    }
365
366    #[test]
367    #[serial]
368    fn test_home_dir() {
369        // Test with HOME set
370        env::set_var("HOME", "/tmp/home");
371        assert_eq!(home_dir(), PathBuf::from("/tmp/home"));
372
373        // Test with HOME unset
374        env::remove_var("HOME");
375        let expected = PathBuf::from(format!("/home/{}", get_username()));
376        assert_eq!(home_dir(), expected);
377    }
378
379    #[test]
380    #[serial]
381    fn test_expand_variables_edge_cases() {
382        env::set_var("HOME", "/tmp/home");
383
384        // Dollar at the end
385        assert_eq!(expand_variables("path/$").unwrap(), "path/$");
386
387        // Dollar with invalid char
388        assert_eq!(
389            expand_variables("path/$!invalid").unwrap(),
390            "path/$!invalid"
391        );
392
393        // Multiple variables
394        env::set_var("VAR1", "val1");
395        env::set_var("VAR2", "val2");
396        assert_eq!(expand_variables("$VAR1/${VAR2}").unwrap(), "val1/val2");
397        env::remove_var("VAR1");
398        env::remove_var("VAR2");
399
400        // Tilde expansion
401        let home_str = home_dir().to_string_lossy().to_string();
402        assert_eq!(
403            expand_variables("~/path").unwrap(),
404            format!("{}/path", home_str)
405        );
406        assert_eq!(expand_variables("~").unwrap(), home_str);
407        assert_eq!(expand_variables("a/~/b").unwrap(), "a/~/b");
408        env::remove_var("HOME");
409    }
410
411    #[test]
412    #[serial]
413    fn test_resolve_path_invalid_cwd() {
414        let temp_dir = tempfile::tempdir().unwrap();
415        let invalid_path = temp_dir.path().join("invalid");
416        std::fs::create_dir(&invalid_path).unwrap();
417
418        let original_cwd = env::current_dir().unwrap();
419        env::set_current_dir(&invalid_path).unwrap();
420        std::fs::remove_dir(&invalid_path).unwrap();
421
422        let result = resolve_path("relative/path");
423        assert!(result.is_err());
424
425        // Restore cwd
426        env::set_current_dir(original_cwd).unwrap();
427    }
428
429    #[test]
430    #[serial]
431    fn test_expand_env_var_special_vars() {
432        env::set_var("HOME", "/tmp/home");
433        env::remove_var("XDG_CONFIG_HOME");
434        env::remove_var("XDG_DATA_HOME");
435        env::remove_var("XDG_CACHE_HOME");
436
437        let mut result = String::new();
438        expand_env_var("HOME", &mut result, "$HOME").unwrap();
439        assert_eq!(result, "/tmp/home");
440
441        result.clear();
442        expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME").unwrap();
443        assert_eq!(result, "/tmp/home/.config");
444
445        result.clear();
446        expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME").unwrap();
447        assert_eq!(result, "/tmp/home/.local/share");
448
449        result.clear();
450        expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME").unwrap();
451        assert_eq!(result, "/tmp/home/.cache");
452
453        env::remove_var("HOME");
454    }
455
456    #[test]
457    #[serial]
458    fn test_desktop_dir() {
459        // User mode
460        env::set_var("XDG_DATA_HOME", "/tmp/data");
461        let desktop = desktop_dir(false);
462        assert_eq!(desktop, PathBuf::from("/tmp/data/applications"));
463
464        // System mode
465        let desktop = desktop_dir(true);
466        assert_eq!(desktop, PathBuf::from("/usr/local/share/applications"));
467    }
468
469    #[test]
470    #[serial]
471    fn test_icons_dir() {
472        // User mode
473        env::set_var("XDG_DATA_HOME", "/tmp/data");
474        let icons = icons_dir(false);
475        assert_eq!(icons, PathBuf::from("/tmp/data/icons/hicolor"));
476
477        // System mode
478        let icons = icons_dir(true);
479        assert_eq!(icons, PathBuf::from("/usr/local/share/icons/hicolor"));
480    }
481}