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}