windjammer_runtime/
cli.rs

1//! Command-line argument parsing with builder pattern
2//!
3//! Windjammer's `std::cli` module maps to these functions.
4//! Provides a fluent builder API similar to clap.
5
6use clap::{Arg, ArgAction, ArgMatches, Command};
7
8/// Create a new CLI application builder
9pub fn new(name: impl Into<String>) -> AppBuilder {
10    AppBuilder {
11        name: name.into(),
12        version: None,
13        author: None,
14        about: None,
15        args: Vec::new(),
16    }
17}
18
19/// Create a new argument (positional or required)
20pub fn arg(name: impl Into<String>) -> ArgBuilder {
21    ArgBuilder {
22        name: name.into(),
23        short: None,
24        long: None,
25        help: None,
26        required: false,
27        multiple: false,
28        default_value: None,
29        arg_type: ArgType::Positional,
30    }
31}
32
33/// Create a new flag (boolean, no value)
34pub fn flag(name: impl Into<String>) -> ArgBuilder {
35    ArgBuilder {
36        name: name.into(),
37        short: None,
38        long: None,
39        help: None,
40        required: false,
41        multiple: false,
42        default_value: None,
43        arg_type: ArgType::Flag,
44    }
45}
46
47/// Create a new option (takes a value)
48pub fn option(name: impl Into<String>) -> ArgBuilder {
49    ArgBuilder {
50        name: name.into(),
51        short: None,
52        long: None,
53        help: None,
54        required: false,
55        multiple: false,
56        default_value: None,
57        arg_type: ArgType::Option,
58    }
59}
60
61#[derive(Debug, Clone)]
62enum ArgType {
63    Positional,
64    Flag,
65    Option,
66}
67
68/// CLI Application builder
69#[derive(Debug, Clone)]
70pub struct AppBuilder {
71    name: String,
72    version: Option<String>,
73    author: Option<String>,
74    about: Option<String>,
75    args: Vec<ArgBuilder>,
76}
77
78/// CLI Argument builder
79#[derive(Debug, Clone)]
80pub struct ArgBuilder {
81    name: String,
82    short: Option<String>,
83    long: Option<String>,
84    help: Option<String>,
85    required: bool,
86    multiple: bool,
87    default_value: Option<String>,
88    arg_type: ArgType,
89}
90
91impl AppBuilder {
92    /// Set the version
93    pub fn version(mut self, version: impl Into<String>) -> Self {
94        self.version = Some(version.into());
95        self
96    }
97
98    /// Set the author
99    pub fn author(mut self, author: impl Into<String>) -> Self {
100        self.author = Some(author.into());
101        self
102    }
103
104    /// Set the about text
105    pub fn about(mut self, about: impl Into<String>) -> Self {
106        self.about = Some(about.into());
107        self
108    }
109
110    /// Add an argument
111    pub fn arg(mut self, arg: ArgBuilder) -> Self {
112        self.args.push(arg);
113        self
114    }
115
116    /// Parse command-line arguments
117    pub fn parse(self) -> CliMatches {
118        // Use Box::leak to get 'static strings (acceptable for CLI parsing which happens once)
119        let name: &'static str = Box::leak(self.name.into_boxed_str());
120        let mut cmd = Command::new(name);
121
122        if let Some(version) = self.version {
123            let version_static: &'static str = Box::leak(version.into_boxed_str());
124            cmd = cmd.version(version_static);
125        }
126        if let Some(author) = self.author {
127            let author_static: &'static str = Box::leak(author.into_boxed_str());
128            cmd = cmd.author(author_static);
129        }
130        if let Some(about) = self.about {
131            let about_static: &'static str = Box::leak(about.into_boxed_str());
132            cmd = cmd.about(about_static);
133        }
134
135        for arg_builder in self.args {
136            // Use Box::leak to get 'static strings (acceptable for CLI parsing which happens once)
137            let name: &'static str = Box::leak(arg_builder.name.into_boxed_str());
138
139            let mut arg = Arg::new(name);
140
141            if let Some(help_str) = arg_builder.help {
142                let help: &'static str = Box::leak(help_str.into_boxed_str());
143                arg = arg.help(help);
144            }
145
146            if let Some(short_str) = arg_builder.short {
147                if let Some(c) = short_str.chars().next() {
148                    arg = arg.short(c);
149                }
150            }
151
152            if let Some(long_str) = arg_builder.long {
153                let long: &'static str = Box::leak(long_str.into_boxed_str());
154                arg = arg.long(long);
155            }
156
157            match arg_builder.arg_type {
158                ArgType::Positional => {
159                    arg = arg.required(arg_builder.required);
160                    if arg_builder.multiple {
161                        arg = arg.num_args(1..);
162                    }
163                    if let Some(default_str) = arg_builder.default_value {
164                        let default: &'static str = Box::leak(default_str.into_boxed_str());
165                        arg = arg.default_value(default);
166                    }
167                }
168                ArgType::Flag => {
169                    arg = arg.action(ArgAction::SetTrue);
170                }
171                ArgType::Option => {
172                    arg = arg.required(arg_builder.required);
173                    if arg_builder.multiple {
174                        arg = arg.num_args(0..);
175                        arg = arg.action(ArgAction::Append);
176                    } else {
177                        arg = arg.num_args(0..=1);
178                    }
179                    if let Some(default_str) = arg_builder.default_value {
180                        let default: &'static str = Box::leak(default_str.into_boxed_str());
181                        arg = arg.default_value(default);
182                    }
183                }
184            }
185
186            cmd = cmd.arg(arg);
187        }
188
189        let matches = cmd.get_matches();
190        CliMatches { matches }
191    }
192
193    /// Get matches without consuming (for testing)
194    pub fn get_matches(self) -> CliMatches {
195        self.parse()
196    }
197}
198
199impl ArgBuilder {
200    /// Set the help text
201    pub fn help(mut self, help: impl Into<String>) -> Self {
202        self.help = Some(help.into());
203        self
204    }
205
206    /// Set the short flag (single character)
207    pub fn short(mut self, short: impl Into<String>) -> Self {
208        self.short = Some(short.into());
209        self
210    }
211
212    /// Set the long flag
213    pub fn long(mut self, long: impl Into<String>) -> Self {
214        self.long = Some(long.into());
215        self
216    }
217
218    /// Mark as required
219    pub fn required(mut self, required: bool) -> Self {
220        self.required = required;
221        self
222    }
223
224    /// Allow multiple values
225    pub fn multiple(mut self, multiple: bool) -> Self {
226        self.multiple = multiple;
227        self
228    }
229
230    /// Set default value
231    pub fn default_value(mut self, value: impl Into<String>) -> Self {
232        self.default_value = Some(value.into());
233        self
234    }
235}
236
237/// Parsed CLI matches
238pub struct CliMatches {
239    matches: ArgMatches,
240}
241
242impl CliMatches {
243    /// Get string value
244    pub fn get(&self, name: &str) -> Option<String> {
245        self.matches.get_one::<String>(name).cloned()
246    }
247
248    /// Get string value (returns Option for unwrap/unwrap_or chaining)
249    pub fn value_of(&self, name: &str) -> Option<String> {
250        self.get(name)
251    }
252
253    /// Check if flag is present
254    pub fn is_present(&self, name: &str) -> bool {
255        self.matches.get_flag(name)
256    }
257
258    /// Get all values for an argument
259    pub fn get_many(&self, name: &str) -> Vec<String> {
260        self.matches
261            .get_many::<String>(name)
262            .map(|vals| vals.map(|s| s.to_string()).collect())
263            .unwrap_or_default()
264    }
265
266    /// Get all values for an argument (returns Option for unwrap/unwrap_or chaining)
267    pub fn values_of(&self, name: &str) -> Option<Vec<String>> {
268        let vals = self
269            .matches
270            .get_many::<String>(name)
271            .map(|vals| vals.map(|s| s.to_string()).collect::<Vec<String>>());
272        vals
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_cli_builder() {
282        let app = new("test")
283            .version("1.0")
284            .author("Test Author")
285            .about("Test app")
286            .arg(arg("pattern").help("Pattern to search").required(true))
287            .arg(flag("verbose").short("v").help("Verbose output"))
288            .arg(
289                option("threads")
290                    .short("j")
291                    .help("Number of threads")
292                    .default_value("4"),
293            );
294
295        assert_eq!(app.name, "test");
296        assert_eq!(app.version, Some("1.0".to_string()));
297        assert_eq!(app.args.len(), 3);
298    }
299
300    #[test]
301    fn test_arg_builder() {
302        let arg = arg("pattern").help("Pattern").required(true);
303        assert_eq!(arg.name, "pattern");
304        assert!(arg.required);
305    }
306
307    #[test]
308    fn test_flag_builder() {
309        let flag = flag("verbose").short("v").help("Verbose");
310        assert_eq!(flag.name, "verbose");
311        matches!(flag.arg_type, ArgType::Flag);
312    }
313
314    #[test]
315    fn test_option_builder() {
316        let opt = option("threads").short("j").default_value("4");
317        assert_eq!(opt.name, "threads");
318        matches!(opt.arg_type, ArgType::Option);
319    }
320}