use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Invocation {
Run {
file: Option<PathBuf>,
opts: RunOpts,
},
Help,
Version,
Doctor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RunOpts {
pub no_config: bool,
pub log: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CliError {
#[error("unknown option '{0}'")]
UnknownOption(String),
#[error("unexpected argument '{0}' — edamame opens one file at a time")]
ExtraArgument(String),
#[error("reading from stdin is not supported")]
StdinNotSupported,
}
impl Invocation {
pub fn parse<I>(args: I) -> Result<Self, CliError>
where
I: IntoIterator<Item = OsString>,
{
let mut file: Option<PathBuf> = None;
let mut opts = RunOpts::default();
let mut help = false;
let mut version = false;
let mut doctor = false;
let mut positional_only = false;
for arg in args {
let flag = if positional_only {
None
} else {
arg.to_str().filter(|s| s.starts_with('-'))
};
match flag {
Some("--") => positional_only = true,
Some("-h" | "--help") => help = true,
Some("-V" | "--version") => version = true,
Some("--doctor") => doctor = true,
Some("--no-config") => opts.no_config = true,
Some("--log") => opts.log = true,
Some("-") => return Err(CliError::StdinNotSupported),
Some(other) => return Err(CliError::UnknownOption(other.to_owned())),
None => {
if file.is_some() {
return Err(CliError::ExtraArgument(arg.to_string_lossy().into_owned()));
}
file = Some(PathBuf::from(arg));
}
}
}
Ok(if help {
Self::Help
} else if version {
Self::Version
} else if doctor {
Self::Doctor
} else {
Self::Run { file, opts }
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Invocation, CliError> {
Invocation::parse(args.iter().map(OsString::from))
}
fn run(args: &[&str]) -> (Option<PathBuf>, RunOpts) {
match parse(args).expect("parses") {
Invocation::Run { file, opts } => (file, opts),
other => panic!("expected Run, got {other:?}"),
}
}
#[test]
fn no_arguments_starts_an_empty_buffer() {
assert_eq!(run(&[]), (None, RunOpts::default()));
}
#[test]
fn a_bare_path_is_the_file_to_open() {
let (file, opts) = run(&["notes.md"]);
assert_eq!(file, Some(PathBuf::from("notes.md")));
assert_eq!(opts, RunOpts::default());
}
#[test]
fn run_flags_combine_with_a_file_in_any_order() {
let expected = RunOpts {
no_config: true,
log: true,
};
for args in [
["--no-config", "--log", "notes.md"],
["notes.md", "--no-config", "--log"],
["--log", "notes.md", "--no-config"],
] {
let (file, opts) = run(&args);
assert_eq!(file, Some(PathBuf::from("notes.md")), "{args:?}");
assert_eq!(opts, expected, "{args:?}");
}
}
#[test]
fn informational_flags_have_both_spellings() {
assert_eq!(parse(&["--help"]), Ok(Invocation::Help));
assert_eq!(parse(&["-h"]), Ok(Invocation::Help));
assert_eq!(parse(&["--version"]), Ok(Invocation::Version));
assert_eq!(parse(&["-V"]), Ok(Invocation::Version));
assert_eq!(parse(&["--doctor"]), Ok(Invocation::Doctor));
}
#[test]
fn informational_flags_win_over_a_run() {
assert_eq!(
parse(&["--doctor", "--version", "--help"]),
Ok(Invocation::Help)
);
assert_eq!(parse(&["--doctor", "--version"]), Ok(Invocation::Version));
assert_eq!(parse(&["notes.md", "--doctor"]), Ok(Invocation::Doctor));
assert_eq!(
parse(&["--no-config", "--version"]),
Ok(Invocation::Version)
);
}
#[test]
fn double_dash_ends_flag_parsing() {
let (file, opts) = run(&["--log", "--", "--doctor"]);
assert_eq!(file, Some(PathBuf::from("--doctor")));
assert!(opts.log, "flags before `--` still apply");
assert_eq!(
parse(&["--", "a.md", "--"]),
Err(CliError::ExtraArgument("--".to_owned()))
);
}
#[test]
fn unknown_flags_and_extra_files_are_rejected() {
assert_eq!(
parse(&["--doctorr"]),
Err(CliError::UnknownOption("--doctorr".to_owned()))
);
assert_eq!(
parse(&["-x"]),
Err(CliError::UnknownOption("-x".to_owned()))
);
assert_eq!(
parse(&["-hV"]),
Err(CliError::UnknownOption("-hV".to_owned()))
);
assert_eq!(
parse(&["a.md", "b.md"]),
Err(CliError::ExtraArgument("b.md".to_owned()))
);
}
#[test]
fn a_bare_dash_is_refused_rather_than_opened_as_a_file() {
assert_eq!(parse(&["-"]), Err(CliError::StdinNotSupported));
}
#[cfg(unix)]
#[test]
fn non_utf8_file_names_survive() {
use std::os::unix::ffi::OsStringExt;
let raw = OsString::from_vec(vec![b'n', 0xff, b'.', b'm', b'd']);
let parsed = Invocation::parse([raw.clone()]).expect("parses");
assert_eq!(
parsed,
Invocation::Run {
file: Some(PathBuf::from(raw)),
opts: RunOpts::default(),
}
);
}
}