Skip to main content

runi_cli/launcher/
schema.rs

1/// A single named option (`-v,--verbose`, `--count`, etc.).
2///
3/// Uni's launcher lets callers describe an option with a single comma-separated
4/// prefix string; we parse that same syntax here so port-over code reads the
5/// same.
6#[derive(Clone, Debug)]
7pub struct CLOption {
8    pub short: Option<String>,
9    pub long: Option<String>,
10    pub description: String,
11    /// `true` when the option consumes the next argument as its value,
12    /// `false` for boolean flags.
13    pub takes_value: bool,
14    /// Placeholder shown in help output (e.g. `<val>`). Ignored for flags.
15    pub value_name: String,
16}
17
18impl CLOption {
19    /// Parse a prefix like `"-v,--verbose"` into short/long tokens.
20    ///
21    /// Returns a `CLOption` with `takes_value == false` (i.e. a flag).
22    /// Use [`CLOption::parse_option`] to build a value-consuming option.
23    ///
24    /// Panics if the prefix produces neither a short nor long alias (e.g.
25    /// `""` or `"verbose"` without a leading dash). Such an option can
26    /// never be matched by the parser and would collide with other
27    /// malformed options in the result map.
28    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    /// Like [`CLOption::parse_flag`] but the option consumes the next argument.
40    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    /// Canonical lookup key — the long name without dashes if present,
52    /// otherwise the short name without dashes. Empty schemas are rejected
53    /// at build time so one of the two is always populated.
54    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/// A positional argument (required or optional).
106#[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/// Schema for a command (root or subcommand). Build with the fluent API
124/// or construct the struct literally for tests.
125#[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    /// Add a boolean flag.
146    pub fn flag(mut self, prefix: &str, description: &str) -> Self {
147        self.options.push(CLOption::parse_flag(prefix, description));
148        self
149    }
150
151    /// Add a value-consuming option.
152    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    /// Add a value-consuming option with a custom placeholder shown in help.
159    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    /// Add a required positional argument.
167    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    /// Add an optional positional argument.
174    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    /// Register a subcommand schema.
181    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}