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::net::{IpAddr, Ipv6Addr};
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, Context};
use clap::{self, ArgAction, FromArgMatches, ValueEnum};
use encoding_rs::Encoding;
use regex_lite::Regex;
use reqwest::{tls, Method, Url};
use serde::Deserialize;
use crate::buffer::Buffer;
use crate::redacted::SecretString;
use crate::request_items::RequestItems;
use crate::utils::config_dir;
#[derive(clap::Parser, Debug)]
#[clap(
version,
long_version = long_version(),
disable_help_flag = true,
args_override_self = true
)]
pub struct Cli {
#[clap(skip)]
pub httpie_compat_mode: bool,
#[clap(short = 'j', long, overrides_with_all = &["form", "multipart"])]
pub json: bool,
#[clap(short = 'f', long, overrides_with_all = &["json", "multipart"])]
pub form: bool,
#[clap(long, conflicts_with_all = &["raw", "compress"], overrides_with_all = &["json", "form"])]
pub multipart: bool,
#[clap(long, value_name = "RAW")]
pub raw: Option<String>,
#[clap(
long,
value_enum,
value_name = "STYLE",
long_help = "\
Controls output processing. Possible values are:
all (default) Enable both coloring and formatting
colors Apply syntax highlighting to output
format Pretty-print json and sort headers
none Disable both coloring and formatting
Defaults to \"format\" if the NO_COLOR env is set and to \"none\" if stdout is not tty."
)]
pub pretty: Option<Pretty>,
#[clap(
long,
value_name = "FORMAT_OPTIONS",
long_help = "\
Set output formatting options. Supported option are:
json.indent:<NUM>
json.format:<true|false>
headers.sort:<true|false>
Example: --format-options=json.indent:2,headers.sort:false"
)]
pub format_options: Vec<FormatOptions>,
#[clap(short = 's', long, value_enum, value_name = "THEME")]
pub style: Option<Theme>,
#[clap(long, value_name = "ENCODING", value_parser = parse_encoding)]
pub response_charset: Option<&'static Encoding>,
#[clap(long, value_name = "MIME_TYPE")]
pub response_mime: Option<String>,
#[clap(
short = 'p',
long,
value_name = "FORMAT",
long_help = "\
String specifying what the output should contain
'H' request headers
'B' request body
'h' response headers
'b' response body
'm' response metadata
Example: --print=Hb"
)]
pub print: Option<Print>,
#[clap(short = 'h', long)]
pub headers: bool,
#[clap(short = 'b', long)]
pub body: bool,
#[clap(short = 'm', long)]
pub meta: bool,
#[clap(short = 'v', long, action = ArgAction::Count)]
pub verbose: u8,
#[clap(long)]
pub debug: bool,
#[clap(long)]
pub all: bool,
#[clap(short = 'P', long, value_name = "FORMAT")]
pub history_print: Option<Print>,
#[clap(short = 'q', long, action = ArgAction::Count)]
pub quiet: u8,
#[clap(short = 'S', long = "stream", name = "stream")]
pub stream_raw: bool,
#[clap(short = 'x', long = "compress", name = "compress", action = ArgAction::Count)]
pub compress: u8,
#[clap(skip)]
pub stream: Option<bool>,
#[clap(short = 'o', long, value_name = "FILE")]
pub output: Option<PathBuf>,
#[clap(short = 'd', long)]
pub download: bool,
#[clap(
short = 'c',
long = "continue",
name = "continue",
requires = "download",
requires = "output"
)]
pub resume: bool,
#[clap(long, value_name = "FILE")]
pub session: Option<OsString>,
#[clap(long, value_name = "FILE", conflicts_with = "session")]
pub session_read_only: Option<OsString>,
#[clap(skip)]
pub is_session_read_only: bool,
#[clap(short = 'A', long, value_enum)]
pub auth_type: Option<AuthType>,
#[clap(short = 'a', long, value_name = "USER[:PASS] | TOKEN")]
pub auth: Option<SecretString>,
#[clap(long, value_name = "TOKEN", hide = true)]
pub bearer: Option<SecretString>,
#[clap(long)]
pub ignore_netrc: bool,
#[clap(long)]
pub offline: bool,
#[clap(long = "check-status", name = "check-status")]
pub check_status_raw: bool,
#[clap(skip)]
pub check_status: Option<bool>,
#[clap(short = 'F', long)]
pub follow: bool,
#[clap(long, value_name = "NUM")]
pub max_redirects: Option<usize>,
#[clap(long, value_name = "SEC")]
pub timeout: Option<Timeout>,
#[clap(long, value_name = "PROTOCOL:URL", number_of_values = 1)]
pub proxy: Vec<Proxy>,
#[clap(long, value_name = "VERIFY", value_parser = VerifyParser)]
pub verify: Option<Verify>,
#[clap(long, value_name = "FILE")]
pub cert: Option<PathBuf>,
#[clap(long, value_name = "FILE")]
pub cert_key: Option<PathBuf>,
#[clap(long, value_name = "VERSION", value_parser)]
pub ssl: Option<TlsVersion>,
#[clap(long, hide = cfg!(not(all(feature = "native-tls", feature = "rustls"))))]
pub native_tls: bool,
#[clap(long, value_name = "SCHEME", hide = true)]
pub default_scheme: Option<String>,
#[clap(long)]
pub https: bool,
#[clap(long, value_name = "VERSION", value_parser)]
pub http_version: Option<HttpVersion>,
#[clap(long, value_name = "HOST:ADDRESS")]
pub resolve: Vec<Resolve>,
#[clap(long, value_name = "NAME")]
pub interface: Option<String>,
#[clap(short = '4', long)]
pub ipv4: bool,
#[clap(short = '6', long)]
pub ipv6: bool,
#[clap(long, value_name = "FILE")]
pub unix_socket: Option<PathBuf>,
#[clap(short = 'I', long)]
pub ignore_stdin: bool,
#[clap(long)]
pub curl: bool,
#[clap(long)]
pub curl_long: bool,
#[arg(
long,
value_name = "KIND",
hide_possible_values = true,
long_help = "\
Generate shell completions or man pages. Possible values are:
complete-bash
complete-elvish
complete-fish
complete-nushell
complete-powershell
complete-zsh
man
Example: xh --generate=complete-bash > xh.bash",
conflicts_with = "raw_method_or_url"
)]
pub generate: Option<Generate>,
#[clap(long, action = ArgAction::HelpShort)]
pub help: Option<bool>,
#[clap(value_name = "[METHOD] URL", required = true)]
raw_method_or_url: Option<String>,
#[clap(value_name = "REQUEST_ITEM", verbatim_doc_comment)]
raw_rest_args: Vec<String>,
#[clap(skip)]
pub method: Option<Method>,
#[clap(skip = ("http://placeholder".parse::<Url>().unwrap()))]
pub url: Url,
#[clap(skip)]
pub request_items: RequestItems,
#[clap(skip)]
pub bin_name: String,
}
impl Cli {
pub fn parse() -> Self {
if let Some(default_args) = default_cli_args() {
let mut args = std::env::args_os();
Self::parse_from(
std::iter::once(args.next().unwrap_or_else(|| "xh".into()))
.chain(default_args.into_iter().map(Into::into))
.chain(args),
)
} else {
Self::parse_from(std::env::args_os())
}
}
pub fn parse_from<I>(iter: I) -> Self
where
I: IntoIterator,
I::Item: Into<OsString> + Clone,
{
match Self::try_parse_from(iter) {
Ok(cli) => cli,
Err(err) => err.exit(),
}
}
pub fn try_parse_from<I>(iter: I) -> clap::error::Result<Self>
where
I: IntoIterator,
I::Item: Into<OsString> + Clone,
{
let mut app = Self::into_app();
let matches = app.try_get_matches_from_mut(iter)?;
let mut cli = Self::from_arg_matches(&matches)?;
app.get_bin_name()
.and_then(|name| name.split('.').next())
.unwrap_or("xh")
.clone_into(&mut cli.bin_name);
if cli.generate.is_some() {
return Ok(cli);
}
let mut raw_method_or_url = cli.raw_method_or_url.clone().unwrap();
if raw_method_or_url == "help" {
app = app.mut_arg("pretty", |a| a.hide_possible_values(true));
app.print_long_help().unwrap();
safe_exit();
}
let mut rest_args = mem::take(&mut cli.raw_rest_args).into_iter();
let raw_url = match parse_method(&raw_method_or_url) {
Some(method) => {
cli.method = Some(method);
rest_args.next().ok_or_else(|| {
app.error(
clap::error::ErrorKind::MissingRequiredArgument,
"Missing <URL>",
)
})?
}
None => {
cli.method = None;
mem::take(&mut raw_method_or_url)
}
};
for request_item in rest_args {
cli.request_items.items.push(
request_item
.parse()
.map_err(|err: clap::error::Error| err.format(&mut app))?,
);
}
if matches!(cli.bin_name.as_str(), "https" | "xhs" | "xhttps") {
cli.https = true;
}
if matches!(cli.bin_name.as_str(), "http" | "https")
|| env::var_os("XH_HTTPIE_COMPAT_MODE").is_some()
{
cli.httpie_compat_mode = true;
}
cli.process_relations(&matches)?;
cli.url = construct_url(&raw_url, cli.default_scheme.as_deref()).map_err(|err| {
app.error(
clap::error::ErrorKind::ValueValidation,
format!("Invalid <URL>: {err}"),
)
})?;
if cfg!(not(feature = "rustls")) {
cli.native_tls = true;
}
Ok(cli)
}
fn process_relations(&mut self, matches: &clap::ArgMatches) -> clap::error::Result<()> {
if self.verbose > 0 {
self.all = true;
}
if self.curl_long {
self.curl = true;
}
if self.https {
self.default_scheme = Some("https".to_string());
}
if self.bearer.is_some() {
self.auth_type = Some(AuthType::Bearer);
self.auth = self.bearer.take();
}
self.check_status = match (self.check_status_raw, matches.get_flag("no-check-status")) {
(true, true) => unreachable!(),
(true, false) => Some(true),
(false, true) => Some(false),
(false, false) => None,
};
self.stream = match (self.stream_raw, matches.get_flag("no-stream")) {
(true, true) => unreachable!(),
(true, false) => Some(true),
(false, true) => Some(false),
(false, false) => None,
};
if self.download {
self.follow = true;
self.check_status = Some(true);
}
if self.json {
self.request_items.body_type = BodyType::Json;
} else if self.form {
self.request_items.body_type = BodyType::Form;
} else if self.multipart {
self.request_items.body_type = BodyType::Multipart;
}
if self.raw.is_some() && !self.request_items.is_body_empty() {
return Err(Self::into_app().error(
clap::error::ErrorKind::ValueValidation,
"Request body (from --raw) and request data (key=value) cannot be mixed.",
));
}
if self.session_read_only.is_some() {
self.is_session_read_only = true;
self.session = mem::take(&mut self.session_read_only);
}
Ok(())
}
pub fn into_app() -> clap::Command {
let app = <Self as clap::CommandFactory>::command();
let negations: Vec<_> = app
.get_arguments()
.filter(|a| !a.is_positional())
.map(|opt| {
let long = opt.get_long().expect("long option");
clap::Arg::new(format!("no-{long}"))
.long(format!("no-{long}"))
.hide(true)
.action(ArgAction::SetTrue)
.overrides_with(opt.get_id())
})
.collect();
let mut app = app.args(negations)
.after_help(format!("Each option can be reset with a --no-OPTION argument.\n\nRun \"{} help\" for more complete documentation.", env!("CARGO_PKG_NAME")))
.after_long_help("Each option can be reset with a --no-OPTION argument.");
app.build();
app
}
pub fn logger_config(&self) -> env_logger::Builder {
if self.debug || std::env::var_os("RUST_LOG").is_some() {
let env = env_logger::Env::default().default_filter_or("debug");
let mut builder = env_logger::Builder::from_env(env);
let start = std::time::Instant::now();
builder.format(move |buf, record| {
let time = start.elapsed().as_secs_f64();
let level = record.level();
let style = buf.default_level_style(level);
let module = record.module_path().unwrap_or("");
let args = record.args();
writeln!(
buf,
"[{time:.6}s {style}{level: <5}{style:#} {module}] {args}"
)
});
builder
} else {
let env = env_logger::Env::default();
let mut builder = env_logger::Builder::from_env(env);
if self.quiet >= 2 {
builder.filter_level(log::LevelFilter::Error);
} else {
builder.filter_level(log::LevelFilter::Warn);
}
let bin_name = self.bin_name.clone();
builder.format(move |buf, record| {
let level = match record.level() {
log::Level::Error => "error",
log::Level::Warn => "warning",
log::Level::Info => "info",
log::Level::Debug => "debug",
log::Level::Trace => "trace",
};
let args = record.args();
writeln!(buf, "{bin_name}: {level}: {args}")
});
builder
}
}
}
#[derive(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> {
if !method.is_empty() && method.chars().all(|c| c.is_ascii_alphabetic()) {
Some(method.to_ascii_uppercase().parse().unwrap())
} else {
None
}
}
fn construct_url(
url: &str,
default_scheme: Option<&str>,
) -> std::result::Result<Url, url::ParseError> {
let mut default_scheme = default_scheme.unwrap_or("http://").to_string();
if !default_scheme.ends_with("://") {
default_scheme.push_str("://");
}
let url: Url = if let Some(url) = url.strip_prefix("://") {
format!("{default_scheme}{url}").parse()?
} else if url.starts_with(':') {
format!("{}{}{}", default_scheme, "localhost", url).parse()?
} else if !Regex::new("[a-zA-Z0-9]://.+").unwrap().is_match(url) {
format!("{default_scheme}{url}").parse()?
} else {
url.parse()?
};
Ok(url)
}
#[derive(Default, ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum AuthType {
#[default]
Basic,
Bearer,
Digest,
}
#[derive(ValueEnum, Debug, Clone)]
pub enum TlsVersion {
#[clap(name = "auto", alias = "ssl2.3")]
Auto,
#[clap(name = "tls1")]
Tls1_0,
#[clap(name = "tls1.1")]
Tls1_1,
#[clap(name = "tls1.2")]
Tls1_2,
#[clap(name = "tls1.3")]
Tls1_3,
}
impl From<TlsVersion> for Option<tls::Version> {
fn from(version: TlsVersion) -> Self {
match version {
TlsVersion::Auto => None,
TlsVersion::Tls1_0 => Some(tls::Version::TLS_1_0),
TlsVersion::Tls1_1 => Some(tls::Version::TLS_1_1),
TlsVersion::Tls1_2 => Some(tls::Version::TLS_1_2),
TlsVersion::Tls1_3 => Some(tls::Version::TLS_1_3),
}
}
}
#[derive(ValueEnum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum Pretty {
All,
Colors,
Format,
None,
}
impl Pretty {
pub fn color(self) -> bool {
matches!(self, Pretty::Colors | Pretty::All)
}
pub fn format(self) -> bool {
matches!(self, Pretty::Format | Pretty::All)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FormatOptions {
pub json_indent: Option<usize>,
pub json_format: Option<bool>,
pub headers_sort: Option<bool>,
}
impl FormatOptions {
pub fn merge(mut self, other: &Self) -> Self {
self.json_indent = other.json_indent.or(self.json_indent);
self.json_format = other.json_format.or(self.json_format);
self.headers_sort = other.headers_sort.or(self.headers_sort);
self
}
}
impl FromStr for FormatOptions {
type Err = anyhow::Error;
fn from_str(options: &str) -> anyhow::Result<FormatOptions> {
let mut format_options = FormatOptions::default();
for argument in options.to_lowercase().split(',') {
let (key, value) = argument
.split_once(':')
.context("Format options consist of a key and a value, separated by a \":\".")?;
let value_error = || format!("Invalid value '{value}' in '{argument}'");
match key {
"json.indent" => {
format_options.json_indent = Some(value.parse().with_context(value_error)?);
}
"json.format" => {
format_options.json_format = Some(value.parse().with_context(value_error)?);
}
"headers.sort" => {
format_options.headers_sort = Some(value.parse().with_context(value_error)?);
}
"json.sort_keys" | "xml.format" | "xml.indent" => {
return Err(anyhow!("Unsupported option '{key}'"));
}
_ => {
return Err(anyhow!("Unknown option '{key}'"));
}
}
}
Ok(format_options)
}
}
#[derive(Default, ValueEnum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum Theme {
#[default]
Auto,
Solarized,
Monokai,
Fruity,
}
impl Theme {
pub fn as_str(&self) -> &'static str {
match self {
Theme::Auto => "ansi",
Theme::Solarized => "solarized",
Theme::Monokai => "monokai",
Theme::Fruity => "fruity",
}
}
pub(crate) fn as_syntect_theme(&self) -> &'static syntect::highlighting::Theme {
&crate::formatting::THEMES.themes[self.as_str()]
}
}
#[derive(Debug, Clone, Copy)]
pub struct Print {
pub request_headers: bool,
pub request_body: bool,
pub response_headers: bool,
pub response_body: bool,
pub response_meta: bool,
}
impl Print {
pub fn new(
verbose: u8,
headers: bool,
body: bool,
meta: bool,
quiet: bool,
offline: bool,
buffer: &Buffer,
) -> Self {
if verbose > 0 {
Print {
request_headers: true,
request_body: true,
response_headers: true,
response_body: true,
response_meta: verbose > 1,
}
} else if quiet {
Print {
request_headers: false,
request_body: false,
response_headers: false,
response_body: false,
response_meta: false,
}
} else if offline {
Print {
request_headers: true,
request_body: true,
response_headers: false,
response_body: false,
response_meta: false,
}
} else if headers {
Print {
request_headers: false,
request_body: false,
response_headers: true,
response_body: false,
response_meta: false,
}
} else if body || !buffer.is_terminal() {
Print {
request_headers: false,
request_body: false,
response_headers: false,
response_body: true,
response_meta: false,
}
} else if meta {
Print {
request_headers: false,
request_body: false,
response_headers: false,
response_body: false,
response_meta: true,
}
} else {
Print {
request_headers: false,
request_body: false,
response_headers: true,
response_body: true,
response_meta: false,
}
}
}
}
impl FromStr for Print {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Print> {
let mut request_headers = false;
let mut request_body = false;
let mut response_headers = false;
let mut response_body = false;
let mut response_meta = false;
for char in s.chars() {
match char {
'H' => request_headers = true,
'B' => request_body = true,
'h' => response_headers = true,
'b' => response_body = true,
'm' => response_meta = true,
char => return Err(anyhow!("{:?} is not a valid value", char)),
}
}
let p = Print {
request_headers,
request_body,
response_headers,
response_body,
response_meta,
};
Ok(p)
}
}
#[derive(Debug, Clone)]
pub struct Timeout(Duration);
impl Timeout {
pub fn as_duration(&self) -> Option<Duration> {
Some(self.0).filter(|t| !t.is_zero())
}
}
impl FromStr for Timeout {
type Err = anyhow::Error;
fn from_str(sec: &str) -> anyhow::Result<Timeout> {
match f64::from_str(sec) {
Ok(s) if !s.is_nan() => {
if s.is_sign_negative() {
Err(anyhow!("Connection timeout is negative"))
} else if s >= Duration::MAX.as_secs_f64() || s.is_infinite() {
Err(anyhow!("Connection timeout is too big"))
} else {
Ok(Timeout(Duration::from_secs_f64(s)))
}
}
_ => Err(anyhow!("Connection timeout is not a valid number")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Proxy {
Http(Url),
Https(Url),
All(Url),
}
impl FromStr for Proxy {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> {
let split_arg: Vec<&str> = s.splitn(2, ':').collect();
match split_arg[..] {
[protocol, url] => {
let url = reqwest::Url::try_from(url).map_err(|e| {
anyhow!(
"Invalid proxy URL '{}' for protocol '{}': {}",
url,
protocol,
e
)
})?;
match protocol.to_lowercase().as_str() {
"http" => Ok(Proxy::Http(url)),
"https" => Ok(Proxy::Https(url)),
"all" => Ok(Proxy::All(url)),
_ => Err(anyhow!("Unknown protocol to set a proxy for: {}", protocol)),
}
}
_ => Err(anyhow!(
"The value passed to --proxy should be formatted as <PROTOCOL>:<PROXY_URL>"
)),
}
}
}
#[derive(Debug, Clone)]
pub struct Resolve {
pub domain: String,
pub addr: IpAddr,
}
impl FromStr for Resolve {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> {
if s.chars().filter(|&c| c == ':').count() == 2 {
return Err(anyhow!(
"Value should be formatted as <HOST>:<ADDRESS> (not <HOST>:<PORT>:<ADDRESS>)"
));
}
let (domain, raw_addr) = s
.split_once(':')
.context("Value should be formatted as <HOST>:<ADDRESS>")?;
let addr = if raw_addr.starts_with('[') && raw_addr.ends_with(']') {
Ipv6Addr::from_str(&raw_addr[1..raw_addr.len() - 1]).map(IpAddr::V6)
} else {
raw_addr.parse()
}
.with_context(|| format!("Invalid address '{raw_addr}'"))?;
Ok(Resolve {
domain: domain.to_string(),
addr,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verify {
Yes,
No,
CustomCaBundle(PathBuf),
}
impl clap::builder::ValueParserFactory for Verify {
type Parser = VerifyParser;
fn value_parser() -> Self::Parser {
VerifyParser
}
}
#[derive(Clone, Debug)]
pub struct VerifyParser;
impl clap::builder::TypedValueParser for VerifyParser {
type Value = Verify;
fn parse_ref(
&self,
_cmd: &clap::Command,
_arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> clap::error::Result<Self::Value, clap::Error> {
Ok(match value.to_ascii_lowercase().to_str() {
Some("no") | Some("false") => Verify::No,
Some("yes") | Some("true") => Verify::Yes,
_ => Verify::CustomCaBundle(PathBuf::from(value)),
})
}
}
impl fmt::Display for Verify {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Verify::No => write!(f, "no"),
Verify::Yes => write!(f, "yes"),
Verify::CustomCaBundle(path) => write!(f, "custom ca bundle: {}", path.display()),
}
}
}
#[derive(Default, Debug, PartialEq, Eq, Copy, Clone)]
pub enum BodyType {
#[default]
Json,
Form,
Multipart,
}
#[derive(ValueEnum, Debug, Clone)]
pub enum HttpVersion {
#[clap(name = "1.0", alias = "1")]
Http10,
#[clap(name = "1.1")]
Http11,
#[clap(name = "2")]
Http2,
#[clap(name = "2-prior-knowledge")]
Http2PriorKnowledge,
#[clap(name = "3-prior-knowledge")]
Http3PriorKnowledge,
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum Generate {
CompleteBash,
CompleteElvish,
CompleteFish,
CompleteNushell,
CompletePowershell,
CompleteZsh,
Man,
}
fn parse_encoding(encoding: &str) -> anyhow::Result<&'static Encoding> {
let normalized_encoding = encoding.to_lowercase().replace(
|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != ':',
"",
);
match normalized_encoding.as_str() {
"u8" | "utf" => return Ok(encoding_rs::UTF_8),
"u16" => return Ok(encoding_rs::UTF_16LE),
_ => (),
}
for encoding in [
&normalized_encoding,
&normalized_encoding.replace(&['-', '_'][..], ""),
&normalized_encoding.replace('_', "-"),
&normalized_encoding.replace('-', "_"),
] {
if let Some(encoding) = Encoding::for_label(encoding.as_bytes()) {
return Ok(encoding);
}
}
{
let mut encoding = normalized_encoding.replace(&['-', '_'][..], "");
if let Some(first_digit_index) = encoding.find(|c: char| c.is_ascii_digit()) {
encoding.insert(first_digit_index, '-');
if let Some(encoding) = Encoding::for_label(encoding.as_bytes()) {
return Ok(encoding);
}
}
}
Err(anyhow::anyhow!(
"{} is not a supported encoding, please refer to https://encoding.spec.whatwg.org/#names-and-labels \
for supported encodings",
encoding
))
}
fn safe_exit() -> ! {
let _ = std::io::stdout().lock().flush();
let _ = std::io::stderr().lock().flush();
std::process::exit(0);
}
fn long_version() -> &'static str {
concat!(env!("CARGO_PKG_VERSION"), "\n", env!("XH_FEATURES"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::request_items::RequestItem;
fn parse<I>(args: I) -> clap::error::Result<Cli>
where
I: IntoIterator,
I::Item: Into<OsString> + Clone,
{
Cli::try_parse_from(
Some("xh".into())
.into_iter()
.chain(args.into_iter().map(Into::into)),
)
}
#[test]
fn implicit_method() {
let cli = parse(["example.org"]).unwrap();
assert_eq!(cli.method, None);
assert_eq!(cli.url.to_string(), "http://example.org/");
assert!(cli.request_items.items.is_empty());
}
#[test]
fn explicit_method() {
let cli = parse(["get", "example.org"]).unwrap();
assert_eq!(cli.method, Some(Method::GET));
assert_eq!(cli.url.to_string(), "http://example.org/");
assert!(cli.request_items.items.is_empty());
}
#[test]
fn method_edge_cases() {
parse(["localhost"]).unwrap_err();
let cli = parse(["purge", ":"]).unwrap();
assert_eq!(cli.method, Some("PURGE".parse().unwrap()));
assert_eq!(cli.url.to_string(), "http://localhost/");
parse([""]).unwrap_err();
}
#[test]
fn missing_url() {
parse(["get"]).unwrap_err();
}
#[test]
fn space_in_url() {
let cli = parse(["post", "example.org/foo bar"]).unwrap();
assert_eq!(cli.method, Some(Method::POST));
assert_eq!(cli.url.to_string(), "http://example.org/foo%20bar");
assert!(cli.request_items.items.is_empty());
}
#[test]
fn url_with_leading_double_slash_colon() {
let cli = parse(["://example.org"]).unwrap();
assert_eq!(cli.url.to_string(), "http://example.org/");
}
#[test]
fn url_with_leading_colon() {
let cli = parse([":3000"]).unwrap();
assert_eq!(cli.url.to_string(), "http://localhost:3000/");
let cli = parse([":3000/users"]).unwrap();
assert_eq!(cli.url.to_string(), "http://localhost:3000/users");
let cli = parse([":"]).unwrap();
assert_eq!(cli.url.to_string(), "http://localhost/");
let cli = parse([":/users"]).unwrap();
assert_eq!(cli.url.to_string(), "http://localhost/users");
}
#[test]
fn url_with_scheme() {
let cli = parse(["https://example.org"]).unwrap();
assert_eq!(cli.url.to_string(), "https://example.org/");
}
#[test]
fn url_without_scheme() {
let cli = parse(["example.org"]).unwrap();
assert_eq!(cli.url.to_string(), "http://example.org/");
}
#[test]
fn request_items() {
let cli = parse(["get", "example.org", "foo=bar"]).unwrap();
assert_eq!(cli.method, Some(Method::GET));
assert_eq!(cli.url.to_string(), "http://example.org/");
assert_eq!(
cli.request_items.items,
vec![RequestItem::DataField {
key: "foo".to_string(),
raw_key: "foo".to_string(),
value: "bar".to_string()
}]
);
}
#[test]
fn request_items_implicit_method() {
let cli = parse(["example.org", "foo=bar"]).unwrap();
assert_eq!(cli.method, None);
assert_eq!(cli.url.to_string(), "http://example.org/");
assert_eq!(
cli.request_items.items,
vec![RequestItem::DataField {
key: "foo".to_string(),
raw_key: "foo".to_string(),
value: "bar".to_string()
}]
);
}
#[test]
fn request_type_overrides() {
let cli = parse(["--form", "--json", ":"]).unwrap();
assert_eq!(cli.request_items.body_type, BodyType::Json);
assert_eq!(cli.json, true);
assert_eq!(cli.form, false);
assert_eq!(cli.multipart, false);
let cli = parse(["--json", "--form", ":"]).unwrap();
assert_eq!(cli.request_items.body_type, BodyType::Form);
assert_eq!(cli.json, false);
assert_eq!(cli.form, true);
assert_eq!(cli.multipart, false);
let cli = parse([":"]).unwrap();
assert_eq!(cli.request_items.body_type, BodyType::Json);
assert_eq!(cli.json, false);
assert_eq!(cli.form, false);
assert_eq!(cli.multipart, false);
}
#[test]
fn superfluous_arg() {
parse(["get", "example.org", "foobar"]).unwrap_err();
}
#[test]
fn superfluous_arg_implicit_method() {
parse(["example.org", "foobar"]).unwrap_err();
}
#[test]
fn multiple_methods() {
parse(["get", "post", "example.org"]).unwrap_err();
}
#[test]
fn proxy_invalid_protocol() {
Cli::try_parse_from([
"xh",
"--proxy=invalid:http://127.0.0.1:8000",
"get",
"example.org",
])
.unwrap_err();
}
#[test]
fn proxy_invalid_proxy_url() {
Cli::try_parse_from(["xh", "--proxy=http:127.0.0.1:8000", "get", "example.org"])
.unwrap_err();
}
#[test]
fn proxy_http() {
let proxy = parse(["--proxy=http:http://127.0.0.1:8000", "get", "example.org"])
.unwrap()
.proxy;
assert_eq!(
proxy,
vec!(Proxy::Http(Url::parse("http://127.0.0.1:8000").unwrap()))
);
}
#[test]
fn proxy_https() {
let proxy = parse(["--proxy=https:http://127.0.0.1:8000", "get", "example.org"])
.unwrap()
.proxy;
assert_eq!(
proxy,
vec!(Proxy::Https(Url::parse("http://127.0.0.1:8000").unwrap()))
);
}
#[test]
fn proxy_all() {
let proxy = parse(["--proxy=all:http://127.0.0.1:8000", "get", "example.org"])
.unwrap()
.proxy;
assert_eq!(
proxy,
vec!(Proxy::All(Url::parse("http://127.0.0.1:8000").unwrap()))
);
}
#[test]
fn executable_name() {
let args = Cli::try_parse_from(["xhs", "example.org"]).unwrap();
assert_eq!(args.https, true);
}
#[test]
fn executable_name_extension() {
let args = Cli::try_parse_from(["xhs.exe", "example.org"]).unwrap();
assert_eq!(args.https, true);
}
#[test]
fn negated_flags() {
let cli = parse(["--no-offline", ":"]).unwrap();
assert_eq!(cli.offline, false);
let cli = parse(["--no-offline", "--offline", ":"]).unwrap();
assert_eq!(cli.offline, true);
let cli = parse(["--no-form", "--multipart", ":"]).unwrap();
assert_eq!(cli.request_items.body_type, BodyType::Multipart);
assert_eq!(cli.json, false);
assert_eq!(cli.form, false);
assert_eq!(cli.multipart, true);
let cli = parse(["--multipart", "--no-form", ":"]).unwrap();
assert_eq!(cli.request_items.body_type, BodyType::Multipart);
assert_eq!(cli.json, false);
assert_eq!(cli.form, false);
assert_eq!(cli.multipart, true);
let cli = parse(["--form", "--no-form", ":"]).unwrap();
assert_eq!(cli.request_items.body_type, BodyType::Json);
assert_eq!(cli.json, false);
assert_eq!(cli.form, false);
assert_eq!(cli.multipart, false);
let cli = parse(["--form", "--json", "--no-form", ":"]).unwrap();
assert_eq!(cli.request_items.body_type, BodyType::Json);
assert_eq!(cli.json, true);
assert_eq!(cli.form, false);
assert_eq!(cli.multipart, false);
let cli = parse(["--curl-long", "--no-curl-long", ":"]).unwrap();
assert_eq!(cli.curl_long, false);
let cli = parse(["--no-curl-long", "--curl-long", ":"]).unwrap();
assert_eq!(cli.curl_long, true);
let cli = parse(["-do=fname", "--continue", "--no-continue", ":"]).unwrap();
assert_eq!(cli.resume, false);
let cli = parse(["-do=fname", "--no-continue", "--continue", ":"]).unwrap();
assert_eq!(cli.resume, true);
let cli = parse(["-I", "--no-ignore-stdin", ":"]).unwrap();
assert_eq!(cli.ignore_stdin, false);
let cli = parse(["--no-ignore-stdin", "-I", ":"]).unwrap();
assert_eq!(cli.ignore_stdin, true);
let cli = parse([
"--proxy=http:http://foo",
"--proxy=http:http://bar",
"--no-proxy",
":",
])
.unwrap();
assert!(cli.proxy.is_empty());
let cli = parse([
"--no-proxy",
"--proxy=http:http://foo",
"--proxy=https:http://bar",
":",
])
.unwrap();
assert_eq!(
cli.proxy,
vec![
Proxy::Http("http://foo".parse().unwrap()),
Proxy::Https("http://bar".parse().unwrap())
]
);
let cli = parse([
"--proxy=http:http://foo",
"--no-proxy",
"--proxy=https:http://bar",
":",
])
.unwrap();
assert_eq!(cli.proxy, vec![Proxy::Https("http://bar".parse().unwrap())]);
let cli = parse(["--bearer=baz", "--no-bearer", ":"]).unwrap();
assert_eq!(cli.bearer, None);
let cli = parse(["--style=solarized", "--no-style", ":"]).unwrap();
assert_eq!(cli.style, None);
let cli = parse([
"--auth=foo:bar",
"--auth-type=bearer",
"--no-auth-type",
":",
])
.unwrap();
assert_eq!(cli.bearer, None);
assert_eq!(cli.auth_type, None);
}
#[test]
fn negating_check_status() {
let cli = parse([":"]).unwrap();
assert_eq!(cli.check_status, None);
let cli = parse(["--check-status", ":"]).unwrap();
assert_eq!(cli.check_status, Some(true));
let cli = parse(["--no-check-status", ":"]).unwrap();
assert_eq!(cli.check_status, Some(false));
let cli = parse(["--check-status", "--no-check-status", ":"]).unwrap();
assert_eq!(cli.check_status, Some(false));
let cli = parse(["--no-check-status", "--check-status", ":"]).unwrap();
assert_eq!(cli.check_status, Some(true));
}
#[test]
fn negating_stream() {
let cli = parse([":"]).unwrap();
assert_eq!(cli.stream, None);
let cli = parse(["--stream", ":"]).unwrap();
assert_eq!(cli.stream, Some(true));
let cli = parse(["--no-stream", ":"]).unwrap();
assert_eq!(cli.stream, Some(false));
let cli = parse(["--stream", "--no-stream", ":"]).unwrap();
assert_eq!(cli.stream, Some(false));
let cli = parse(["--no-stream", "--stream", ":"]).unwrap();
assert_eq!(cli.stream, Some(true));
}
#[test]
fn parse_encoding_label() {
let test_cases = vec![
("~~~~UtF////16@@", encoding_rs::UTF_16LE),
("utf16", encoding_rs::UTF_16LE),
("utf_16_be", encoding_rs::UTF_16BE),
("utf16be", encoding_rs::UTF_16BE),
("utf-16-be", encoding_rs::UTF_16BE),
("utf_8", encoding_rs::UTF_8),
("utf8", encoding_rs::UTF_8),
("utf-8", encoding_rs::UTF_8),
("u8", encoding_rs::UTF_8),
("iso8859_6", encoding_rs::ISO_8859_6),
("iso_8859-2:1987", encoding_rs::ISO_8859_2),
("l1", encoding_rs::WINDOWS_1252),
("elot-928", encoding_rs::ISO_8859_7),
];
for (input, output) in test_cases {
assert_eq!(parse_encoding(input).unwrap(), output);
}
assert_eq!(parse_encoding("notreal").is_err(), true);
assert_eq!(parse_encoding("").is_err(), true);
}
#[test]
fn parse_format_options() {
let invalid_format_options = vec![
":8",
"json.indent:",
":",
"",
"json.format:true, json.indent:4",
"json.indent:-8",
"json.format:ffalse",
"json.sort_keys:true",
"xml.format:false",
"xml.indent:false",
"toml.format:true",
];
for format_option in invalid_format_options {
assert!(FormatOptions::from_str(format_option).is_err());
}
assert!(FormatOptions::from_str(
"json.indent:8,json.format:true,headers.sort:false,JSON.FORMAT:TRUE"
)
.is_ok());
}
#[test]
fn merge_format_options() {
let format_option_one = FormatOptions::from_str("json.indent:2").unwrap();
let format_option_two =
FormatOptions::from_str("headers.sort:true,headers.sort:false").unwrap();
assert_eq!(
format_option_one.merge(&format_option_two),
FormatOptions {
json_indent: Some(2),
headers_sort: Some(false),
json_format: None
}
)
}
#[test]
fn parse_resolve() {
let invalid_test_cases = [
"example.com:[127.0.0.1]",
"example.com:80:[::1]",
"example.com::::1",
"example.com:1",
"example.com:example.com",
"http://example.com:127.0.0.1",
"http://example.com:[::1]",
"http://example.com:80:[::1]",
];
for input in invalid_test_cases {
assert!(Resolve::from_str(input).is_err())
}
assert!(Resolve::from_str("example.com:127.0.0.1").is_ok());
assert!(Resolve::from_str("example.com:::1").is_ok());
assert!(Resolve::from_str("example.com:[::1]").is_ok());
}
#[test]
fn generate() {
let cli = parse(["--generate", "complete-bash"]).unwrap();
assert_eq!(cli.generate, Some(Generate::CompleteBash));
assert_eq!(cli.raw_method_or_url, None);
}
#[test]
fn generate_with_url() {
parse(["--generate", "complete-zsh", "example.org"]).unwrap_err();
}
}