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 user's desktop directory
141pub fn desktop_dir() -> PathBuf {
142    xdg_data_home().join("applications")
143}
144
145/// Returns the user's icons directory
146pub fn icons_dir() -> PathBuf {
147    xdg_data_home().join("icons/hicolor")
148}
149
150fn expand_variables(path: &str) -> PathResult<String> {
151    let mut result = String::with_capacity(path.len());
152    let mut chars = path.chars().peekable();
153
154    while let Some(c) = chars.next() {
155        match c {
156            '$' => {
157                if chars.peek() == Some(&'{') {
158                    chars.next();
159                    let var_name = consume_until(&mut chars, '}')?;
160                    expand_env_var(&var_name, &mut result, path)?;
161                } else {
162                    let var_name = consume_var_name(&mut chars);
163                    if var_name.is_empty() {
164                        result.push('$');
165                    } else {
166                        expand_env_var(&var_name, &mut result, path)?;
167                    }
168                }
169            }
170            '~' if result.is_empty() => result.push_str(&home_dir().to_string_lossy()),
171            _ => result.push(c),
172        }
173    }
174
175    Ok(result)
176}
177
178fn consume_until(
179    chars: &mut std::iter::Peekable<std::str::Chars>,
180    delimiter: char,
181) -> PathResult<String> {
182    let mut var_name = String::new();
183
184    for c in chars.by_ref() {
185        if c == delimiter {
186            return Ok(var_name);
187        }
188        var_name.push(c);
189    }
190
191    Err(PathError::UnclosedVariable {
192        input: format!("${{{var_name}"),
193    })
194}
195
196fn consume_var_name(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
197    let mut var_name = String::new();
198
199    while let Some(&c) = chars.peek() {
200        if c.is_alphanumeric() || c == '_' {
201            var_name.push(chars.next().unwrap());
202        } else {
203            break;
204        }
205    }
206
207    var_name
208}
209
210fn expand_env_var(var_name: &str, result: &mut String, original: &str) -> PathResult<()> {
211    match var_name {
212        "HOME" => result.push_str(&home_dir().to_string_lossy()),
213        "XDG_CONFIG_HOME" => result.push_str(&xdg_config_home().to_string_lossy()),
214        "XDG_DATA_HOME" => result.push_str(&xdg_data_home().to_string_lossy()),
215        "XDG_CACHE_HOME" => result.push_str(&xdg_cache_home().to_string_lossy()),
216        _ => {
217            let value = env::var(var_name).map_err(|_| {
218                PathError::MissingEnvVar {
219                    input: original.into(),
220                    var: var_name.into(),
221                }
222            })?;
223            result.push_str(&value);
224        }
225    }
226    Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231    use std::env;
232
233    use serial_test::serial;
234
235    use super::*;
236
237    #[test]
238    fn test_expand_variables_simple() {
239        env::set_var("TEST_VAR", "test_value");
240
241        let result = expand_variables("$TEST_VAR/path").unwrap();
242        assert_eq!(result, "test_value/path");
243
244        env::remove_var("TEST_VAR");
245    }
246
247    #[test]
248    fn test_expand_variables_braces() {
249        env::set_var("TEST_VAR_BRACES", "test_value");
250
251        let result = expand_variables("${TEST_VAR_BRACES}/path").unwrap();
252        assert_eq!(result, "test_value/path");
253
254        env::remove_var("TEST_VAR_BRACES");
255    }
256
257    #[test]
258    fn test_expand_variables_missing_braces() {
259        env::set_var("TEST_VAR_MISSING_BRACES", "test_value");
260
261        let result = expand_variables("${TEST_VAR_MISSING_BRACES");
262        assert!(result.is_err());
263
264        env::remove_var("TEST_VAR_MISSING_BRACES");
265    }
266
267    #[test]
268    fn test_expand_variables_missing_var() {
269        let result = expand_variables("$THIS_VAR_DOESNT_EXIST");
270        assert!(result.is_err());
271    }
272
273    #[test]
274    fn test_consume_var_name() {
275        let mut chars = "VAR_NAME_123/extra".chars().peekable();
276        let var_name = consume_var_name(&mut chars);
277        assert_eq!(var_name, "VAR_NAME_123");
278    }
279
280    #[test]
281    #[serial]
282    fn test_xdg_directories() {
283        // We need to set HOME to have a predictable home directory for the test
284        env::set_var("HOME", "/tmp/home");
285        let home = home_dir();
286        assert_eq!(home, PathBuf::from("/tmp/home"));
287
288        // Test without XDG variables set
289        env::remove_var("XDG_CONFIG_HOME");
290        env::remove_var("XDG_DATA_HOME");
291        env::remove_var("XDG_CACHE_HOME");
292
293        let config = xdg_config_home();
294        let data = xdg_data_home();
295        let cache = xdg_cache_home();
296
297        assert_eq!(config, home.join(".config"));
298        assert_eq!(data, home.join(".local/share"));
299        assert_eq!(cache, home.join(".cache"));
300        assert!(config.is_absolute());
301        assert!(data.is_absolute());
302        assert!(cache.is_absolute());
303
304        // Test with XDG variables set
305        env::set_var("XDG_CONFIG_HOME", "/tmp/config");
306        env::set_var("XDG_DATA_HOME", "/tmp/data");
307        env::set_var("XDG_CACHE_HOME", "/tmp/cache");
308
309        assert_eq!(xdg_config_home(), PathBuf::from("/tmp/config"));
310        assert_eq!(xdg_data_home(), PathBuf::from("/tmp/data"));
311        assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/cache"));
312
313        env::remove_var("XDG_CONFIG_HOME");
314        env::remove_var("XDG_DATA_HOME");
315        env::remove_var("XDG_CACHE_HOME");
316        env::remove_var("HOME");
317    }
318
319    #[test]
320    #[serial]
321    fn test_resolve_path() {
322        env::set_var("HOME", "/tmp/home");
323
324        assert!(resolve_path("").is_err());
325
326        // Absolute path
327        assert_eq!(
328            resolve_path("/absolute/path").unwrap(),
329            PathBuf::from("/absolute/path")
330        );
331
332        // Relative path
333        let expected_relative = env::current_dir().unwrap().join("relative/path");
334        assert_eq!(resolve_path("relative/path").unwrap(), expected_relative);
335
336        // Tilde path
337        let home = home_dir();
338        assert_eq!(resolve_path("~/path").unwrap(), home.join("path"));
339        assert_eq!(resolve_path("~").unwrap(), home);
340
341        // Tilde not at start
342        let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start");
343        assert_eq!(
344            resolve_path("not/at/~/start").unwrap(),
345            expected_tilde_middle
346        );
347        env::remove_var("HOME");
348
349        // Unclosed variable
350        let result = resolve_path("${VAR");
351        assert!(result.is_err());
352
353        // Missing variable
354        let result = resolve_path("${VAR}");
355        assert!(result.is_err());
356    }
357
358    #[test]
359    #[serial]
360    fn test_home_dir() {
361        // Test with HOME set
362        env::set_var("HOME", "/tmp/home");
363        assert_eq!(home_dir(), PathBuf::from("/tmp/home"));
364
365        // Test with HOME unset
366        env::remove_var("HOME");
367        let expected = PathBuf::from(format!("/home/{}", get_username()));
368        assert_eq!(home_dir(), expected);
369    }
370
371    #[test]
372    #[serial]
373    fn test_expand_variables_edge_cases() {
374        env::set_var("HOME", "/tmp/home");
375
376        // Dollar at the end
377        assert_eq!(expand_variables("path/$").unwrap(), "path/$");
378
379        // Dollar with invalid char
380        assert_eq!(
381            expand_variables("path/$!invalid").unwrap(),
382            "path/$!invalid"
383        );
384
385        // Multiple variables
386        env::set_var("VAR1", "val1");
387        env::set_var("VAR2", "val2");
388        assert_eq!(expand_variables("$VAR1/${VAR2}").unwrap(), "val1/val2");
389        env::remove_var("VAR1");
390        env::remove_var("VAR2");
391
392        // Tilde expansion
393        let home_str = home_dir().to_string_lossy().to_string();
394        assert_eq!(
395            expand_variables("~/path").unwrap(),
396            format!("{}/path", home_str)
397        );
398        assert_eq!(expand_variables("~").unwrap(), home_str);
399        assert_eq!(expand_variables("a/~/b").unwrap(), "a/~/b");
400        env::remove_var("HOME");
401    }
402
403    #[test]
404    #[serial]
405    fn test_resolve_path_invalid_cwd() {
406        let temp_dir = tempfile::tempdir().unwrap();
407        let invalid_path = temp_dir.path().join("invalid");
408        std::fs::create_dir(&invalid_path).unwrap();
409
410        let original_cwd = env::current_dir().unwrap();
411        env::set_current_dir(&invalid_path).unwrap();
412        std::fs::remove_dir(&invalid_path).unwrap();
413
414        let result = resolve_path("relative/path");
415        assert!(result.is_err());
416
417        // Restore cwd
418        env::set_current_dir(original_cwd).unwrap();
419    }
420
421    #[test]
422    #[serial]
423    fn test_expand_env_var_special_vars() {
424        env::set_var("HOME", "/tmp/home");
425        env::remove_var("XDG_CONFIG_HOME");
426        env::remove_var("XDG_DATA_HOME");
427        env::remove_var("XDG_CACHE_HOME");
428
429        let mut result = String::new();
430        expand_env_var("HOME", &mut result, "$HOME").unwrap();
431        assert_eq!(result, "/tmp/home");
432
433        result.clear();
434        expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME").unwrap();
435        assert_eq!(result, "/tmp/home/.config");
436
437        result.clear();
438        expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME").unwrap();
439        assert_eq!(result, "/tmp/home/.local/share");
440
441        result.clear();
442        expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME").unwrap();
443        assert_eq!(result, "/tmp/home/.cache");
444
445        env::remove_var("HOME");
446    }
447
448    #[test]
449    #[serial]
450    fn test_desktop_dir() {
451        env::set_var("XDG_DATA_HOME", "/tmp/data");
452        let desktop = desktop_dir();
453        assert_eq!(desktop, PathBuf::from("/tmp/data/applications"));
454    }
455
456    #[test]
457    #[serial]
458    fn test_icons_dir() {
459        env::set_var("XDG_DATA_HOME", "/tmp/data");
460        let icons = icons_dir();
461        assert_eq!(icons, PathBuf::from("/tmp/data/icons/hicolor"));
462    }
463}