1pub mod exit {
18 pub const UNKNOWN_OPTION: i32 = 1;
20 pub const MISSING_ARGUMENT: i32 = 2;
22 pub const NO_FILES: i32 = 4;
24}
25
26pub type Option_ = (&'static str, Option<char>, bool);
29
30#[derive(Debug, Default, PartialEq, Eq)]
33pub struct Parsed {
34 pub options: Vec<(&'static str, Option<String>)>,
35 pub files: Vec<String>,
36}
37
38pub fn parse(program: &str, table: &[Option_], raw: Vec<String>) -> Result<Parsed, (String, i32)> {
45 let mut parsed = Parsed::default();
46 let mut rest = raw.into_iter().peekable();
47 while let Some(argument) = rest.peek().cloned() {
48 if !argument.starts_with('-') || argument == "-" {
51 break;
52 }
53 rest.next();
54 if argument == "--" {
55 break;
56 }
57 if let Some(long) = argument.strip_prefix("--") {
58 let (name, inline) = match long.split_once('=') {
59 Some((name, value)) => (name, Some(value.to_string())),
60 None => (long, None),
61 };
62 let Some((name, _, takes_value)) = table.iter().find(|(option, _, _)| *option == name)
63 else {
64 return Err((
65 format!("{program}: unrecognized option '--{name}'"),
66 exit::UNKNOWN_OPTION,
67 ));
68 };
69 let value = match takes_value {
70 false => None,
71 true => match inline.or_else(|| rest.next()) {
72 Some(value) => Some(value),
73 None => {
74 return Err((
75 format!("{program}: option '--{name}' requires an argument"),
76 exit::MISSING_ARGUMENT,
77 ))
78 }
79 },
80 };
81 parsed.options.push((name, value));
82 continue;
83 }
84 for letter in argument.trim_start_matches('-').chars() {
87 let Some((name, _, takes_value)) =
88 table.iter().find(|(_, short, _)| *short == Some(letter))
89 else {
90 return Err((
91 format!("{program}: unrecognized option '{letter}'"),
92 exit::UNKNOWN_OPTION,
93 ));
94 };
95 let value = match takes_value {
96 false => None,
97 true => match rest.next() {
98 Some(value) => Some(value),
99 None => {
100 return Err((
101 format!("{program}: option '-{letter}' requires an argument"),
102 exit::MISSING_ARGUMENT,
103 ))
104 }
105 },
106 };
107 parsed.options.push((name, value));
108 }
109 }
110 parsed.files.extend(rest);
111 Ok(parsed)
112}
113
114pub fn shown_name(file: &str) -> String {
118 use std::io::IsTerminal;
119 if std::io::stdout().is_terminal() {
120 format!("{file:?}")
121 } else {
122 file.to_string()
123 }
124}
125
126pub fn database_dirs(database: &Option<String>) -> Vec<std::path::PathBuf> {
129 match database {
130 Some(list) => list.split(':').filter(|d| !d.is_empty()).map(std::path::PathBuf::from).collect(),
131 None => crate::data_dirs(),
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 const TABLE: &[Option_] = &[
140 ("brief", Some('b'), false),
141 ("language", Some('l'), true),
142 ("all", Some('a'), false),
143 ("database", None, true),
144 ];
145
146 fn parsed(argv: &[&str]) -> Parsed {
147 parse("mimetype", TABLE, argv.iter().map(|a| a.to_string()).collect()).expect("these parse")
148 }
149
150 #[test]
151 fn short_options_can_be_bundled_and_the_last_one_takes_the_argument() {
152 let args = parsed(&["-bl", "de", "part.3mf"]);
153 assert_eq!(args.options, [("brief", None), ("language", Some("de".to_string()))]);
154 assert_eq!(args.files, ["part.3mf"]);
155 }
156
157 #[test]
158 fn a_long_option_takes_its_value_attached_or_apart() {
159 assert_eq!(parsed(&["--language=fr", "x"]).options, [("language", Some("fr".to_string()))]);
160 assert_eq!(parsed(&["--language", "fr", "x"]).options, [("language", Some("fr".to_string()))]);
161 }
162
163 #[test]
166 fn a_double_dash_ends_the_options() {
167 let args = parsed(&["--brief", "--", "-b", "--all"]);
168 assert_eq!(args.options, [("brief", None)]);
169 assert_eq!(args.files, ["-b", "--all"]);
170 }
171
172 #[test]
173 fn a_bare_dash_is_a_filename_not_an_option() {
174 assert_eq!(parsed(&["-"]).files, ["-"]);
175 }
176
177 #[test]
178 fn an_unknown_option_is_named_with_the_exit_code_the_reference_uses() {
179 let (message, code) = parse("mimetype", TABLE, vec!["--nonsense".to_string()]).unwrap_err();
180 assert!(message.contains("mimetype: unrecognized option '--nonsense'"), "{message}");
181 assert_eq!(code, exit::UNKNOWN_OPTION);
182
183 let (message, code) = parse("mimeopen", TABLE, vec!["--language".to_string()]).unwrap_err();
184 assert!(message.contains("mimeopen: option '--language' requires an argument"), "{message}");
185 assert_eq!(code, exit::MISSING_ARGUMENT);
186 }
187
188 #[test]
191 fn the_error_names_the_command_that_was_run() {
192 let (message, _) = parse("mimeopen", TABLE, vec!["-z".to_string()]).unwrap_err();
193 assert!(message.starts_with("mimeopen:"), "{message}");
194 }
195
196 #[test]
197 fn the_database_option_replaces_the_xdg_directories() {
198 let dirs = database_dirs(&Some("/a:/b".to_string()));
199 assert_eq!(dirs, [std::path::PathBuf::from("/a"), std::path::PathBuf::from("/b")]);
200 assert_eq!(database_dirs(&None), crate::data_dirs());
201 }
202}