1#[derive(Clone, Debug)]
7pub struct CLOption {
8 pub short: Option<String>,
9 pub long: Option<String>,
10 pub description: String,
11 pub takes_value: bool,
14 pub value_name: String,
16}
17
18impl CLOption {
19 pub fn parse_flag(prefix: &str, description: impl Into<String>) -> Self {
29 let (short, long) = require_aliases(prefix);
30 Self {
31 short,
32 long,
33 description: description.into(),
34 takes_value: false,
35 value_name: String::new(),
36 }
37 }
38
39 pub fn parse_option(prefix: &str, description: impl Into<String>) -> Self {
41 let (short, long) = require_aliases(prefix);
42 Self {
43 short,
44 long,
45 description: description.into(),
46 takes_value: true,
47 value_name: "val".to_string(),
48 }
49 }
50
51 pub fn canonical(&self) -> String {
55 if let Some(long) = &self.long {
56 strip_dashes(long).to_string()
57 } else if let Some(short) = &self.short {
58 strip_dashes(short).to_string()
59 } else {
60 String::new()
61 }
62 }
63
64 pub fn matches_long(&self, name: &str) -> bool {
65 self.long
66 .as_deref()
67 .map(|l| strip_dashes(l) == name)
68 .unwrap_or(false)
69 }
70
71 pub fn matches_short(&self, name: &str) -> bool {
72 self.short
73 .as_deref()
74 .map(|s| strip_dashes(s) == name)
75 .unwrap_or(false)
76 }
77}
78
79fn split_prefix(prefix: &str) -> (Option<String>, Option<String>) {
80 let mut short = None;
81 let mut long = None;
82 for part in prefix.split(',').map(str::trim).filter(|s| !s.is_empty()) {
83 if part.starts_with("--") {
84 long = Some(part.to_string());
85 } else if part.starts_with('-') {
86 short = Some(part.to_string());
87 }
88 }
89 (short, long)
90}
91
92fn require_aliases(prefix: &str) -> (Option<String>, Option<String>) {
93 let (short, long) = split_prefix(prefix);
94 assert!(
95 short.is_some() || long.is_some(),
96 "option prefix '{prefix}' must contain at least one of -<short> or --<long>",
97 );
98 (short, long)
99}
100
101fn strip_dashes(s: &str) -> &str {
102 s.trim_start_matches('-')
103}
104
105#[derive(Clone, Debug)]
107pub struct CLArgument {
108 pub name: String,
109 pub description: String,
110 pub required: bool,
111}
112
113impl CLArgument {
114 pub fn new(name: impl Into<String>, description: impl Into<String>, required: bool) -> Self {
115 Self {
116 name: name.into(),
117 description: description.into(),
118 required,
119 }
120 }
121}
122
123#[derive(Clone, Debug)]
126pub struct CommandSchema {
127 pub name: String,
128 pub description: String,
129 pub options: Vec<CLOption>,
130 pub arguments: Vec<CLArgument>,
131 pub subcommands: Vec<CommandSchema>,
132}
133
134impl CommandSchema {
135 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
136 Self {
137 name: name.into(),
138 description: description.into(),
139 options: Vec::new(),
140 arguments: Vec::new(),
141 subcommands: Vec::new(),
142 }
143 }
144
145 pub fn flag(mut self, prefix: &str, description: &str) -> Self {
147 self.options.push(CLOption::parse_flag(prefix, description));
148 self
149 }
150
151 pub fn option(mut self, prefix: &str, description: &str) -> Self {
153 self.options
154 .push(CLOption::parse_option(prefix, description));
155 self
156 }
157
158 pub fn option_named(mut self, prefix: &str, value_name: &str, description: &str) -> Self {
160 let mut opt = CLOption::parse_option(prefix, description);
161 opt.value_name = value_name.to_string();
162 self.options.push(opt);
163 self
164 }
165
166 pub fn argument(mut self, name: &str, description: &str) -> Self {
168 self.arguments
169 .push(CLArgument::new(name, description, true));
170 self
171 }
172
173 pub fn optional_argument(mut self, name: &str, description: &str) -> Self {
175 self.arguments
176 .push(CLArgument::new(name, description, false));
177 self
178 }
179
180 pub fn subcommand(mut self, schema: CommandSchema) -> Self {
182 self.subcommands.push(schema);
183 self
184 }
185
186 pub(crate) fn find_option_long(&self, name: &str) -> Option<&CLOption> {
187 self.options.iter().find(|o| o.matches_long(name))
188 }
189
190 pub(crate) fn find_option_short(&self, name: &str) -> Option<&CLOption> {
191 self.options.iter().find(|o| o.matches_short(name))
192 }
193
194 pub(crate) fn find_subcommand(&self, name: &str) -> Option<&CommandSchema> {
195 self.subcommands.iter().find(|s| s.name == name)
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use runi_test::pretty_assertions::assert_eq;
203
204 #[test]
205 fn splits_both_short_and_long() {
206 let opt = CLOption::parse_flag("-v,--verbose", "desc");
207 assert_eq!(opt.short.as_deref(), Some("-v"));
208 assert_eq!(opt.long.as_deref(), Some("--verbose"));
209 assert_eq!(opt.canonical(), "verbose");
210 }
211
212 #[test]
213 fn splits_long_only() {
214 let opt = CLOption::parse_option("--count", "desc");
215 assert_eq!(opt.short, None);
216 assert_eq!(opt.long.as_deref(), Some("--count"));
217 assert!(opt.takes_value);
218 }
219
220 #[test]
221 fn splits_short_only() {
222 let opt = CLOption::parse_flag("-n", "desc");
223 assert_eq!(opt.short.as_deref(), Some("-n"));
224 assert_eq!(opt.long, None);
225 assert_eq!(opt.canonical(), "n");
226 }
227
228 #[test]
229 fn matches_strip_dashes() {
230 let opt = CLOption::parse_flag("-v,--verbose", "desc");
231 assert!(opt.matches_long("verbose"));
232 assert!(opt.matches_short("v"));
233 assert!(!opt.matches_long("v"));
234 }
235
236 #[test]
237 #[should_panic(expected = "option prefix 'verbose' must contain at least one of")]
238 fn option_prefix_without_dashes_panics() {
239 let _ = CLOption::parse_flag("verbose", "");
240 }
241
242 #[test]
243 #[should_panic(expected = "option prefix '' must contain at least one of")]
244 fn empty_option_prefix_panics() {
245 let _ = CLOption::parse_option("", "");
246 }
247
248 #[test]
249 fn builder_collects_options_and_args() {
250 let s = CommandSchema::new("app", "desc")
251 .flag("-v,--verbose", "verbose")
252 .option("-n,--count", "count")
253 .argument("file", "input file")
254 .optional_argument("out", "output file");
255 assert_eq!(s.options.len(), 2);
256 assert_eq!(s.arguments.len(), 2);
257 assert!(s.arguments[0].required);
258 assert!(!s.arguments[1].required);
259 }
260}