#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::shell) enum Client {
Curl,
Wget,
}
const CURL_SHORT_VALUED: &str = "AbcCdDeEFHKmoPQrtTuUwxXYyz";
const WGET_SHORT_VALUED: &str = "oaeiBtOTwQPUlARDXIn";
const CURL_LONG_VALUED: &[&str] = &[
"output",
"output-dir",
"request",
"max-time",
"connect-timeout",
"retry",
"retry-delay",
"retry-max-time",
"range",
"continue-at",
"write-out",
"max-redirs",
"max-filesize",
"limit-rate",
"url",
"data",
"data-ascii",
"data-binary",
"data-raw",
"data-urlencode",
"json",
"form",
"form-string",
"header",
"user",
"user-agent",
"referer",
"cookie",
"cookie-jar",
"config",
"upload-file",
"dump-header",
"proxy",
"resolve",
"connect-to",
];
const CURL_LONG_FLAGS: &[&str] = &[
"silent",
"show-error",
"fail",
"fail-with-body",
"fail-early",
"include",
"head",
"insecure",
"verbose",
"remote-name",
"remote-name-all",
"remote-header-name",
"remote-time",
"globoff",
"no-buffer",
"compressed",
"progress-bar",
"no-progress-meter",
"create-dirs",
"ipv4",
"ipv6",
"http1.1",
"http2",
"retry-connrefused",
"retry-all-errors",
"disable",
"location",
"netrc",
"netrc-optional",
"next",
];
const WGET_LONG_VALUED: &[&str] = &[
"output-document",
"directory-prefix",
"tries",
"timeout",
"read-timeout",
"connect-timeout",
"dns-timeout",
"wait",
"waitretry",
"limit-rate",
"max-redirect",
"progress",
"output-file",
"append-output",
"execute",
"input-file",
"header",
"user-agent",
"post-data",
"post-file",
"body-data",
"body-file",
"method",
"user",
"password",
"config",
];
const WGET_LONG_FLAGS: &[&str] = &[
"quiet",
"verbose",
"no-verbose",
"continue",
"timestamping",
"server-response",
"no-clobber",
"no-check-certificate",
"show-progress",
"no-parent",
"no-directories",
"spider",
"content-disposition",
"inet4-only",
"inet6-only",
"no-config",
"no-netrc",
];
impl Client {
pub(in crate::shell) fn of(program: &str) -> Option<Self> {
match program {
"curl" => Some(Self::Curl),
"wget" => Some(Self::Wget),
_ => None,
}
}
fn short_valued(self) -> &'static str {
match self {
Self::Curl => CURL_SHORT_VALUED,
Self::Wget => WGET_SHORT_VALUED,
}
}
pub(in crate::shell) fn long_valued(self) -> &'static [&'static str] {
match self {
Self::Curl => CURL_LONG_VALUED,
Self::Wget => WGET_LONG_VALUED,
}
}
pub(in crate::shell) fn long_flags(self) -> &'static [&'static str] {
match self {
Self::Curl => CURL_LONG_FLAGS,
Self::Wget => WGET_LONG_FLAGS,
}
}
fn resolve(self, written: &str) -> (&str, bool) {
let known = || self.long_valued().iter().chain(self.long_flags());
if known().any(|n| *n == written) {
return (written, true);
}
let mut hits = known().filter(|n| !written.is_empty() && n.starts_with(written));
match (hits.next(), hits.next()) {
(Some(name), None) => (name, false),
_ => (written, false),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::shell) enum Arg<'a> {
Long {
name: &'a str,
exact: bool,
value: Option<&'a str>,
},
Short {
letter: char,
value: Option<&'a str>,
},
Operand(&'a str),
EndOfOptions,
Missing,
}
pub(in crate::shell) fn parse(argv: &[String], client: Client) -> Vec<Arg<'_>> {
let mut parsed = Vec::new();
let mut words = argv.iter().skip(1).map(String::as_str);
let mut ended = false;
while let Some(word) = words.next() {
if ended || word == "-" || !word.starts_with('-') {
parsed.push(Arg::Operand(word));
} else if word == "--" {
ended = true;
parsed.push(Arg::EndOfOptions);
} else if let Some(long) = word.strip_prefix("--") {
let (written, attached) = long
.split_once('=')
.map_or((long, None), |(n, v)| (n, Some(v)));
let (name, exact) = client.resolve(written);
let value = match attached {
None if client.long_valued().contains(&name) => {
let Some(v) = words.next() else {
parsed.push(Arg::Missing);
continue;
};
Some(v)
}
other => other,
};
parsed.push(Arg::Long { name, exact, value });
} else {
let cluster = &word[1..];
for (pos, letter) in cluster.char_indices() {
if !client.short_valued().contains(letter) {
parsed.push(Arg::Short {
letter,
value: None,
});
continue;
}
let rest = &cluster[pos + letter.len_utf8()..];
let value = if rest.is_empty() {
words.next()
} else {
Some(rest)
};
parsed.push(value.map_or(Arg::Missing, |v| Arg::Short {
letter,
value: Some(v),
}));
break;
}
}
}
parsed
}
enum Role<'a> {
Output(&'a str),
Dir(&'a str),
RemoteName,
WriteOut(&'a str),
Url(&'a str),
Spider,
Other,
}
#[derive(Clone, Copy)]
enum Opt<'a> {
S(char),
L(&'a str),
}
fn role<'a>(arg: &Arg<'a>, client: Client) -> Role<'a> {
let (opt, value) = match *arg {
Arg::Operand(url) => return Role::Url(url),
Arg::Short { letter, value } => (Opt::S(letter), value),
Arg::Long { name, value, .. } => (Opt::L(name), value),
Arg::EndOfOptions | Arg::Missing => return Role::Other,
};
match (client, opt, value) {
(Client::Curl, Opt::L("url"), Some(url)) => Role::Url(url),
(Client::Curl, Opt::S('o') | Opt::L("output"), Some(v))
| (Client::Wget, Opt::S('O') | Opt::L("output-document"), Some(v)) => Role::Output(v),
(Client::Curl, Opt::L("output-dir"), Some(v))
| (Client::Wget, Opt::S('P') | Opt::L("directory-prefix"), Some(v)) => Role::Dir(v),
(
Client::Curl,
Opt::S('O' | 'J') | Opt::L("remote-name" | "remote-name-all" | "remote-header-name"),
_,
) => Role::RemoteName,
(Client::Curl, Opt::S('w') | Opt::L("write-out"), Some(v)) => Role::WriteOut(v),
(Client::Wget, Opt::L("spider"), _) => Role::Spider,
_ => Role::Other,
}
}
pub(in crate::shell) fn last_segment(url: &str) -> &str {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let Some(at) = rest.find(['/', '?', '#']) else {
return "";
};
let path = rest[at..].split(['?', '#']).next().unwrap_or("");
path.rsplit('/').next().unwrap_or("")
}
pub(in crate::shell) fn names_its_file(url: &str) -> bool {
let name = last_segment(url);
!matches!(name, "" | "." | "..") && !name.contains(['%', '{', '}', '[', ']', '\\'])
}
pub(in crate::shell) fn written(argv: &[String], client: Client) -> Vec<String> {
let parsed = parse(argv, client);
let roles: Vec<Role<'_>> = parsed.iter().map(|a| role(a, client)).collect();
let dirs: Vec<&str> = roles
.iter()
.filter_map(|r| match r {
Role::Dir(d) if !d.is_empty() => Some(*d),
_ => None,
})
.collect();
let mut files: Vec<String> = Vec::new();
let mut outputs = false;
for r in &roles {
match r {
Role::Output(file) => {
outputs = true;
if !file.is_empty() && *file != "-" {
under(&dirs, file, client == Client::Wget, &mut files);
}
}
Role::WriteOut(format) => {
for part in format.split("%output{").skip(1) {
if let Some((file, _)) = part.split_once('}') {
files.push(file.strip_prefix(">>").unwrap_or(file).to_owned());
}
}
}
_ => {}
}
}
let by_name = match client {
Client::Curl => roles.iter().any(|r| matches!(r, Role::RemoteName)),
Client::Wget => !outputs && !roles.iter().any(|r| matches!(r, Role::Spider)),
};
if by_name {
for r in &roles {
if let Role::Url(url) = r {
let name = match (last_segment(url), client) {
("", Client::Wget) => "index.html",
("", Client::Curl) => "curl_response",
(name, _) => name,
};
under(&dirs, name, false, &mut files);
}
}
}
files
}
fn under(dirs: &[&str], file: &str, also_plain: bool, files: &mut Vec<String>) {
if dirs.is_empty() || also_plain || file.starts_with('/') {
files.push(file.to_owned());
}
for dir in dirs {
let dir = dir.trim_end_matches('/');
files.push(format!("{dir}/{}", file.trim_start_matches('/')));
}
}
#[cfg(test)]
mod tests;