resolve_path/
lib.rs

1//! A crate for resolving relative (`./`) and tilde paths (`~/`) in Rust.
2//!
3//! Note that this does not perform _path canonicalization_, i.e. it will
4//! not eliminate segments like `..` or `./././` in a path. This crate
5//! is intended simply to anchor relative paths such that they have an
6//! absolute path from the root.
7//!
8//! # Motivation
9//!
10//! Rust has `Path` and `PathBuf` in the standard library for working with
11//! file paths, but unfortunately there is no easy and ergonomic way to
12//! resolve relative paths in the following ways:
13//!
14//! - with respect to the process current-working-directory (CWD)
15//! - with respect to the active user's home directory (`~/`)
16//! - with respect to a user-provided absolute path
17//!
18//! # API
19//!
20//! This crate provides an extension trait [`PathResolveExt`] with extension
21//! methods for path-like types. The following methods are provided:
22//!
23//! ## `resolve` and `try_resolve`
24//!
25//! These methods will resolve relative paths (`./...`) with respect to the
26//! process current-working-directory, and will also resolve tilde-paths (`~/...`)
27//! to the active user's home directory.
28//!
29//! Assuming a home directory of `/home/user` and a CWD of `/home/user/Documents`,
30//! the `resolve` methods will evaluate in the following ways:
31//!
32//! ```no_run
33//! use std::path::Path;
34//! use resolve_path::PathResolveExt;
35//!
36//! // Direct variant (may panic)
37//! assert_eq!("~/.vimrc".resolve(), Path::new("/home/user/.vimrc"));
38//! assert_eq!("./notes.txt".resolve(), Path::new("/home/user/Documents/notes.txt"));
39//!
40//! // Try variant (returns Result)
41//! assert_eq!("~/.vimrc".try_resolve().unwrap(), Path::new("/home/user/.vimrc"));
42//! assert_eq!("./notes.txt".try_resolve().unwrap(), Path::new("/home/user/Documents/notes.txt"));
43//! ```
44//!
45//! ## `resolve_in` and `try_resolve_in`
46//!
47//! These methods will resolve tilde-paths (`~/...`) in the normal way, but will
48//! resolve relative paths (`./...`) with respect to a provided base directory.
49//! This can be very useful, for example when evaluating paths given in a config
50//! file with respect to the location of the config file, rather than with respect
51//! to the process CWD.
52//!
53//! Assuming the same home directory of `/home/user` and CWD of `/home/user/Documents`,
54//! the `resolve_in` methods will evaluate in the following ways:
55//!
56//! ```no_run
57//! use std::path::Path;
58//! use resolve_path::PathResolveExt;
59//!
60//! // Direct variant (may panic)
61//! assert_eq!("~/.vimrc".resolve_in("~/.config/alacritty/"), Path::new("/home/user/.vimrc"));
62//! assert_eq!("./alacritty.yml".resolve_in("~/.config/alacritty/"), Path::new("/home/user/.config/alacritty/alacritty.yml"));
63//!
64//! // Try variant (returns Result)
65//! assert_eq!("~/.vimrc".try_resolve_in("~/.config/alacritty/").unwrap(), Path::new("/home/user/.vimrc"));
66//! assert_eq!("./alacritty.yml".try_resolve_in("~/.config/alacritty/").unwrap(), Path::new("/home/user/.config/alacritty/alacritty.yml"));
67//! ```
68//!
69//! ## Why use `Cow<Path>`?
70//!
71//! If any of the [`PathResolveExt`] methods are called on a path that does not
72//! actually need to be resolved (i.e. a path that is already absolute), then
73//! the resolver methods will simply return `Cow::Borrowed(&Path)` with the original
74//! path ref within. If resolution _does_ occur, then the path will one way or another
75//! be edited (e.g. by adding an absolute path prefix), and will be returned as
76//! a `Cow::Owned(PathBuf)`. This way we can avoid allocation where it is unnecessary.
77
78use std::borrow::Cow;
79use std::ffi::OsStr;
80use std::io::{Error as IoError, ErrorKind};
81use std::path::{Path, PathBuf};
82
83type Result<T, E = IoError> = core::result::Result<T, E>;
84
85/// Extension trait for resolving paths against a base path.
86///
87/// # Example
88///
89/// ```
90/// use std::path::Path;
91/// use resolve_path::PathResolveExt as _;
92/// assert_eq!(Path::new("./config.yml").resolve_in("/home/user/.app"), Path::new("/home/user/.app/config.yml"));
93/// ```
94pub trait PathResolveExt {
95    /// Resolves the path in the process's current directory
96    ///
97    /// # Example
98    ///
99    /// ```no_run
100    /// use std::path::Path;
101    /// use resolve_path::PathResolveExt;
102    /// std::env::set_current_dir("/home/user/.config/alacritty").unwrap();
103    /// let resolved = "./alacritty.yml".resolve();
104    /// assert_eq!(resolved, Path::new("/home/user/.config/alacritty"));
105    /// ```
106    ///
107    /// # Panics
108    ///
109    /// This function panics if:
110    ///
111    /// - It is unable to detect the current working directory
112    /// - It is unable to resolve the home directory for a tilde (`~`)
113    ///
114    /// See [`try_resolve`][`PathResolveExt::try_resolve`] for a non-panicking API.
115    fn resolve(&self) -> Cow<Path> {
116        self.try_resolve()
117            .expect("should resolve path in current directory")
118    }
119
120    /// Attempts to resolve the path in the process's current directory
121    ///
122    /// Returns an error if:
123    ///
124    /// - It is unable to detect the current working directory
125    /// - It is unable to resolve the home directory for a tilde (`~`)
126    fn try_resolve(&self) -> Result<Cow<Path>> {
127        let cwd = std::env::current_dir()?;
128        let resolved = self.try_resolve_in(&cwd)?;
129        Ok(resolved)
130    }
131
132    /// Resolves this path against a given base path.
133    ///
134    /// # Example
135    ///
136    /// ```
137    /// use std::path::{Path, PathBuf};
138    /// use resolve_path::PathResolveExt as _;
139    ///
140    /// assert_eq!("./config.yml".resolve_in("/home/user/.app"), Path::new("/home/user/.app/config.yml"));
141    /// assert_eq!(String::from("./config.yml").resolve_in("/home/user/.app"), Path::new("/home/user/.app/config.yml"));
142    /// assert_eq!(Path::new("./config.yml").resolve_in("/home/user/.app"), Path::new("/home/user/.app/config.yml"));
143    /// assert_eq!(PathBuf::from("./config.yml").resolve_in("/home/user/.app"), Path::new("/home/user/.app/config.yml"));
144    /// ```
145    ///
146    /// # Panics
147    ///
148    /// Panics if we attempt to resolve a `~` in either path and are
149    /// unable to determine the home directory from the environment
150    /// (using the `dirs` crate). See [`try_resolve_in`][`PathResolveExt::try_resolve_in`]
151    /// for a non-panicking option.
152    fn resolve_in<P: AsRef<Path>>(&self, base: P) -> Cow<Path> {
153        self.try_resolve_in(base).expect("should resolve path")
154    }
155
156    /// Resolves this path against a given base path, returning an error
157    /// if unable to resolve a home directory.
158    fn try_resolve_in<P: AsRef<Path>>(&self, base: P) -> Result<Cow<Path>>;
159}
160
161impl<T: AsRef<OsStr>> PathResolveExt for T {
162    fn try_resolve_in<P: AsRef<Path>>(&self, base: P) -> Result<Cow<Path>> {
163        try_resolve_path(base.as_ref(), Path::new(self))
164    }
165}
166
167fn try_resolve_path<'a>(base: &Path, to_resolve: &'a Path) -> Result<Cow<'a, Path>> {
168    // If the path to resolve is absolute, there's no relativity to resolve
169    if to_resolve.is_absolute() {
170        return Ok(Cow::Borrowed(to_resolve));
171    }
172
173    // If the path to resolve has a tilde, resolve it to home and be done
174    if to_resolve.starts_with(Path::new("~")) {
175        let resolved = resolve_tilde(to_resolve)?;
176        return Ok(resolved);
177    }
178
179    // Resolve the base path by expanding tilde if needed
180    let absolute_base = if base.is_absolute() {
181        base.to_owned()
182    } else {
183        // Attempt to resolve a tilde in the base path
184        let base_resolved_tilde = resolve_tilde(base)?;
185        if base_resolved_tilde.is_relative() {
186            return Err(IoError::new(
187                ErrorKind::InvalidData,
188                "the base path must be able to resolve to an absolute path",
189            ));
190        }
191
192        base_resolved_tilde.into_owned()
193    };
194
195    // If the base path points to a file, use that file's parent directory as the base
196    let base_directory = match std::fs::metadata(&absolute_base) {
197        Ok(meta) => {
198            // If we know this path points to a file, use the file's parent dir
199            if meta.is_file() {
200                match absolute_base.parent() {
201                    Some(parent) => parent.to_path_buf(),
202                    None => {
203                        return Err(IoError::new(
204                            ErrorKind::NotFound,
205                            "the base path points to a file with no parent directory",
206                        ))
207                    }
208                }
209            } else {
210                // If we know this path points to a dir, use it
211                absolute_base
212            }
213        }
214        // If we cannot get FS metadata about this path, just use it as-is
215        Err(_) => absolute_base,
216    };
217
218    let resolved = base_directory.join(to_resolve);
219    Ok(Cow::Owned(resolved))
220}
221
222/// Resolve a tilde in the given path to the home directory, if a tilde is present.
223///
224/// - If the path does not begin with a tilde, returns the original path
225/// - If the path is not valid UTF-8, returns the original path
226/// - If the tilde names another user (e.g. `~user`), returns the original path
227/// - Otherwise, resolves the tilde to the homedir and joins with the remaining path
228///
229/// # Example
230///
231/// ```ignore
232/// # use std::path::Path;
233/// # use resolve_path::resolve_tilde;
234/// assert_eq!(resolve_tilde(Path::new("~")).unwrap(), Path::new("/home/test"));
235/// assert_eq!(resolve_tilde(Path::new("~/.config")).unwrap(), Path::new("/home/test/.config"));
236/// assert_eq!(resolve_tilde(Path::new("/tmp/hello")).unwrap(), Path::new("/tmp/hello"));
237/// assert_eq!(resolve_tilde(Path::new("./configure")).unwrap(), Path::new("./configure"));
238/// ```
239fn resolve_tilde(path: &Path) -> Result<Cow<Path>> {
240    let home = home_dir().ok_or_else(|| IoError::new(ErrorKind::NotFound, "homedir not found"))?;
241    Ok(resolve_tilde_with_home(home, path))
242}
243
244/// Resolve a tilde in a given path to a _given_ home directory.
245///
246/// - If the path does not begin with a tilde, returns the original path
247/// - If the path is not valid UTF-8, returns the original path
248/// - If the tilde names another user (e.g. `~user`), returns the original path
249/// - Otherwise, resolves the tilde to the homedir and joins with the remaining path
250///
251/// # Example
252///
253/// ```ignore
254/// # use std::path::{Path, PathBuf};
255/// # use resolve_path::resolve_tilde_with_home;
256/// assert_eq!(resolve_tilde_with_home(PathBuf::from("/home/test"), Path::new("~")), Path::new("/home/test"));
257/// assert_eq!(resolve_tilde_with_home(PathBuf::from("/home/test"), Path::new("~/.config")), Path::new("/home/test/.config"));
258/// assert_eq!(resolve_tilde_with_home(PathBuf::from("/home/test"), Path::new("/tmp/hello")), Path::new("/tmp/hello"));
259/// assert_eq!(resolve_tilde_with_home(PathBuf::from("/home/test"), Path::new("./configure")), Path::new("./configure"));
260/// ```
261fn resolve_tilde_with_home(home: PathBuf, path: &Path) -> Cow<Path> {
262    // If this path has no tilde, return it as-is
263    if !path.starts_with(Path::new("~")) {
264        return Cow::Borrowed(path);
265    }
266
267    // If we have a tilde, strip it and convert the remainder to UTF-8 str slice
268    let path_str = match path.to_str() {
269        Some(s) => s,
270        None => return Cow::Borrowed(path),
271    };
272    let stripped = &path_str[1..];
273
274    // Support a solo "~" with no trailing path
275    if stripped.is_empty() {
276        return Cow::Owned(home);
277    }
278
279    // Support a path starting with "~/..."
280    if stripped.starts_with('/') {
281        let stripped = stripped.trim_start_matches('/');
282        let resolved = home.join(stripped);
283        return Cow::Owned(resolved);
284    }
285
286    // If we have something like "~user", return original path
287    Cow::Borrowed(path)
288}
289
290#[allow(unused)]
291#[cfg(not(test))]
292fn home_dir() -> Option<PathBuf> {
293    dirs::home_dir()
294}
295
296/// During testing, always resolve home to /home/test
297#[allow(unused)]
298#[cfg(test)]
299fn home_dir() -> Option<PathBuf> {
300    Some(PathBuf::from("/home/test"))
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use std::ffi::OsString;
307
308    #[test]
309    fn test_resolve_tilde() {
310        assert_eq!("~".resolve(), Path::new("/home/test"));
311        assert_eq!("~".to_string().resolve(), Path::new("/home/test"));
312        assert_eq!(Path::new("~").resolve(), Path::new("/home/test"));
313        assert_eq!(PathBuf::from("~").resolve(), Path::new("/home/test"));
314        assert_eq!(OsStr::new("~").resolve(), Path::new("/home/test"));
315        assert_eq!(OsString::from("~").resolve(), Path::new("/home/test"));
316    }
317
318    #[test]
319    fn test_resolve_tilde_slash() {
320        assert_eq!("~/".resolve(), Path::new("/home/test"));
321    }
322
323    #[test]
324    fn test_resolve_tilde_path() {
325        assert_eq!(
326            "~/.config/alacritty/alacritty.yml".resolve(),
327            Path::new("/home/test/.config/alacritty/alacritty.yml")
328        );
329    }
330
331    #[test]
332    fn test_resolve_tilde_multislash() {
333        assert_eq!("~/////".resolve(), Path::new("/home/test"));
334    }
335
336    #[test]
337    fn test_resolve_tilde_multislash_path() {
338        assert_eq!("~/////.config".resolve(), Path::new("/home/test/.config"));
339    }
340
341    #[test]
342    fn test_resolve_tilde_with_relative_segments() {
343        assert_eq!(
344            "~/.config/../.vim/".resolve(),
345            Path::new("/home/test/.config/../.vim/")
346        )
347    }
348
349    #[test]
350    fn test_resolve_path() {
351        assert_eq!(
352            "./config.yml".resolve_in("/home/user/.app"),
353            Path::new("/home/user/.app/config.yml")
354        );
355    }
356
357    #[test]
358    fn test_resolve_path_base_trailing_slash() {
359        assert_eq!(
360            "./config.yml".resolve_in("/home/user/.app/"),
361            Path::new("/home/user/.app/config.yml")
362        );
363    }
364
365    #[test]
366    fn test_resolve_path_with_tilde() {
367        assert_eq!(
368            "./config.yml".resolve_in("~/.app"),
369            Path::new("/home/test/.app/config.yml")
370        );
371    }
372
373    #[test]
374    fn test_resolve_absolute_path() {
375        assert_eq!(
376            "/etc/nixos/configuration.nix".resolve_in("/home/usr/.app"),
377            Path::new("/etc/nixos/configuration.nix")
378        );
379    }
380
381    #[test]
382    fn test_resolve_absolute_path2() {
383        assert_eq!(
384            "~/.config/alacritty/alacritty.yml".resolve_in("/tmp"),
385            Path::new("/home/test/.config/alacritty/alacritty.yml")
386        );
387    }
388
389    #[test]
390    fn test_resolve_relative_path() {
391        assert_eq!(
392            "../.app2/config.yml".resolve_in("/home/user/.app"),
393            Path::new("/home/user/.app/../.app2/config.yml")
394        );
395    }
396
397    #[test]
398    fn test_resolve_current_dir() {
399        assert_eq!(".".resolve_in("/home/user"), Path::new("/home/user"));
400    }
401
402    #[test]
403    fn test_resolve_cwd() {
404        std::env::set_current_dir("/tmp").unwrap();
405        assert_eq!("garbage.txt".resolve(), Path::new("/tmp/garbage.txt"));
406    }
407
408    #[test]
409    fn test_resolve_base_file() {
410        let base_path = "/tmp/path-resolve-test.txt";
411        std::fs::write(base_path, "Hello!").unwrap();
412        assert_eq!(
413            "./other-tmp-file.txt".resolve_in(base_path),
414            Path::new("/tmp/other-tmp-file.txt")
415        );
416    }
417}