use crate::parse::{parse_base, parse_statuscodes};
use anyhow::{anyhow, Context, Error, Result};
use clap::StructOpt;
use const_format::{concatcp, formatcp};
use lychee_lib::{
Base, Input, DEFAULT_MAX_REDIRECTS, DEFAULT_MAX_RETRIES, DEFAULT_RETRY_WAIT_TIME_SECS,
DEFAULT_TIMEOUT_SECS, DEFAULT_USER_AGENT,
};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use std::{collections::HashSet, fs, io::ErrorKind, path::PathBuf, str::FromStr, time::Duration};
pub(crate) const LYCHEE_IGNORE_FILE: &str = ".lycheeignore";
pub(crate) const LYCHEE_CACHE_FILE: &str = ".lycheecache";
const DEFAULT_METHOD: &str = "get";
const DEFAULT_MAX_CACHE_AGE: &str = "1d";
const DEFAULT_MAX_CONCURRENCY: usize = 128;
const MAX_CONCURRENCY_STR: &str = concatcp!(DEFAULT_MAX_CONCURRENCY);
const MAX_CACHE_AGE_STR: &str = concatcp!(DEFAULT_MAX_CACHE_AGE);
const MAX_REDIRECTS_STR: &str = concatcp!(DEFAULT_MAX_REDIRECTS);
const MAX_RETRIES_STR: &str = concatcp!(DEFAULT_MAX_RETRIES);
const HELP_MSG_CACHE: &str = formatcp!(
"Use request cache stored on disk at `{}`",
LYCHEE_CACHE_FILE,
);
const TIMEOUT_STR: &str = concatcp!(DEFAULT_TIMEOUT_SECS);
const RETRY_WAIT_TIME_STR: &str = concatcp!(DEFAULT_RETRY_WAIT_TIME_SECS);
#[derive(Debug, Deserialize, Clone)]
pub(crate) enum Format {
Compact,
Detailed,
Json,
Markdown,
Raw,
}
impl FromStr for Format {
type Err = Error;
fn from_str(format: &str) -> Result<Self, Self::Err> {
match format.to_lowercase().as_str() {
"compact" | "string" => Ok(Format::Compact),
"detailed" => Ok(Format::Detailed),
"json" => Ok(Format::Json),
"markdown" | "md" => Ok(Format::Markdown),
"raw" => Ok(Format::Raw),
_ => Err(anyhow!("Unknown format {}", format)),
}
}
}
impl Default for Format {
fn default() -> Self {
Format::Compact
}
}
macro_rules! default_function {
( $( $name:ident : $T:ty = $e:expr; )* ) => {
$(
#[allow(clippy::missing_const_for_fn)]
fn $name() -> $T {
$e
}
)*
};
}
default_function! {
max_redirects: usize = DEFAULT_MAX_REDIRECTS;
max_retries: u64 = DEFAULT_MAX_RETRIES;
max_concurrency: usize = DEFAULT_MAX_CONCURRENCY;
max_cache_age: Duration = humantime::parse_duration(DEFAULT_MAX_CACHE_AGE).unwrap();
user_agent: String = DEFAULT_USER_AGENT.to_string();
timeout: usize = DEFAULT_TIMEOUT_SECS;
retry_wait_time: usize = DEFAULT_RETRY_WAIT_TIME_SECS;
method: String = DEFAULT_METHOD.to_string();
}
macro_rules! fold_in {
( $cli:ident , $toml:ident ; $( $key:ident : $default:expr; )* ) => {
$(
if $cli.$key == $default && $toml.$key != $default {
$cli.$key = $toml.$key;
}
)*
};
}
#[derive(Debug, StructOpt)]
#[clap(
name = "lychee",
about = "A glorious link checker.\n\nProject home page: https://github.com/lycheeverse/lychee"
)]
pub(crate) struct LycheeOptions {
#[clap(name = "inputs", required = true)]
raw_inputs: Vec<String>,
#[clap(short, long = "config", default_value = "./lychee.toml")]
pub(crate) config_file: String,
#[clap(flatten)]
pub(crate) config: Config,
}
impl LycheeOptions {
pub(crate) fn inputs(&self) -> Result<Vec<Input>> {
let excluded = if self.config.exclude_path.is_empty() {
None
} else {
Some(self.config.exclude_path.clone())
};
self.raw_inputs
.iter()
.map(|s| Input::new(s, None, self.config.glob_ignore_case, excluded.clone()))
.collect::<Result<_, _>>()
.context("Cannot parse inputs from arguments")
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Deserialize, StructOpt, Clone)]
pub(crate) struct Config {
#[clap(short, long)]
#[serde(default)]
pub(crate) verbose: bool,
#[clap(short, long, verbatim_doc_comment)]
#[serde(default)]
pub(crate) no_progress: bool,
#[clap(help = HELP_MSG_CACHE)]
#[clap(long)]
#[serde(default)]
pub(crate) cache: bool,
#[clap(
long,
parse(try_from_str = humantime::parse_duration),
default_value = &MAX_CACHE_AGE_STR
)]
#[serde(default = "max_cache_age")]
#[serde(with = "humantime_serde")]
pub(crate) max_cache_age: Duration,
#[clap(long)]
#[serde(default)]
pub(crate) dump: bool,
#[clap(short, long, default_value = &MAX_REDIRECTS_STR)]
#[serde(default = "max_redirects")]
pub(crate) max_redirects: usize,
#[clap(long, default_value = &MAX_RETRIES_STR)]
#[serde(default = "max_retries")]
pub(crate) max_retries: u64,
#[clap(long, default_value = &MAX_CONCURRENCY_STR)]
#[serde(default = "max_concurrency")]
pub(crate) max_concurrency: usize,
#[clap(short = 'T', long)]
#[serde(default)]
pub(crate) threads: Option<usize>,
#[clap(short, long, default_value = DEFAULT_USER_AGENT)]
#[serde(default = "user_agent")]
pub(crate) user_agent: String,
#[clap(short, long)]
#[serde(default)]
pub(crate) insecure: bool,
#[clap(short, long)]
#[serde(default)]
pub(crate) scheme: Vec<String>,
#[clap(long)]
#[serde(default)]
pub(crate) offline: bool,
#[clap(long)]
#[serde(default)]
pub(crate) include: Vec<String>,
#[clap(long)]
#[serde(default)]
pub(crate) exclude: Vec<String>,
#[clap(long)]
#[serde(default)]
pub(crate) exclude_file: Vec<String>,
#[clap(long)]
#[serde(default)]
pub(crate) exclude_path: Vec<PathBuf>,
#[clap(short = 'E', long, verbatim_doc_comment)]
#[serde(default)]
pub(crate) exclude_all_private: bool,
#[clap(long)]
#[serde(default)]
pub(crate) exclude_private: bool,
#[clap(long)]
#[serde(default)]
pub(crate) exclude_link_local: bool,
#[clap(long)]
#[serde(default)]
pub(crate) exclude_loopback: bool,
#[clap(long)]
#[serde(default)]
pub(crate) exclude_mail: bool,
#[serde(default)]
#[clap(long)]
pub(crate) remap: Vec<String>,
#[clap(short, long)]
#[serde(default)]
pub(crate) headers: Vec<String>,
#[clap(short, long, parse(try_from_str = parse_statuscodes))]
#[serde(default)]
pub(crate) accept: Option<HashSet<u16>>,
#[clap(short, long, default_value = &TIMEOUT_STR)]
#[serde(default = "timeout")]
pub(crate) timeout: usize,
#[clap(short, long, default_value = &RETRY_WAIT_TIME_STR)]
#[serde(default = "retry_wait_time")]
pub(crate) retry_wait_time: usize,
#[clap(short = 'X', long, default_value = DEFAULT_METHOD)]
#[serde(default = "method")]
pub(crate) method: String,
#[clap(short, long, parse(try_from_str = parse_base))]
#[serde(default)]
pub(crate) base: Option<Base>,
#[clap(long)]
#[serde(default)]
pub(crate) basic_auth: Option<String>,
#[clap(long, env = "GITHUB_TOKEN", hide_env_values = true)]
#[serde(default)]
pub(crate) github_token: Option<SecretString>,
#[clap(long)]
#[serde(default)]
pub(crate) skip_missing: bool,
#[clap(long)]
#[serde(default)]
pub(crate) include_verbatim: bool,
#[clap(long)]
#[serde(default)]
pub(crate) glob_ignore_case: bool,
#[clap(short, long, parse(from_os_str))]
#[serde(default)]
pub(crate) output: Option<PathBuf>,
#[clap(short, long, default_value = "compact")]
#[serde(default)]
pub(crate) format: Format,
#[clap(long)]
#[serde(default)]
pub(crate) require_https: bool,
}
impl Config {
pub(crate) fn load_from_file(path: &str) -> Result<Option<Config>> {
let result = fs::read(path);
let contents = match result {
Ok(c) => c,
Err(e) => {
return match e.kind() {
ErrorKind::NotFound => Ok(None),
_ => Err(Error::from(e)),
}
}
};
Ok(Some(toml::from_slice(&contents)?))
}
pub(crate) fn merge(&mut self, toml: Config) {
fold_in! {
self, toml;
verbose: false;
cache: false;
no_progress: false;
max_redirects: DEFAULT_MAX_REDIRECTS;
max_retries: DEFAULT_MAX_RETRIES;
max_concurrency: DEFAULT_MAX_CONCURRENCY;
max_cache_age: humantime::parse_duration(DEFAULT_MAX_CACHE_AGE).unwrap();
threads: None;
user_agent: DEFAULT_USER_AGENT;
insecure: false;
scheme: Vec::<String>::new();
include: Vec::<String>::new();
exclude: Vec::<String>::new();
exclude_file: Vec::<String>::new(); exclude_path: Vec::<PathBuf>::new();
exclude_all_private: false;
exclude_private: false;
exclude_link_local: false;
exclude_loopback: false;
exclude_mail: false;
remap: Vec::<String>::new();
headers: Vec::<String>::new();
accept: None;
timeout: DEFAULT_TIMEOUT_SECS;
retry_wait_time: DEFAULT_RETRY_WAIT_TIME_SECS;
method: DEFAULT_METHOD;
base: None;
basic_auth: None;
skip_missing: false;
include_verbatim: false;
glob_ignore_case: false;
output: None;
require_https: false;
}
if self
.github_token
.as_ref()
.map(ExposeSecret::expose_secret)
.is_none()
&& toml
.github_token
.as_ref()
.map(ExposeSecret::expose_secret)
.is_some()
{
self.github_token = toml.github_token;
}
}
}