Search code examples
pathrustdirectoryhome-directory

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.


Solution

    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).