Skip to content

Commit

Permalink
Merge pull request #165 from ducaale/default-options
Browse files Browse the repository at this point in the history
Support reading args from a config file
  • Loading branch information
ducaale authored Aug 2, 2021
2 parents d188374 + 97bd685 commit 7b958dd
Show file tree
Hide file tree
Showing 8 changed files with 163 additions and 70 deletions.
51 changes: 48 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ 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;
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

Expand All @@ -33,6 +35,7 @@ use crate::{buffer::Buffer, request_items::RequestItem};
AppSettings::DeriveDisplayOrder,
AppSettings::UnifiedHelpMessage,
AppSettings::ColoredHelp,
AppSettings::AllArgsOverrideSelf,
],
)]
pub struct Cli {
Expand Down Expand Up @@ -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<I>(iter: I) -> Self
Expand Down Expand Up @@ -481,6 +493,39 @@ impl Cli {
}
}

#[derive(Serialize, Deserialize)]
struct Config {
default_options: Vec<String>,
}

fn default_cli_args() -> Option<Vec<String>> {
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::<Config>(&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<Method> {
// This unfortunately matches "localhost"
if !method.is_empty() && method.chars().all(|c| c.is_ascii_alphabetic()) {
Expand Down
4 changes: 2 additions & 2 deletions src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ fn get_user_agent() -> &'static str {

#[exit_status::main]
fn main() -> Result<i32> {
let args = Cli::from_args();
let args = Cli::parse();

if args.curl {
to_curl::print_curl_translation(args)?;
Expand Down
65 changes: 61 additions & 4 deletions src/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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")?;
Expand All @@ -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') {
Expand All @@ -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
Expand Down Expand Up @@ -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::<serde::de::IgnoredAny>(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(),
Expand Down
6 changes: 3 additions & 3 deletions src/request_items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(..) => {}
Expand Down
2 changes: 1 addition & 1 deletion src/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?
Expand Down
62 changes: 6 additions & 56 deletions src/utils.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<PathBuf> {
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 {
Expand Down Expand Up @@ -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::<serde::de::IgnoredAny>(text).is_ok()
}
41 changes: 41 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

0 comments on commit 7b958dd

Please sign in to comment.