use std::{ffi::OsString, path::PathBuf};
use crate::cli::ui::{ColorChoice, display_path};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Verbosity {
Quiet,
Normal,
Verbose,
}
#[derive(Debug)]
pub struct Invocation {
pub command: Command,
pub options: Options,
}
#[derive(Debug)]
pub enum Command {
Pack {
input: PathBuf,
output: Option<PathBuf>,
},
Extract {
archive: PathBuf,
output: Option<PathBuf>,
},
List {
archive: PathBuf,
path: Option<OsString>,
},
Info {
archive: PathBuf,
},
Verify {
archive: PathBuf,
},
Help,
Version,
}
#[derive(Debug)]
pub struct Options {
pub output: Option<PathBuf>,
pub force: bool,
pub verbosity: Verbosity,
pub progress: bool,
pub color: ColorChoice,
pub tree: bool,
pub check: bool,
}
impl Default for Options {
fn default() -> Self {
Self {
output: None,
force: false,
verbosity: Verbosity::Normal,
progress: true,
color: ColorChoice::Auto,
tree: false,
check: false,
}
}
}
#[derive(Debug)]
pub struct UsageError(pub String);
impl UsageError {
fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
type Parsed<T> = Result<T, UsageError>;
pub fn parse(raw: impl IntoIterator<Item = OsString>) -> Parsed<Invocation> {
let raw = raw.into_iter().collect::<Vec<_>>();
if raw.is_empty() {
return Ok(Invocation {
command: Command::Help,
options: Options::default(),
});
}
let (mut positionals, mut options, wants_help, wants_version) = split(&raw)?;
if wants_help {
return Ok(Invocation {
command: Command::Help,
options,
});
}
if wants_version {
return Ok(Invocation {
command: Command::Version,
options,
});
}
let name = match positionals.first().and_then(|first| first.to_str()) {
Some(text) if is_command_name(text) => {
let name = text.to_owned();
positionals.pop_front();
name
}
_ => infer_command(positionals.first())?,
};
let mut take_output = |positionals: &mut Positionals| -> Parsed<Option<PathBuf>> {
match (positionals.pop_front_path(), options.output.take()) {
(Some(_), Some(_)) => Err(UsageError::new(
"the output path was given both positionally and with --output",
)),
(positional, flag) => Ok(positional.or(flag)),
}
};
let command = match name.as_str() {
"help" => Command::Help,
"version" => Command::Version,
"pack" => {
let input = take_path(&mut positionals, "pack", "a source directory")?;
let output = take_output(&mut positionals)?;
Command::Pack { input, output }
}
"extract" => {
let archive = take_path(&mut positionals, "extract", "an archive")?;
let output = take_output(&mut positionals)?;
Command::Extract { archive, output }
}
"list" => {
let archive = take_path(&mut positionals, "list", "an archive")?;
let path = positionals.pop_front();
Command::List { archive, path }
}
"info" => Command::Info {
archive: take_path(&mut positionals, "info", "an archive")?,
},
"verify" => Command::Verify {
archive: take_path(&mut positionals, "verify", "an archive")?,
},
other => return Err(UsageError::new(format!("unknown command `{other}`"))),
};
if let Some(extra) = positionals.pop_front() {
return Err(UsageError::new(format!(
"unexpected argument `{}`",
extra.to_string_lossy()
)));
}
if options.verbosity == Verbosity::Quiet {
options.progress = false;
}
Ok(Invocation { command, options })
}
fn is_command_name(text: &str) -> bool {
matches!(
text,
"pack" | "extract" | "list" | "info" | "verify" | "help" | "version"
)
}
fn infer_command(first: Option<&OsString>) -> Parsed<String> {
let Some(path) = first else {
return Err(UsageError::new("no command or path given"));
};
let path = PathBuf::from(path);
if path.is_dir() {
Ok("pack".to_owned())
} else if path.is_file() {
Ok("extract".to_owned())
} else {
Err(UsageError::new(format!(
"`{}` is not an existing file or directory",
display_path(&path)
)))
}
}
fn take_path(positionals: &mut Positionals, command: &str, what: &str) -> Parsed<PathBuf> {
positionals
.pop_front_path()
.ok_or_else(|| UsageError::new(format!("`{command}` needs {what}")))
}
#[derive(Debug, Default)]
struct Positionals(std::collections::VecDeque<OsString>);
impl Positionals {
fn push(&mut self, value: OsString) {
self.0.push_back(value);
}
fn first(&self) -> Option<&OsString> {
self.0.front()
}
fn pop_front(&mut self) -> Option<OsString> {
self.0.pop_front()
}
fn pop_front_path(&mut self) -> Option<PathBuf> {
self.0.pop_front().map(PathBuf::from)
}
}
fn split(args: &[OsString]) -> Parsed<(Positionals, Options, bool, bool)> {
let mut positionals = Positionals::default();
let mut options = Options::default();
let mut help = false;
let mut version = false;
let mut literal = false;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
index += 1;
if literal {
positionals.push(arg.clone());
continue;
}
let Some(text) = arg.to_str() else {
positionals.push(arg.clone());
continue;
};
if text == "--" {
literal = true;
} else if let Some(long) = text.strip_prefix("--") {
let (name, attached) = match long.split_once('=') {
Some((name, value)) => (name, Some(value.to_owned())),
None => (long, None),
};
match name {
"help" => help = true,
"version" => version = true,
"force" => options.force = true,
"verbose" => options.verbosity = Verbosity::Verbose,
"quiet" => options.verbosity = Verbosity::Quiet,
"tree" => options.tree = true,
"check" => options.check = true,
"no-progress" => options.progress = false,
"color" => {
options.color = parse_color(&value(attached, args, &mut index, "--color")?)?;
}
"output" => {
let value = match attached {
Some(value) => OsString::from(value),
None => next(args, &mut index, "--output")?,
};
options.output = Some(PathBuf::from(value));
}
other => return Err(UsageError::new(format!("unknown option `--{other}`"))),
}
} else if text.len() > 1 && text.starts_with('-') {
let chars = text[1..].char_indices();
for (position, flag) in chars {
let attached = || {
let rest = &text[1 + position + flag.len_utf8()..];
(!rest.is_empty()).then(|| rest.to_owned())
};
match flag {
'h' => help = true,
'V' => version = true,
'f' => options.force = true,
'v' => options.verbosity = Verbosity::Verbose,
'q' => options.verbosity = Verbosity::Quiet,
'o' => {
let value = match attached() {
Some(value) => OsString::from(value),
None => next(args, &mut index, "-o")?,
};
options.output = Some(PathBuf::from(value));
break;
}
other => return Err(UsageError::new(format!("unknown option `-{other}`"))),
}
}
} else {
positionals.push(arg.clone());
}
}
Ok((positionals, options, help, version))
}
fn value(
attached: Option<String>,
args: &[OsString],
index: &mut usize,
name: &str,
) -> Parsed<String> {
match attached {
Some(value) => Ok(value),
None => next(args, index, name)?
.into_string()
.map_err(|_| UsageError::new(format!("`{name}` needs a valid UTF-8 value"))),
}
}
fn next(args: &[OsString], index: &mut usize, name: &str) -> Parsed<OsString> {
let value = args
.get(*index)
.ok_or_else(|| UsageError::new(format!("`{name}` needs a value")))?;
*index += 1;
Ok(value.clone())
}
fn parse_color(text: &str) -> Parsed<ColorChoice> {
match text {
"auto" => Ok(ColorChoice::Auto),
"always" => Ok(ColorChoice::Always),
"never" => Ok(ColorChoice::Never),
other => Err(UsageError::new(format!(
"`{other}` is not a color mode (use auto, always, or never)"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_args(args: &[&str]) -> Parsed<Invocation> {
parse(args.iter().map(OsString::from))
}
#[test]
fn reads_subcommands_and_positionals() {
let parsed = parse_args(&["pack", "src", "out.zar"]).unwrap();
let Command::Pack { input, output } = parsed.command else {
panic!("expected pack");
};
assert_eq!(input, PathBuf::from("src"));
assert_eq!(output, Some(PathBuf::from("out.zar")));
}
#[test]
fn accepts_every_option_spelling() {
let parsed = parse_args(&["pack", "src", "--color=never", "-fv"]).unwrap();
assert_eq!(parsed.options.color, ColorChoice::Never);
assert!(parsed.options.force);
assert_eq!(parsed.options.verbosity, Verbosity::Verbose);
let parsed = parse_args(&["pack", "src", "-o", "out.zar"]).unwrap();
let Command::Pack { output, .. } = parsed.command else {
panic!("expected pack");
};
assert_eq!(output, Some(PathBuf::from("out.zar")));
}
#[test]
fn accepts_options_on_either_side_of_the_subcommand() {
for args in [
&["--color", "never", "pack", "src", "out.zar"][..],
&["pack", "--color", "never", "src", "out.zar"][..],
&["pack", "src", "out.zar", "--color", "never"][..],
] {
let parsed = parse_args(args).unwrap();
assert_eq!(parsed.options.color, ColorChoice::Never, "{args:?}");
let Command::Pack { input, output } = parsed.command else {
panic!("expected pack for {args:?}");
};
assert_eq!(input, PathBuf::from("src"));
assert_eq!(output, Some(PathBuf::from("out.zar")));
}
}
#[test]
fn rejects_an_output_given_twice() {
assert!(parse_args(&["pack", "src", "out.zar", "-o", "other.zar"]).is_err());
}
#[test]
fn quiet_disables_the_progress_bar() {
let parsed = parse_args(&["pack", "src", "--quiet"]).unwrap();
assert!(!parsed.options.progress);
}
#[test]
fn treats_double_dash_as_a_literal_terminator() {
let parsed = parse_args(&["list", "--", "--weird-name.zar"]).unwrap();
let Command::List { archive, .. } = parsed.command else {
panic!("expected list");
};
assert_eq!(archive, PathBuf::from("--weird-name.zar"));
}
#[test]
fn rejects_bad_options_and_missing_arguments() {
assert!(parse_args(&["pack"]).is_err());
assert!(parse_args(&["pack", "src", "--nope"]).is_err());
assert!(parse_args(&["pack", "src", "--color", "mauve"]).is_err());
assert!(parse_args(&["pack", "src", "--color"]).is_err());
assert!(parse_args(&["verify", "a.zar", "b.zar"]).is_err());
}
#[test]
fn help_and_version_win_over_other_arguments() {
assert!(matches!(
parse_args(&["pack", "src", "--help"]).unwrap().command,
Command::Help
));
assert!(matches!(
parse_args(&["--version"]).unwrap().command,
Command::Version
));
assert!(matches!(parse_args(&[]).unwrap().command, Command::Help));
}
}