pub mod exit {
pub const UNKNOWN_OPTION: i32 = 1;
pub const MISSING_ARGUMENT: i32 = 2;
pub const NO_FILES: i32 = 4;
}
pub type Option_ = (&'static str, Option<char>, bool);
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Parsed {
pub options: Vec<(&'static str, Option<String>)>,
pub files: Vec<String>,
}
pub fn parse(program: &str, table: &[Option_], raw: Vec<String>) -> Result<Parsed, (String, i32)> {
let mut parsed = Parsed::default();
let mut rest = raw.into_iter().peekable();
while let Some(argument) = rest.peek().cloned() {
if !argument.starts_with('-') || argument == "-" {
break;
}
rest.next();
if argument == "--" {
break;
}
if let Some(long) = argument.strip_prefix("--") {
let (name, inline) = match long.split_once('=') {
Some((name, value)) => (name, Some(value.to_string())),
None => (long, None),
};
let Some((name, _, takes_value)) = table.iter().find(|(option, _, _)| *option == name)
else {
return Err((
format!("{program}: unrecognized option '--{name}'"),
exit::UNKNOWN_OPTION,
));
};
let value = match takes_value {
false => None,
true => match inline.or_else(|| rest.next()) {
Some(value) => Some(value),
None => {
return Err((
format!("{program}: option '--{name}' requires an argument"),
exit::MISSING_ARGUMENT,
))
}
},
};
parsed.options.push((name, value));
continue;
}
for letter in argument.trim_start_matches('-').chars() {
let Some((name, _, takes_value)) =
table.iter().find(|(_, short, _)| *short == Some(letter))
else {
return Err((
format!("{program}: unrecognized option '{letter}'"),
exit::UNKNOWN_OPTION,
));
};
let value = match takes_value {
false => None,
true => match rest.next() {
Some(value) => Some(value),
None => {
return Err((
format!("{program}: option '-{letter}' requires an argument"),
exit::MISSING_ARGUMENT,
))
}
},
};
parsed.options.push((name, value));
}
}
parsed.files.extend(rest);
Ok(parsed)
}
pub fn shown_name(file: &str) -> String {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
format!("{file:?}")
} else {
file.to_string()
}
}
pub fn database_dirs(database: &Option<String>) -> Vec<std::path::PathBuf> {
match database {
Some(list) => list.split(':').filter(|d| !d.is_empty()).map(std::path::PathBuf::from).collect(),
None => crate::data_dirs(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const TABLE: &[Option_] = &[
("brief", Some('b'), false),
("language", Some('l'), true),
("all", Some('a'), false),
("database", None, true),
];
fn parsed(argv: &[&str]) -> Parsed {
parse("mimetype", TABLE, argv.iter().map(|a| a.to_string()).collect()).expect("these parse")
}
#[test]
fn short_options_can_be_bundled_and_the_last_one_takes_the_argument() {
let args = parsed(&["-bl", "de", "part.3mf"]);
assert_eq!(args.options, [("brief", None), ("language", Some("de".to_string()))]);
assert_eq!(args.files, ["part.3mf"]);
}
#[test]
fn a_long_option_takes_its_value_attached_or_apart() {
assert_eq!(parsed(&["--language=fr", "x"]).options, [("language", Some("fr".to_string()))]);
assert_eq!(parsed(&["--language", "fr", "x"]).options, [("language", Some("fr".to_string()))]);
}
#[test]
fn a_double_dash_ends_the_options() {
let args = parsed(&["--brief", "--", "-b", "--all"]);
assert_eq!(args.options, [("brief", None)]);
assert_eq!(args.files, ["-b", "--all"]);
}
#[test]
fn a_bare_dash_is_a_filename_not_an_option() {
assert_eq!(parsed(&["-"]).files, ["-"]);
}
#[test]
fn an_unknown_option_is_named_with_the_exit_code_the_reference_uses() {
let (message, code) = parse("mimetype", TABLE, vec!["--nonsense".to_string()]).unwrap_err();
assert!(message.contains("mimetype: unrecognized option '--nonsense'"), "{message}");
assert_eq!(code, exit::UNKNOWN_OPTION);
let (message, code) = parse("mimeopen", TABLE, vec!["--language".to_string()]).unwrap_err();
assert!(message.contains("mimeopen: option '--language' requires an argument"), "{message}");
assert_eq!(code, exit::MISSING_ARGUMENT);
}
#[test]
fn the_error_names_the_command_that_was_run() {
let (message, _) = parse("mimeopen", TABLE, vec!["-z".to_string()]).unwrap_err();
assert!(message.starts_with("mimeopen:"), "{message}");
}
#[test]
fn the_database_option_replaces_the_xdg_directories() {
let dirs = database_dirs(&Some("/a:/b".to_string()));
assert_eq!(dirs, [std::path::PathBuf::from("/a"), std::path::PathBuf::from("/b")]);
assert_eq!(database_dirs(&None), crate::data_dirs());
}
}