1use serde_json::{json, Value};
17
18pub 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
58pub 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 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 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 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 if tok == "--" {
153 state.remaining.extend(argv[i + 1..].iter().cloned());
154 return Ok(());
155 }
156
157 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 if let Some(rest) = tok.strip_prefix('-') {
172 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 }
186
187 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 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 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
255fn 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
273pub fn envelope(ok: bool, command: &str, data: Value) -> Value {
280 json!({
281 "ok": ok,
282 "command": command,
283 "data": data,
284 })
285}
286
287pub 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
299pub 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 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}