Skip to main content

lex_runtime/
cli.rs

1//! `std.cli` — argparse-equivalent for end-user Lex programs
2//! (Rubric port follow-up).
3//!
4//! The Rubric CLI has six subcommands with a mixed bag of positional
5//! / option / flag arguments, JSON-envelope output, and ACLI
6//! introspection. Hand-rolling each subcommand's parser is days of
7//! clipped-wing work; this module provides the equivalent Lex builder
8//! surface.
9//!
10//! The wire format for spec values is JSON (opaque to user code,
11//! constructed via `cli.flag` / `cli.option` / `cli.positional` /
12//! `cli.spec`). Internal records carry a `kind` discriminator so
13//! `parse` can identify what each entry is without relying on field
14//! shape.
15
16use serde_json::{json, Value};
17
18// ---- Spec construction --------------------------------------------
19
20pub fn flag_spec(name: &str, short: Option<&str>, help: &str) -> Value {
21    json!({
22        "kind": "flag",
23        "name": name,
24        "short": short,
25        "help": help,
26    })
27}
28
29pub fn option_spec(name: &str, short: Option<&str>, help: &str, default: Option<&str>) -> Value {
30    json!({
31        "kind": "option",
32        "name": name,
33        "short": short,
34        "help": help,
35        "default": default,
36    })
37}
38
39pub fn positional_spec(name: &str, help: &str, required: bool) -> Value {
40    json!({
41        "kind": "positional",
42        "name": name,
43        "help": help,
44        "required": required,
45    })
46}
47
48pub fn build_spec(name: &str, help: &str, args: Vec<Value>, subcommands: Vec<Value>) -> Value {
49    json!({
50        "kind": "spec",
51        "name": name,
52        "help": help,
53        "args": args,
54        "subcommands": subcommands,
55    })
56}
57
58// ---- Parsing ------------------------------------------------------
59
60/// Parse `argv` against `spec`. Returns a `CliParsed`-shaped JSON
61/// value on success, an error message on failure.
62///
63/// `CliParsed` shape:
64/// ```json
65/// {
66///   "command":     ["rubric", "scan"],   // path of subcommand names
67///   "flags":       { "verbose": true },
68///   "options":     { "output": "report.json" },
69///   "positionals": { "path": "./src" },
70///   "remaining":   []                    // args after `--` separator
71/// }
72/// ```
73pub fn parse(spec: &Value, argv: &[String]) -> Result<Value, String> {
74    let mut state = ParseState::default();
75    parse_into(spec, argv, 0, &mut state)?;
76    Ok(json!({
77        "command": state.command,
78        "flags": state.flags,
79        "options": state.options,
80        "positionals": state.positionals,
81        "remaining": state.remaining,
82    }))
83}
84
85#[derive(Default)]
86struct ParseState {
87    command: Vec<String>,
88    flags: serde_json::Map<String, Value>,
89    options: serde_json::Map<String, Value>,
90    positionals: serde_json::Map<String, Value>,
91    remaining: Vec<String>,
92}
93
94fn parse_into(spec: &Value, argv: &[String], start: usize, state: &mut ParseState) -> Result<(), String> {
95    let name = spec_name(spec);
96    state.command.push(name.to_string());
97
98    // Index this spec's args by long/short for cheap lookup.
99    let args = spec_args(spec);
100    let mut by_long: std::collections::HashMap<&str, &Value> = std::collections::HashMap::new();
101    let mut by_short: std::collections::HashMap<&str, &Value> = std::collections::HashMap::new();
102    let mut positionals: Vec<&Value> = Vec::new();
103    for a in args {
104        let kind = a.get("kind").and_then(|v| v.as_str()).unwrap_or("");
105        match kind {
106            "flag" | "option" => {
107                if let Some(n) = a.get("name").and_then(|v| v.as_str()) {
108                    by_long.insert(n, a);
109                }
110                if let Some(s) = a.get("short").and_then(|v| v.as_str()) {
111                    by_short.insert(s, a);
112                }
113            }
114            "positional" => positionals.push(a),
115            _ => {}
116        }
117    }
118
119    // Apply defaults for any options that have one — overwritten if
120    // the option is later seen on the command line.
121    for a in args {
122        if a.get("kind").and_then(|v| v.as_str()) == Some("option") {
123            if let (Some(n), Some(d)) = (
124                a.get("name").and_then(|v| v.as_str()),
125                a.get("default").and_then(|v| v.as_str()),
126            ) {
127                state.options.insert(n.to_string(), Value::String(d.to_string()));
128            }
129        }
130    }
131    // All flags default to `false` so consumers don't have to handle
132    // the missing-key case.
133    for a in args {
134        if a.get("kind").and_then(|v| v.as_str()) == Some("flag") {
135            if let Some(n) = a.get("name").and_then(|v| v.as_str()) {
136                state.flags.insert(n.to_string(), Value::Bool(false));
137            }
138        }
139    }
140
141    let subcommands = spec_subcommands(spec);
142    let sub_by_name: std::collections::HashMap<&str, &Value> = subcommands.iter()
143        .filter_map(|s| spec_name_opt(s).map(|n| (n, s)))
144        .collect();
145
146    let mut i = start;
147    let mut positional_idx = 0usize;
148    while i < argv.len() {
149        let tok = &argv[i];
150
151        // `--` ends flag/option parsing; everything after is remainder.
152        if tok == "--" {
153            state.remaining.extend(argv[i + 1..].iter().cloned());
154            return Ok(());
155        }
156
157        // Long flag / option: --name or --name=value.
158        if let Some(rest) = tok.strip_prefix("--") {
159            let (lname, inline_val) = match rest.split_once('=') {
160                Some((n, v)) => (n, Some(v.to_string())),
161                None => (rest, None),
162            };
163            let entry = by_long.get(lname).ok_or_else(|| format!(
164                "unknown flag `--{lname}` for `{name}`"))?;
165            apply_flag_or_option(entry, inline_val, &mut i, argv, state)?;
166            i += 1;
167            continue;
168        }
169
170        // Short flag: `-x` (single char) or `-x=value`.
171        if let Some(rest) = tok.strip_prefix('-') {
172            // Reject negative-number-as-positional collision: a token
173            // like "-5" with no matching short flag is treated as a
174            // positional value rather than an unknown flag.
175            let (sname, inline_val) = match rest.split_once('=') {
176                Some((n, v)) => (n, Some(v.to_string())),
177                None => (rest, None),
178            };
179            if let Some(entry) = by_short.get(sname) {
180                apply_flag_or_option(entry, inline_val, &mut i, argv, state)?;
181                i += 1;
182                continue;
183            }
184            // Fall through: treat as positional.
185        }
186
187        // Subcommand match (only at the first non-flag positional
188        // *before* any explicit positional has been consumed).
189        if positional_idx == 0 && !sub_by_name.is_empty() {
190            if let Some(sub) = sub_by_name.get(tok.as_str()) {
191                return parse_into(sub, argv, i + 1, state);
192            }
193        }
194
195        // Positional.
196        if let Some(p) = positionals.get(positional_idx) {
197            let pname = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
198            state.positionals.insert(pname.to_string(), Value::String(tok.clone()));
199            positional_idx += 1;
200        } else {
201            return Err(format!(
202                "unexpected positional argument `{tok}` for `{name}`"));
203        }
204        i += 1;
205    }
206
207    // Validate required positionals.
208    for (idx, p) in positionals.iter().enumerate() {
209        if idx >= positional_idx
210            && p.get("required").and_then(|v| v.as_bool()).unwrap_or(false)
211        {
212            let pname = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
213            return Err(format!(
214                "missing required positional `{pname}` for `{name}`"));
215        }
216    }
217
218    Ok(())
219}
220
221fn apply_flag_or_option(
222    entry: &Value,
223    inline_val: Option<String>,
224    i: &mut usize,
225    argv: &[String],
226    state: &mut ParseState,
227) -> Result<(), String> {
228    let kind = entry.get("kind").and_then(|v| v.as_str()).unwrap_or("");
229    let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("?");
230    match kind {
231        "flag" => {
232            if let Some(v) = inline_val {
233                return Err(format!(
234                    "flag `--{name}` does not take a value (got `={v}`)"));
235            }
236            state.flags.insert(name.to_string(), Value::Bool(true));
237        }
238        "option" => {
239            let val = match inline_val {
240                Some(v) => v,
241                None => {
242                    let next = argv.get(*i + 1).ok_or_else(|| format!(
243                        "option `--{name}` requires a value"))?;
244                    *i += 1;
245                    next.clone()
246                }
247            };
248            state.options.insert(name.to_string(), Value::String(val));
249        }
250        _ => return Err(format!("internal: unexpected entry kind `{kind}`")),
251    }
252    Ok(())
253}
254
255// ---- Spec helpers -------------------------------------------------
256
257fn spec_name(spec: &Value) -> &str {
258    spec.get("name").and_then(|v| v.as_str()).unwrap_or("")
259}
260
261fn spec_name_opt(spec: &Value) -> Option<&str> {
262    spec.get("name").and_then(|v| v.as_str())
263}
264
265fn spec_args(spec: &Value) -> &[Value] {
266    spec.get("args").and_then(|v| v.as_array()).map(|a| a.as_slice()).unwrap_or(&[])
267}
268
269fn spec_subcommands(spec: &Value) -> &[Value] {
270    spec.get("subcommands").and_then(|v| v.as_array()).map(|a| a.as_slice()).unwrap_or(&[])
271}
272
273// ---- ACLI envelope + introspection + help -------------------------
274
275/// `{ "ok": true|false, "command": "<name>", "data": <data> }`. The
276/// shape mirrors `acli`'s output envelope so user programs can match
277/// `lex`'s own `--output json` shape without each command rolling
278/// their own.
279pub fn envelope(ok: bool, command: &str, data: Value) -> Value {
280    json!({
281        "ok": ok,
282        "command": command,
283        "data": data,
284    })
285}
286
287/// Machine-readable description of a spec — recurses through
288/// subcommands. Useful for tools that want to discover a CLI's
289/// surface without invoking `--help`.
290pub fn describe(spec: &Value) -> Value {
291    json!({
292        "name": spec_name(spec),
293        "help": spec.get("help").cloned().unwrap_or(Value::String(String::new())),
294        "args": spec_args(spec).to_vec(),
295        "subcommands": spec_subcommands(spec).iter().map(describe).collect::<Vec<_>>(),
296    })
297}
298
299/// Human-readable help text. Layout matches `argparse`/`clap` for
300/// familiarity. Subcommands are listed below the args.
301pub fn help_text(spec: &Value) -> String {
302    let mut out = String::new();
303    out.push_str(spec_name(spec));
304    if let Some(h) = spec.get("help").and_then(|v| v.as_str()) {
305        if !h.is_empty() {
306            out.push_str(" — ");
307            out.push_str(h);
308        }
309    }
310    out.push('\n');
311
312    let args = spec_args(spec);
313    let positionals: Vec<&Value> = args.iter()
314        .filter(|a| a.get("kind").and_then(|v| v.as_str()) == Some("positional"))
315        .collect();
316    let flags: Vec<&Value> = args.iter()
317        .filter(|a| matches!(a.get("kind").and_then(|v| v.as_str()), Some("flag") | Some("option")))
318        .collect();
319
320    if !positionals.is_empty() {
321        out.push_str("\nUSAGE:\n  ");
322        out.push_str(spec_name(spec));
323        for p in &positionals {
324            let n = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
325            let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
326            if required {
327                out.push_str(&format!(" <{n}>"));
328            } else {
329                out.push_str(&format!(" [{n}]"));
330            }
331        }
332        out.push('\n');
333    }
334
335    if !flags.is_empty() {
336        out.push_str("\nFLAGS:\n");
337        for f in flags {
338            let n = f.get("name").and_then(|v| v.as_str()).unwrap_or("?");
339            let s = f.get("short").and_then(|v| v.as_str()).unwrap_or("");
340            let h = f.get("help").and_then(|v| v.as_str()).unwrap_or("");
341            let prefix = if s.is_empty() {
342                format!("      --{n}")
343            } else {
344                format!("  -{s}, --{n}")
345            };
346            out.push_str(&format!("{prefix:<24}  {h}\n"));
347        }
348    }
349
350    let subs = spec_subcommands(spec);
351    if !subs.is_empty() {
352        out.push_str("\nSUBCOMMANDS:\n");
353        for sub in subs {
354            let n = spec_name(sub);
355            let h = sub.get("help").and_then(|v| v.as_str()).unwrap_or("");
356            out.push_str(&format!("  {n:<16}  {h}\n"));
357        }
358    }
359
360    out
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn spec_simple() -> Value {
368        build_spec(
369            "rubric", "Rubric CLI",
370            vec![
371                flag_spec("verbose", Some("v"), "show debug output"),
372                option_spec("output", Some("o"), "write report", None),
373                positional_spec("path", "directory to scan", true),
374            ],
375            vec![],
376        )
377    }
378
379    #[test]
380    fn parse_simple_positional_and_flag() {
381        let s = spec_simple();
382        let parsed = parse(&s, &["./src".into(), "--verbose".into()]).unwrap();
383        assert_eq!(parsed["positionals"]["path"], "./src");
384        assert_eq!(parsed["flags"]["verbose"], true);
385    }
386
387    #[test]
388    fn parse_short_flag() {
389        let s = spec_simple();
390        let parsed = parse(&s, &["./src".into(), "-v".into()]).unwrap();
391        assert_eq!(parsed["flags"]["verbose"], true);
392    }
393
394    #[test]
395    fn parse_option_with_separate_value() {
396        let s = spec_simple();
397        let parsed = parse(&s, &[
398            "--output".into(), "report.json".into(), "./src".into(),
399        ]).unwrap();
400        assert_eq!(parsed["options"]["output"], "report.json");
401        assert_eq!(parsed["positionals"]["path"], "./src");
402    }
403
404    #[test]
405    fn parse_option_with_inline_equals() {
406        let s = spec_simple();
407        let parsed = parse(&s, &["--output=report.json".into(), "./src".into()]).unwrap();
408        assert_eq!(parsed["options"]["output"], "report.json");
409    }
410
411    #[test]
412    fn parse_default_option_value_is_present() {
413        let s = build_spec("x", "", vec![
414            option_spec("level", None, "verbosity", Some("info")),
415        ], vec![]);
416        let parsed = parse(&s, &[]).unwrap();
417        assert_eq!(parsed["options"]["level"], "info");
418    }
419
420    #[test]
421    fn parse_missing_required_positional_errors() {
422        let s = spec_simple();
423        let err = parse(&s, &[]).unwrap_err();
424        assert!(err.contains("missing required") && err.contains("path"),
425            "expected missing-positional error, got: {err}");
426    }
427
428    #[test]
429    fn parse_unknown_flag_errors() {
430        let s = spec_simple();
431        let err = parse(&s, &["./src".into(), "--bogus".into()]).unwrap_err();
432        assert!(err.contains("unknown") && err.contains("--bogus"),
433            "expected unknown-flag error, got: {err}");
434    }
435
436    #[test]
437    fn parse_flag_with_inline_value_errors() {
438        let s = spec_simple();
439        let err = parse(&s, &["--verbose=yes".into(), "./src".into()]).unwrap_err();
440        assert!(err.contains("does not take a value"),
441            "expected flag-no-value error, got: {err}");
442    }
443
444    #[test]
445    fn parse_double_dash_collects_remaining() {
446        let s = spec_simple();
447        let parsed = parse(&s, &[
448            "./src".into(), "--".into(),
449            "--would-be-flag".into(), "extra".into(),
450        ]).unwrap();
451        assert_eq!(
452            parsed["remaining"].as_array().unwrap(),
453            &[Value::String("--would-be-flag".into()), Value::String("extra".into())],
454        );
455    }
456
457    #[test]
458    fn parse_subcommand_descends() {
459        let s = build_spec(
460            "rubric", "",
461            vec![flag_spec("verbose", Some("v"), "")],
462            vec![
463                build_spec("scan", "scan a directory",
464                    vec![positional_spec("path", "", true)],
465                    vec![]),
466                build_spec("init", "initialise", vec![], vec![]),
467            ],
468        );
469        let parsed = parse(&s, &["scan".into(), "./src".into()]).unwrap();
470        assert_eq!(parsed["command"], json!(["rubric", "scan"]));
471        assert_eq!(parsed["positionals"]["path"], "./src");
472    }
473
474    #[test]
475    fn unknown_flag_in_subcommand_errors() {
476        // Subcommands have their own flag namespace. A flag declared
477        // on the parent doesn't propagate to children — they must be
478        // re-declared on the subcommand if needed.
479        let s = build_spec(
480            "rubric", "",
481            vec![flag_spec("verbose", Some("v"), "")],
482            vec![build_spec("scan", "", vec![], vec![])],
483        );
484        let err = parse(&s, &["scan".into(), "-v".into()]).unwrap_err();
485        assert!(err.contains("unknown") || err.contains("unexpected"),
486            "subcommand should reject parent's flag; got: {err}");
487    }
488
489    #[test]
490    fn envelope_shape_is_acli_compatible() {
491        let env = envelope(true, "rubric", json!({"hits": 3}));
492        assert_eq!(env["ok"], true);
493        assert_eq!(env["command"], "rubric");
494        assert_eq!(env["data"]["hits"], 3);
495    }
496
497    #[test]
498    fn describe_recurses_into_subcommands() {
499        let s = build_spec(
500            "rubric", "outer",
501            vec![flag_spec("verbose", Some("v"), "")],
502            vec![build_spec("scan", "scan dir", vec![], vec![])],
503        );
504        let d = describe(&s);
505        assert_eq!(d["name"], "rubric");
506        assert_eq!(d["help"], "outer");
507        let subs = d["subcommands"].as_array().unwrap();
508        assert_eq!(subs.len(), 1);
509        assert_eq!(subs[0]["name"], "scan");
510        assert_eq!(subs[0]["help"], "scan dir");
511    }
512
513    #[test]
514    fn help_text_lists_args_and_subs() {
515        let s = build_spec(
516            "rubric", "Rubric CLI",
517            vec![
518                flag_spec("verbose", Some("v"), "noisy"),
519                option_spec("output", Some("o"), "write to FILE", None),
520                positional_spec("path", "directory", true),
521            ],
522            vec![build_spec("scan", "scan a directory", vec![], vec![])],
523        );
524        let h = help_text(&s);
525        assert!(h.contains("rubric"));
526        assert!(h.contains("Rubric CLI"));
527        assert!(h.contains("--verbose"));
528        assert!(h.contains("-v"));
529        assert!(h.contains("--output"));
530        assert!(h.contains("<path>"));
531        assert!(h.contains("scan"));
532        assert!(h.contains("scan a directory"));
533    }
534}