Repository: ducaale/xh (rust)

Issue:
Expand tilde to home directory
On Linux, the following works in HTPPie:

`http -f POST example.com/files @~/.profile`

But when attempting `xh -f POST example.com/files @~/.profile`, I get "xh: error: No such file or directory (os error 2)".

It turns out the tilde is not being interpreted, and it works if I expand it manually: `xh -f POST example.com/files @/home/user/.profile`. I can also use `$HOME` so that my shell expands it for me, but I much prefer the brevity of `~`.

Pull request description:
Expand tilde in request items that contain a path
I have opted not to do the same for paths passed as an argument to flags such as `--session` and `--cert` as Bash and [Powershell 7.1](https://docs.microsoft.com/en-us/powershell/scripting/learn/experimental-features?view=powershell-7.2#psnativepspathresolution) both support expanding tilde in paths. Also, see https://stackoverflow.com/a/30027501/5915221.  

Resolves #189 

Patch:
diff --git a/src/request_items.rs b/src/request_items.rs
index 55fd9bb..6be8bd3 100644
--- a/src/request_items.rs
+++ b/src/request_items.rs
@@ -12,6 +12,7 @@ use reqwest::{blocking::multipart, Method};
 use structopt::clap;
 
 use crate::cli::BodyType;
+use crate::utils::expand_tilde;
 
 pub const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
 pub const JSON_CONTENT_TYPE: &str = "application/json";
@@ -302,13 +303,15 @@ impl RequestItems {
                     body.insert(key, value);
                 }
                 RequestItem::JsonFieldFromFile(key, value) => {
-                    body.insert(key, serde_json::from_str(&fs::read_to_string(value)?)?);
+                    let path = expand_tilde(value);
+                    body.insert(key, serde_json::from_str(&fs::read_to_string(path)?)?);
                 }
                 RequestItem::DataField(key, value) => {
                     body.insert(key, serde_json::Value::String(value));
                 }
                 RequestItem::DataFieldFromFile(key, value) => {
-                    body.insert(key, serde_json::Value::String(fs::read_to_string(value)?));
+                    let path = expand_tilde(value);
+                    body.insert(key, serde_json::Value::String(fs::read_to_string(path)?));
                 }
                 RequestItem::FormFile { .. } => unreachable!(),
                 RequestItem::HttpHeader(..) => {}
@@ -328,7 +331,8 @@ impl RequestItems {
                 }
                 RequestItem::DataField(key, value) => text_fields.push((key, value)),
                 RequestItem::DataFieldFromFile(key, value) => {
-                    text_fields.push((key, fs::read_to_string(value)?));
+                    let path = expand_tilde(value);
+                    text_fields.push((key, fs::read_to_string(path)?));
                 }
                 RequestItem::FormFile { .. } => unreachable!(),
                 RequestItem::HttpHeader(..) => {}
@@ -350,7 +354,8 @@ impl RequestItems {
                     form = form.text(key, value);
                 }
                 RequestItem::DataFieldFromFile(key, value) => {
-                    form = form.text(key, fs::read_to_string(value)?);
+                    let path = expand_tilde(value);
+                    form = form.text(key, fs::read_to_string(path)?);
                 }
                 RequestItem::FormFile {
                     key,
@@ -358,7 +363,7 @@ impl RequestItems {
                     file_type,
                     file_name_header,
                 } => {
-                    let mut part = file_to_part(&file_name)?;
+                    let mut part = file_to_part(expand_tilde(&file_name))?;
                     if let Some(file_type) = file_type {
                         part = part.mime_str(&file_type)?;
                     }
@@ -412,7 +417,7 @@ impl RequestItems {
                             .or_else(|| mime_guess::from_path(&file_name).first_raw())
                             .map(HeaderValue::from_str)
                             .transpose()?,
-                        file_name: file_name.into(),
+                        file_name: expand_tilde(file_name),
                         file_name_header,
                     });
                 }
diff --git a/src/utils.rs b/src/utils.rs
index b84bac3..63d0cf1 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -1,6 +1,6 @@
 use std::env::var_os;
 use std::io::{self, Write};
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 
 use anyhow::Result;
 use reqwest::blocking::Request;
@@ -59,6 +59,22 @@ pub fn get_home_dir() -> Option<PathBuf> {
     dirs::home_dir()
 }
 
+/// Perform simple tilde expansion if `dirs::home_dir()` is `Some(path)`.
+///
+/// Note that prefixed tilde e.g `~foo` is ignored.
+///
+/// See https://www.gnu.org/software/bash/manual/html_node/Tilde-Expansion.html
+pub fn expand_tilde(path: impl AsRef<Path>) -> PathBuf {
+    if let Ok(path) = path.as_ref().strip_prefix("~") {
+        let mut expanded_path = PathBuf::new();
+        expanded_path.push(get_home_dir().unwrap_or_else(|| "~".into()));
+        expanded_path.push(path);
+        expanded_path
+    } else {
+        path.as_ref().into()
+    }
+}
+
 // https://stackoverflow.com/a/45145246/5915221
 #[macro_export]
 macro_rules! vec_of_strings {


Test patch:
diff --git a/tests/cli.rs b/tests/cli.rs
index 78dcdf0..8cd9044 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -12,7 +12,7 @@ use std::time::Duration;
 use assert_cmd::cmd::Command;
 use indoc::indoc;
 use predicates::str::contains;
-use tempfile::{tempdir, NamedTempFile};
+use tempfile::{tempdir, NamedTempFile, TempDir};
 
 pub trait RequestExt {
     fn query_params(&self) -> HashMap<String, String>;
@@ -823,7 +823,7 @@ fn netrc_file_user_password_auth() {
             hyper::Response::default()
         });
 
-        let homedir = tempfile::TempDir::new().unwrap();
+        let homedir = TempDir::new().unwrap();
         let netrc_path = homedir.path().join(netrc_file);
         let mut netrc = File::create(&netrc_path).unwrap();
         writeln!(
@@ -2707,3 +2707,44 @@ fn encoding_detection() {
     // (even for non-ASCII-compatible encodings)
     case("text/plain; charset=UTF-16", "\0\0", BINARY_SUPPRESSOR);
 }
+
+#[test]
+fn tilde_expanded_in_request_items() {
+    let homedir = TempDir::new().unwrap();
+
+    std::fs::write(homedir.path().join("secret_key.txt"), "sxemfalm.....").unwrap();
+    get_command()
+        .env("HOME", homedir.path())
+        .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path())
+        .args(&["--offline", ":", "key=@~/secret_key.txt"])
+        .assert()
+        .stdout(contains("sxemfalm....."))
+        .success();
+
+    std::fs::write(homedir.path().join("ids.json"), "[102,111,164]").unwrap();
+    get_command()
+        .env("HOME", homedir.path())
+        .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path())
+        .args(&["--offline", "--pretty=none", ":", "ids:=@~/ids.json"])
+        .assert()
+        .stdout(contains("[102,111,164]"))
+        .success();
+
+    std::fs::write(homedir.path().join("moby-dick.txt"), "Call me Ishmael.").unwrap();
+    get_command()
+        .env("HOME", homedir.path())
+        .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path())
+        .args(&["--offline", "--form", ":", "content@~/moby-dick.txt"])
+        .assert()
+        .stdout(contains("Call me Ishmael."))
+        .success();
+
+    std::fs::write(homedir.path().join("random_file"), "random data").unwrap();
+    get_command()
+        .env("HOME", homedir.path())
+        .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path())
+        .args(&["--offline", ":", "@~/random_file"])
+        .assert()
+        .stdout(contains("random data"))
+        .success();
+}