Clément Joly
Clément Joly

Reputation: 983

Expand tilde in Rust Path idiomatically

Sometimes, for instance when reading some configuration file, you read a file path entered by the user without going through the shell (for instance, you get ~/test).

As Option 2 below doesn’t write to test file in user home directory, I’m wondering if there is something more idiomatic than Option 1.

use std::env::var;
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;

fn write_to(path: &Path) {
    let mut f = File::create(path).unwrap();
    f.write_all("Hi".as_bytes()).unwrap();
}

fn main() {
    // Option 1
    let from_env = format!("{}/test", var("HOME").unwrap());
    let with_var = Path::new(&from_env);
    // Create $HOME/test
    write_to(with_var);

    // Option 2
    let with_tilde = Path::new("~/test");
    // Create the test file in current directory, provided a directory ./~ exists
    write_to(with_tilde);
}

Note: unwrap() is used here to keep the example short. There should be some error handling in production code.

Upvotes: 34

Views: 13286

Answers (2)

schneiderfelipe
schneiderfelipe

Reputation: 492

EDIT:

The expanduser crate probably does everything you want, including expansion of ~user. An example (adapted from the documentation):

use expanduser::expanduser;

let path = expanduser("~foolmeonce/path/to/directory")?;
assert_eq!(path.display().to_string(), "/home/foolmeonce/path/to/directory");

let path = expanduser("~/path/to/directory")?;
assert_eq!(path.display().to_string(), "/home/foolmetwice/path/to/directory");

ORIGINAL ANSWER:

Here's an implementation returning a Cow<Path>, so that we only allocate if there's actually a tilde prefix in the path:

use std::{borrow::Cow, path::Path};

use directories::UserDirs;
use lazy_static::lazy_static;

fn expand_home_dir<'a, P: AsRef<Path> + ?Sized>(path: &'a P) -> Cow<'a, Path> {
    let path = path.as_ref();

    if !path.starts_with("~") {
        return path.into();
    }

    lazy_static! {
        static ref HOME_DIR: &'static Path = UserDirs::new().unwrap().home_dir();
    }

    HOME_DIR.join(path.strip_prefix("~").unwrap()).into()
}

Things to notice:

  • The home directory is retrieved at most once.
  • The only unwrap that could fail is the one in the lazy_static! block, but there's no recovery from it.
  • The only possible allocation happening is in join.

Some usage examples:

#[test]
fn test_expand_home_dir() {
    lazy_static! {
        static ref HOME_DIR: String = std::env::var("HOME").unwrap();
    }

    // Simple prefix expansion.
    assert_eq!(
        expand_home_dir("~/a/path/to/a/file"),
        Path::new(&format!("{}/a/path/to/a/file", &*HOME_DIR))
    );

    // Lone tilde is user's home directory.
    assert_eq!(expand_home_dir("~"), Path::new(&*HOME_DIR));

    // Tilde in the middle of a path should not be expanded.
    assert_eq!(
        expand_home_dir("/a/~/path/to/a/file"),
        Path::new("/a/~/path/to/a/file")
    );

    // No tilde, no expansion in absolute paths.
    assert_eq!(
        expand_home_dir("/a/path/to/a/file"),
        Path::new("/a/path/to/a/file")
    );

    // No tilde, no expansion in relative paths.
    assert_eq!(
        expand_home_dir("another/path/to/a/file"),
        Path::new("another/path/to/a/file")
    );
}

Upvotes: 5

Andrey Tyukin
Andrey Tyukin

Reputation: 44918

  1. The most idiomatic way would be to just use an existing crate, in this case shellexpand (github, crates.io) seems to do what you want:

    extern crate shellexpand; // 1.0.0
    
    #[test]
    fn test_shellexpand() {
        let home = std::env::var("HOME").unwrap();
        assert_eq!(shellexpand::tilde("~/foo"), format!("{}/foo", home));
    }
    
  2. Alternatively, you could try it with dirs (crates.io). Here is a sketch:

    extern crate dirs; // 1.0.4
    
    use std::path::{Path, PathBuf};
    
    fn expand_tilde<P: AsRef<Path>>(path_user_input: P) -> Option<PathBuf> {
        let p = path_user_input.as_ref();
        if !p.starts_with("~") {
            return Some(p.to_path_buf());
        }
        if p == Path::new("~") {
            return dirs::home_dir();
        }
        dirs::home_dir().map(|mut h| {
            if h == Path::new("/") {
                // Corner case: `h` root directory;
                // don't prepend extra `/`, just drop the tilde.
                p.strip_prefix("~").unwrap().to_path_buf()
            } else {
                h.push(p.strip_prefix("~/").unwrap());
                h
            }
        })
    }
    

    Usage examples:

    #[test]
    fn test_expand_tilde() {
        // Should work on your linux box during tests, would fail in stranger
        // environments!
        let home = std::env::var("HOME").unwrap();
        let projects = PathBuf::from(format!("{}/Projects", home));
        assert_eq!(expand_tilde("~/Projects"), Some(projects));
        assert_eq!(expand_tilde("/foo/bar"), Some("/foo/bar".into()));
        assert_eq!(
            expand_tilde("~alice/projects"),
            Some("~alice/projects".into())
        );
    }
    

    Some remarks:

    • The P: AsRef<Path> input type imitates what the standard library does. This is why the method accepts all Path-like inputs, like &str, &OsStr, and &Path.
    • Path::new doesn't allocate anything, it points to exactly the same bytes as the &str.
    • strip_prefix("~/").unwrap() should never fail here, because we checked that the path starts with ~ and is not just ~. The only way how this can be is that the path starts with ~/ (because of how starts_with is defined).

Upvotes: 41

Related Questions