diff --git a/src/cli.rs b/src/cli.rs index dc83b737..8b706938 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,7 @@ use std::convert::TryFrom; use std::env; use std::ffi::OsString; use std::fmt; +use std::fs; use std::io::Write; use std::mem; use std::path::PathBuf; @@ -9,10 +10,11 @@ use std::str::FromStr; use std::time::Duration; use reqwest::{Method, Url}; +use serde::{Deserialize, Serialize}; use structopt::clap::{self, arg_enum, AppSettings, Error, ErrorKind, Result}; use structopt::StructOpt; -use crate::{buffer::Buffer, request_items::RequestItem}; +use crate::{buffer::Buffer, request_items::RequestItem, utils::config_dir}; // Some doc comments were copy-pasted from HTTPie @@ -33,6 +35,7 @@ use crate::{buffer::Buffer, request_items::RequestItem}; AppSettings::DeriveDisplayOrder, AppSettings::UnifiedHelpMessage, AppSettings::ColoredHelp, + AppSettings::AllArgsOverrideSelf, ], )] pub struct Cli { @@ -297,8 +300,17 @@ const NEGATION_FLAGS: &[&str] = &[ ]; impl Cli { - pub fn from_args() -> Self { - Cli::from_iter(std::env::args_os()) + pub fn parse() -> Self { + if let Some(default_args) = default_cli_args() { + let mut args = std::env::args_os(); + Cli::from_iter( + std::iter::once(args.next().unwrap_or_else(|| "xh".into())) + .chain(default_args.into_iter().map(Into::into)) + .chain(args), + ) + } else { + Cli::from_iter(std::env::args_os()) + } } pub fn from_iter(iter: I) -> Self @@ -481,6 +493,39 @@ impl Cli { } } +#[derive(Serialize, Deserialize)] +struct Config { + default_options: Vec, +} + +fn default_cli_args() -> Option> { + let content = match fs::read_to_string(config_dir()?.join("config.json")) { + Ok(file) => Some(file), + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + eprintln!( + "\n{}: warning: Unable to read config file: {}\n", + env!("CARGO_PKG_NAME"), + err + ); + } + None + } + }?; + + match serde_json::from_str::(&content) { + Ok(config) => Some(config.default_options), + Err(err) => { + eprintln!( + "\n{}: warning: Unable to parse config file: {}\n", + env!("CARGO_PKG_NAME"), + err + ); + None + } + } +} + fn parse_method(method: &str) -> Option { // This unfortunately matches "localhost" if !method.is_empty() && method.chars().all(|c| c.is_ascii_alphabetic()) { diff --git a/src/download.rs b/src/download.rs index dc9a80ec..f990504e 100644 --- a/src/download.rs +++ b/src/download.rs @@ -187,7 +187,7 @@ pub fn download_file( dest_name = file_name; buffer = Box::new(open_opts.open(&dest_name)?); } else if test_pretend_term() || atty::is(Stream::Stdout) { - let (new_name, handle) = open_new_file(get_file_name(&response, &orig_url).into())?; + let (new_name, handle) = open_new_file(get_file_name(&response, orig_url).into())?; dest_name = new_name; buffer = Box::new(handle); } else { @@ -208,7 +208,7 @@ pub fn download_file( total_length = Some(total_for_content_range(header, starting_length)?); } else { starting_length = 0; - total_length = get_content_length(&response.headers()); + total_length = get_content_length(response.headers()); } let starting_time = Instant::now(); diff --git a/src/main.rs b/src/main.rs index 7c0371cb..5e040270 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,7 @@ fn get_user_agent() -> &'static str { #[exit_status::main] fn main() -> Result { - let args = Cli::from_args(); + let args = Cli::parse(); if args.curl { to_curl::print_curl_translation(args)?; diff --git a/src/printer.rs b/src/printer.rs index b0f1cd2c..6aaf5fa9 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -13,7 +13,7 @@ use crate::{ buffer::Buffer, cli::{Pretty, Theme}, formatting::{get_json_formatter, Highlighter}, - utils::{copy_largebuf, get_content_type, test_mode, valid_json, ContentType, BUFFER_SIZE}, + utils::{copy_largebuf, test_mode, BUFFER_SIZE}, }; const BINARY_SUPPRESSOR: &str = concat!( @@ -299,7 +299,7 @@ impl Printer { } let request_line = format!("{} {}{} {:?}\n", method, url.path(), query_string, version); - let headers = &self.headers_to_string(&headers, self.sort_headers); + let headers = self.headers_to_string(&headers, self.sort_headers); self.print_headers(&(request_line + &headers))?; self.buffer.print("\n\n")?; @@ -320,7 +320,7 @@ impl Printer { } pub fn print_request_body(&mut self, request: &mut Request) -> anyhow::Result<()> { - let content_type = get_content_type(&request.headers()); + let content_type = get_content_type(request.headers()); if let Some(body) = request.body_mut() { let body = body.buffer()?; if body.contains(&b'\0') { @@ -336,7 +336,7 @@ impl Printer { } pub fn print_response_body(&mut self, mut response: Response) -> anyhow::Result<()> { - let content_type = get_content_type(&response.headers()); + let content_type = get_content_type(response.headers()); if !self.buffer.is_terminal() { if (self.color || self.indent_json) && content_type.is_text() { // The user explicitly asked for formatting even though this is @@ -387,6 +387,63 @@ impl Printer { } } +pub enum ContentType { + Json, + Html, + Xml, + JavaScript, + Css, + Text, + UrlencodedForm, + Multipart, + Unknown, +} + +impl ContentType { + pub fn is_text(&self) -> bool { + !matches!( + self, + ContentType::Unknown | ContentType::UrlencodedForm | ContentType::Multipart + ) + } +} + +pub fn get_content_type(headers: &HeaderMap) -> ContentType { + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|content_type| { + if content_type.contains("json") { + Some(ContentType::Json) + } else if content_type.contains("html") { + Some(ContentType::Html) + } else if content_type.contains("xml") { + Some(ContentType::Xml) + } else if content_type.contains("multipart") { + Some(ContentType::Multipart) + } else if content_type.contains("x-www-form-urlencoded") { + Some(ContentType::UrlencodedForm) + } else if content_type.contains("javascript") { + Some(ContentType::JavaScript) + } else if content_type.contains("css") { + Some(ContentType::Css) + } else if content_type.contains("text") { + // We later check if this one's JSON + // HTTPie checks for "json", "javascript" and "text" in one place: + // https://github.com/httpie/httpie/blob/a32ad344dd/httpie/output/formatters/json.py#L14 + // We have it more spread out but it behaves more or less the same + Some(ContentType::Text) + } else { + None + } + }) + .unwrap_or(ContentType::Unknown) +} + +pub fn valid_json(text: &str) -> bool { + serde_json::from_str::(text).is_ok() +} + /// Decode a streaming response in a way that matches `.text()`. /// /// Note that in practice this seems to behave like String::from_utf8_lossy(), diff --git a/src/request_items.rs b/src/request_items.rs index 4bf98f5b..0dfc3ef3 100644 --- a/src/request_items.rs +++ b/src/request_items.rs @@ -265,12 +265,12 @@ impl RequestItems { for item in &self.0 { match item { RequestItem::HttpHeader(key, value) => { - let key = HeaderName::from_bytes(&key.as_bytes())?; - let value = HeaderValue::from_str(&value)?; + let key = HeaderName::from_bytes(key.as_bytes())?; + let value = HeaderValue::from_str(value)?; headers.insert(key, value); } RequestItem::HttpHeaderToUnset(key) => { - let key = HeaderName::from_bytes(&key.as_bytes())?; + let key = HeaderName::from_bytes(key.as_bytes())?; headers_to_unset.push(key); } RequestItem::UrlParam(..) => {} diff --git a/src/url.rs b/src/url.rs index 01351240..abaa4681 100644 --- a/src/url.rs +++ b/src/url.rs @@ -16,7 +16,7 @@ pub fn construct_url( } let mut url: Url = if url.starts_with(':') { format!("{}{}{}", default_scheme, "localhost", url).parse()? - } else if !regex!("[a-zA-Z0-9]://.+").is_match(&url) { + } else if !regex!("[a-zA-Z0-9]://.+").is_match(url) { format!("{}{}", default_scheme, url).parse()? } else { url.parse()? diff --git a/src/utils.rs b/src/utils.rs index e44227cb..7750a4d1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,10 +1,9 @@ use std::{ env::var_os, io::{self, Write}, + path::PathBuf, }; -use reqwest::header::{HeaderMap, CONTENT_TYPE}; - /// Whether to make some things more deterministic for the benefit of tests pub fn test_mode() -> bool { // In integration tests the binary isn't compiled with cfg(test), so we @@ -21,59 +20,14 @@ pub fn test_default_color() -> bool { var_os("XH_TEST_MODE_COLOR").is_some() } -pub enum ContentType { - Json, - Html, - Xml, - JavaScript, - Css, - Text, - UrlencodedForm, - Multipart, - Unknown, -} - -impl ContentType { - pub fn is_text(&self) -> bool { - !matches!( - self, - ContentType::Unknown | ContentType::UrlencodedForm | ContentType::Multipart - ) +pub fn config_dir() -> Option { + if let Some(dir) = std::env::var_os("XH_CONFIG_DIR") { + Some(dir.into()) + } else { + dirs::config_dir().map(|dir| dir.join("xh")) } } -pub fn get_content_type(headers: &HeaderMap) -> ContentType { - headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .and_then(|content_type| { - if content_type.contains("json") { - Some(ContentType::Json) - } else if content_type.contains("html") { - Some(ContentType::Html) - } else if content_type.contains("xml") { - Some(ContentType::Xml) - } else if content_type.contains("multipart") { - Some(ContentType::Multipart) - } else if content_type.contains("x-www-form-urlencoded") { - Some(ContentType::UrlencodedForm) - } else if content_type.contains("javascript") { - Some(ContentType::JavaScript) - } else if content_type.contains("css") { - Some(ContentType::Css) - } else if content_type.contains("text") { - // We later check if this one's JSON - // HTTPie checks for "json", "javascript" and "text" in one place: - // https://github.com/httpie/httpie/blob/a32ad344dd/httpie/output/formatters/json.py#L14 - // We have it more spread out but it behaves more or less the same - Some(ContentType::Text) - } else { - None - } - }) - .unwrap_or(ContentType::Unknown) -} - // https://stackoverflow.com/a/45145246/5915221 #[macro_export] macro_rules! vec_of_strings { @@ -121,7 +75,3 @@ pub fn copy_largebuf(reader: &mut impl io::Read, writer: &mut impl Write) -> io: } } } - -pub fn valid_json(text: &str) -> bool { - serde_json::from_str::(text).is_ok() -} diff --git a/tests/cli.rs b/tests/cli.rs index db011c63..1f066628 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1323,3 +1323,44 @@ fn accept_encoding_not_modifiable_in_download_mode() { .assert(); mock.assert(); } + +#[test] +fn read_args_from_config() { + let config_dir = tempdir().unwrap(); + File::create(config_dir.path().join("config.json")).unwrap(); + std::fs::write( + config_dir.path().join("config.json"), + serde_json::json!({"default_options": ["--form", "--print=hbHB"]}).to_string(), + ) + .unwrap(); + + get_command() + .env("XH_CONFIG_DIR", config_dir.path()) + .arg(":") + .arg("--offline") + .arg("--print=B") // this should overwrite the value from config.json + .arg("sort=asc") + .arg("limit=100") + .assert() + .stdout("sort=asc&limit=100\n\n") + .success(); +} + +#[test] +fn warns_if_config_is_invalid() { + let config_dir = tempdir().unwrap(); + File::create(config_dir.path().join("config.json")).unwrap(); + std::fs::write( + config_dir.path().join("config.json"), + serde_json::json!({"default_options": "--form"}).to_string(), + ) + .unwrap(); + + get_command() + .env("XH_CONFIG_DIR", config_dir.path()) + .arg(":") + .arg("--offline") + .assert() + .stderr(contains("Unable to parse config file")) + .success(); +}