Skip to main content

clyops/
lib.rs

1//! clyops — declarative CLI parsing. Behavior follows `spec/SPEC.md`.
2//!
3//! ```no_run
4//! use clyops::Cli;
5//!
6//! let mut cli = Cli::new();
7//! cli.arg("input", "Input file", "", "path");
8//! cli.opt("PORT", "port", "p", "8080", "Server port").group("Network").rule("port");
9//! cli.opt("VERBOSE", "verbose", "v", "flag", "Verbose output");
10//! let args = cli.run();
11//! println!("{} {} {}", args.str("input"), args.int("PORT"), args.bool("VERBOSE"));
12//! ```
13
14mod completions;
15
16use regex::Regex;
17use std::collections::{HashMap, HashSet};
18use std::fmt::Write as _;
19use std::io::{IsTerminal, Write as _};
20use std::path::Path;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23// ---------------------------------------------------------------------------
24// Values
25// ---------------------------------------------------------------------------
26
27/// A resolved, typed value.
28#[derive(Debug, Clone, PartialEq)]
29pub enum Value {
30    /// No value was supplied.
31    Null,
32    /// Boolean flag or bool-rule value.
33    Bool(bool),
34    /// Signed integer produced by int or port validation.
35    Int(i64),
36    /// Floating-point value.
37    Float(f64),
38    /// String value.
39    Str(String),
40    /// Values of an array option or variadic argument.
41    List(Vec<Value>),
42}
43
44impl Value {
45    /// Return the string, or None for another variant.
46    pub fn as_str(&self) -> Option<&str> {
47        if let Value::Str(s) = self {
48            Some(s)
49        } else {
50            None
51        }
52    }
53    /// Return the integer, or None for another variant.
54    pub fn as_int(&self) -> Option<i64> {
55        if let Value::Int(n) = self {
56            Some(*n)
57        } else {
58            None
59        }
60    }
61    /// Return a float, converting Int values; None for nonnumeric variants.
62    pub fn as_float(&self) -> Option<f64> {
63        match self {
64            Value::Float(f) => Some(*f),
65            Value::Int(n) => Some(*n as f64),
66            _ => None,
67        }
68    }
69    /// Return the boolean, or None for another variant.
70    pub fn as_bool(&self) -> Option<bool> {
71        if let Value::Bool(b) = self {
72            Some(*b)
73        } else {
74            None
75        }
76    }
77    /// Return list elements, or an empty slice for another variant.
78    pub fn as_list(&self) -> &[Value] {
79        if let Value::List(l) = self {
80            l
81        } else {
82            &[]
83        }
84    }
85    /// Whether this is an unset value.
86    pub fn is_null(&self) -> bool {
87        matches!(self, Value::Null)
88    }
89}
90
91/// Resolved values keyed by option var and argument name, in registration order.
92#[derive(Debug, Clone, Default)]
93pub struct Values(Vec<(String, Value)>);
94
95static NULL: Value = Value::Null;
96
97impl Values {
98    /// The value for `name`, or `Value::Null` when unknown or unset.
99    pub fn get(&self, name: &str) -> &Value {
100        self.0.iter().find(|(k, _)| k == name).map(|(_, v)| v).unwrap_or(&NULL)
101    }
102    /// String value, or "" when unset.
103    pub fn str(&self, name: &str) -> &str {
104        self.get(name).as_str().unwrap_or("")
105    }
106    /// Integer value (int*/port rules), or 0 when unset.
107    pub fn int(&self, name: &str) -> i64 {
108        self.get(name).as_int().unwrap_or(0)
109    }
110    /// Number value (float*/int* rules), or 0.0 when unset.
111    pub fn float(&self, name: &str) -> f64 {
112        self.get(name).as_float().unwrap_or(0.0)
113    }
114    /// Boolean value (flags, bool rule), or false when unset.
115    pub fn bool(&self, name: &str) -> bool {
116        self.get(name).as_bool().unwrap_or(false)
117    }
118    /// List of strings (array options, variadics).
119    pub fn strs(&self, name: &str) -> Vec<&str> {
120        self.get(name).as_list().iter().filter_map(Value::as_str).collect()
121    }
122    /// Iterate over resolved name/value pairs in registration order.
123    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
124        self.0.iter().map(|(k, v)| (k.as_str(), v))
125    }
126    fn set(&mut self, name: &str, value: Value) {
127        self.0.push((name.to_string(), value));
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Minimal JSON writer (pretty, two-space indent)
133// ---------------------------------------------------------------------------
134
135enum Json {
136    Null,
137    Bool(bool),
138    Num(String),
139    Str(String),
140    Arr(Vec<Json>),
141    Obj(Vec<(&'static str, Json)>),
142    Map(Vec<(String, Json)>),
143}
144
145fn json_str(s: &str, out: &mut String) {
146    out.push('"');
147    for c in s.chars() {
148        match c {
149            '"' => out.push_str("\\\""),
150            '\\' => out.push_str("\\\\"),
151            '\n' => out.push_str("\\n"),
152            '\r' => out.push_str("\\r"),
153            '\t' => out.push_str("\\t"),
154            c if (c as u32) < 0x20 => {
155                let _ = write!(out, "\\u{:04x}", c as u32);
156            }
157            c => out.push(c),
158        }
159    }
160    out.push('"');
161}
162
163impl Json {
164    fn render(&self, depth: usize, out: &mut String) {
165        let pad = |d: usize, out: &mut String| out.push_str(&"  ".repeat(d));
166        match self {
167            Json::Null => out.push_str("null"),
168            Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
169            Json::Num(n) => out.push_str(n),
170            Json::Str(s) => json_str(s, out),
171            Json::Arr(items) if items.is_empty() => out.push_str("[]"),
172            Json::Arr(items) => {
173                out.push_str("[\n");
174                for (i, item) in items.iter().enumerate() {
175                    pad(depth + 1, out);
176                    item.render(depth + 1, out);
177                    out.push_str(if i + 1 < items.len() { ",\n" } else { "\n" });
178                }
179                pad(depth, out);
180                out.push(']');
181            }
182            Json::Obj(_) | Json::Map(_) => {
183                let entries: Vec<(&str, &Json)> = match self {
184                    Json::Obj(e) => e.iter().map(|(k, v)| (*k, v)).collect(),
185                    Json::Map(e) => e.iter().map(|(k, v)| (k.as_str(), v)).collect(),
186                    _ => unreachable!(),
187                };
188                if entries.is_empty() {
189                    out.push_str("{}");
190                    return;
191                }
192                out.push_str("{\n");
193                for (i, (k, v)) in entries.iter().enumerate() {
194                    pad(depth + 1, out);
195                    json_str(k, out);
196                    out.push_str(": ");
197                    v.render(depth + 1, out);
198                    out.push_str(if i + 1 < entries.len() { ",\n" } else { "\n" });
199                }
200                pad(depth, out);
201                out.push('}');
202            }
203        }
204    }
205
206    fn pretty(&self) -> String {
207        let mut out = String::new();
208        self.render(0, &mut out);
209        out
210    }
211
212    fn from_value(v: &Value) -> Json {
213        match v {
214            Value::Null => Json::Null,
215            Value::Bool(b) => Json::Bool(*b),
216            Value::Int(n) => Json::Num(n.to_string()),
217            Value::Float(f) => Json::Num(if f.is_finite() { format!("{f}") } else { "null".into() }),
218            Value::Str(s) => Json::Str(s.clone()),
219            Value::List(l) => Json::Arr(l.iter().map(Json::from_value).collect()),
220        }
221    }
222}
223
224// ---------------------------------------------------------------------------
225// Logging
226// ---------------------------------------------------------------------------
227
228static SILENT: AtomicBool = AtomicBool::new(false);
229
230/// Suppress info/warn/error/success output (die and parse errors still print).
231pub fn set_silent(value: bool) {
232    SILENT.store(value, Ordering::Relaxed);
233}
234
235#[cfg(unix)]
236fn timestamp() -> String {
237    // Local time via the C library; the leading fields of `struct tm` are the
238    // same on every Unix we target.
239    #[repr(C)]
240    struct Tm {
241        sec: i32,
242        min: i32,
243        hour: i32,
244        mday: i32,
245        mon: i32,
246        year: i32,
247        wday: i32,
248        yday: i32,
249        isdst: i32,
250        gmtoff: i64,
251        zone: *const i8,
252    }
253    extern "C" {
254        fn time(t: *mut i64) -> i64;
255        fn localtime_r(t: *const i64, tm: *mut Tm) -> *mut Tm;
256    }
257    let mut tm =
258        Tm { sec: 0, min: 0, hour: 0, mday: 0, mon: 0, year: 0, wday: 0, yday: 0, isdst: 0, gmtoff: 0, zone: std::ptr::null() };
259    unsafe {
260        let now = time(std::ptr::null_mut());
261        localtime_r(&now, &mut tm);
262    }
263    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", tm.year + 1900, tm.mon + 1, tm.mday, tm.hour, tm.min, tm.sec)
264}
265
266#[cfg(not(unix))]
267fn timestamp() -> String {
268    // UTC without a date library: civil-from-days (Howard Hinnant).
269    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
270    let (days, rem) = (secs.div_euclid(86400), secs.rem_euclid(86400));
271    let z = days + 719468;
272    let era = z.div_euclid(146097);
273    let doe = z - era * 146097;
274    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
275    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
276    let mp = (5 * doy + 2) / 153;
277    let d = doy - (153 * mp + 2) / 5 + 1;
278    let m = if mp < 10 { mp + 3 } else { mp - 9 };
279    let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
280    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60)
281}
282
283fn emit(level: &str, msg: &str, force: bool) {
284    if !force && (SILENT.load(Ordering::Relaxed) || std::env::var("CLYOPS_SILENT").as_deref() == Ok("true")) {
285        return;
286    }
287    let mut tag = level.to_string();
288    if std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() {
289        let color = match level {
290            "info" => "1;37",
291            "warning" => "0;33",
292            "success" => "0;32",
293            _ => "0;31",
294        };
295        tag = format!("\x1b[{color}m{level}\x1b[0m");
296    }
297    let _ = writeln!(std::io::stderr(), "{} [{}] {}", timestamp(), tag, msg);
298}
299
300/// Write an informational message to stderr unless silent.
301pub fn info(msg: &str) {
302    emit("info", msg, false)
303}
304/// Write a warning to stderr unless silent.
305pub fn warn(msg: &str) {
306    emit("warning", msg, false)
307}
308/// Write an error message to stderr unless silent.
309pub fn error(msg: &str) {
310    emit("error", msg, false)
311}
312/// Write a success message to stderr unless silent.
313pub fn success(msg: &str) {
314    emit("success", msg, false)
315}
316
317/// Print an error and exit with `code`. Never suppressed.
318pub fn die(code: i32, msg: &str) -> ! {
319    emit("error", msg, true);
320    std::process::exit(code)
321}
322
323// ---------------------------------------------------------------------------
324// Rules
325// ---------------------------------------------------------------------------
326
327fn re(pattern: &str) -> Regex {
328    Regex::new(pattern).expect("built-in pattern")
329}
330
331fn full(pattern: &str, value: &str) -> bool {
332    re(&format!("^(?:{pattern})$")).is_match(value)
333}
334
335fn bool_word(value: &str) -> Option<bool> {
336    match value.to_ascii_lowercase().as_str() {
337        "true" | "yes" | "1" | "on" => Some(true),
338        "false" | "no" | "0" | "off" => Some(false),
339        _ => None,
340    }
341}
342
343const FIXED_RULES: &[&str] = &[
344    "int",
345    "float",
346    "string",
347    "path",
348    "ip",
349    "hostname",
350    "url",
351    "port",
352    "email",
353    "uuid",
354    "bool",
355    "date:YYYY-MM-DD",
356    "file:exists",
357    "file:readable",
358    "file:writable",
359    "dir:exists",
360    "dir:writable",
361];
362
363fn known_rule(rule: &str) -> bool {
364    rule.is_empty()
365        || FIXED_RULES.contains(&rule)
366        || full(r"int:(\d+-\d*|-\d+)", rule)
367        || full(r"float:(\d*\.?\d+-(\d*\.?\d+)?|-\d*\.?\d+)", rule)
368        || full(r"string:(\d+|\d+-\d*|-\d+)", rule)
369        || (rule.starts_with("choice:") && rule.len() > 7)
370        || (rule.starts_with("regex:") && rule.len() > 6 && Regex::new(&rule[6..]).is_ok())
371}
372
373fn bounds(rule: &str) -> (&str, &str) {
374    let range = &rule[rule.find(':').map_or(0, |i| i + 1)..];
375    range.split_once('-').unwrap_or((range, ""))
376}
377
378fn is_path_rule(rule: &str) -> bool {
379    rule == "path" || rule.starts_with("file:") || rule.starts_with("dir:")
380}
381
382/// Help text for a validation rule (spec section 5).
383pub fn describe_rule(rule: &str) -> String {
384    let fixed = match rule {
385        "int" => "integer",
386        "float" => "number",
387        "string" => "text",
388        "path" => "path",
389        "ip" => "IP address",
390        "hostname" => "hostname",
391        "url" => "URL",
392        "port" => "port: 1-65535",
393        "email" => "email address",
394        "uuid" => "UUID",
395        "bool" => "true/false, yes/no, 1/0, on/off",
396        "date:YYYY-MM-DD" => "date: YYYY-MM-DD",
397        "file:exists" => "existing file",
398        "file:readable" => "readable file",
399        "file:writable" => "writable file",
400        "dir:exists" => "existing directory",
401        "dir:writable" => "writable directory",
402        _ => "",
403    };
404    if !fixed.is_empty() {
405        return fixed.to_string();
406    }
407    for (prefix, noun, suffix) in [("int:", "integer", ""), ("float:", "number", ""), ("string:", "text", " chars")] {
408        if let Some(range) = rule.strip_prefix(prefix) {
409            if !range.contains('-') {
410                return format!("{noun}: {range}{suffix}");
411            }
412            let (lo, hi) = bounds(rule);
413            return match (lo.is_empty(), hi.is_empty()) {
414                (false, false) => format!("{noun}: {lo}-{hi}{suffix}"),
415                (false, true) => format!("{noun}: >={lo}{suffix}"),
416                _ => format!("{noun}: <={hi}{suffix}"),
417            };
418        }
419    }
420    if let Some(c) = rule.strip_prefix("choice:") {
421        return format!("choices: {}", c.split(',').collect::<Vec<_>>().join(", "));
422    }
423    if let Some(p) = rule.strip_prefix("regex:") {
424        return format!("pattern: {p}");
425    }
426    rule.to_string()
427}
428
429#[cfg(unix)]
430fn access(path: &str, mode: i32) -> bool {
431    extern "C" {
432        fn access(path: *const std::os::raw::c_char, mode: std::os::raw::c_int) -> std::os::raw::c_int;
433    }
434    match std::ffi::CString::new(path) {
435        Ok(c) => unsafe { access(c.as_ptr(), mode) == 0 },
436        Err(_) => false,
437    }
438}
439
440#[cfg(not(unix))]
441fn access(path: &str, mode: i32) -> bool {
442    match std::fs::metadata(path) {
443        Ok(m) => mode != W_OK || !m.permissions().readonly(),
444        Err(_) => false,
445    }
446}
447
448const R_OK: i32 = 4;
449const W_OK: i32 = 2;
450
451/// Validate `value` against `rule` and convert it to its typed form.
452/// The error is the spec's error text.
453pub fn validate(value: &str, rule: &str, name: &str) -> Result<Value, String> {
454    let fail = |msg: String| Err(format!("{name} {msg}"));
455    let check_bounds = |num: f64, (lo, hi): (&str, &str)| -> Result<(), String> {
456        if !lo.is_empty() && num < lo.parse::<f64>().unwrap_or(f64::MIN) {
457            return Err(format!("{name} must be >= {lo}, got {value}"));
458        }
459        if !hi.is_empty() && num > hi.parse::<f64>().unwrap_or(f64::MAX) {
460            return Err(format!("{name} must be <= {hi}, got {value}"));
461        }
462        Ok(())
463    };
464
465    if rule == "int" || rule.starts_with("int:") {
466        if !full("-?[0-9]+", value) {
467            return fail(format!("must be an integer, got '{value}'"));
468        }
469        let num: i64 = value.parse().map_err(|_| format!("{name} must be an integer, got '{value}'"))?;
470        if rule != "int" {
471            check_bounds(num as f64, bounds(rule))?;
472        }
473        return Ok(Value::Int(num));
474    }
475    if rule == "float" || rule.starts_with("float:") {
476        if !full(r"-?[0-9]*\.?[0-9]+", value) {
477            return fail(format!("must be a number, got '{value}'"));
478        }
479        let num: f64 = value.parse().unwrap_or(0.0);
480        if rule != "float" {
481            check_bounds(num, bounds(rule))?;
482        }
483        return Ok(Value::Float(num));
484    }
485    if let Some(spec) = rule.strip_prefix("string:") {
486        let len = value.chars().count();
487        if !spec.contains('-') {
488            if len != spec.parse::<usize>().unwrap_or(0) {
489                return fail(format!("must be exactly {spec} characters, got {len}"));
490            }
491        } else {
492            let (lo, hi) = bounds(rule);
493            if !lo.is_empty() && len < lo.parse().unwrap_or(0) {
494                return fail(format!("must be at least {lo} characters, got {len}"));
495            }
496            if !hi.is_empty() && len > hi.parse().unwrap_or(usize::MAX) {
497                return fail(format!("must be at most {hi} characters, got {len}"));
498            }
499        }
500        return Ok(Value::Str(value.into()));
501    }
502    if let Some(choices) = rule.strip_prefix("choice:") {
503        if !choices.split(',').any(|c| c == value) {
504            return fail(format!("must be one of: {}, got '{value}'", choices.split(',').collect::<Vec<_>>().join(", ")));
505        }
506        return Ok(Value::Str(value.into()));
507    }
508    if let Some(pattern) = rule.strip_prefix("regex:") {
509        if !re(pattern).is_match(value) {
510            return fail(format!("does not match required pattern, got '{value}'"));
511        }
512        return Ok(Value::Str(value.into()));
513    }
514
515    let ok = match rule {
516        "bool" => {
517            return match bool_word(value) {
518                Some(b) => Ok(Value::Bool(b)),
519                None => fail(format!("must be a boolean (true/false, yes/no, 1/0, on/off), got '{value}'")),
520            }
521        }
522        "port" => {
523            return match value.parse::<i64>() {
524                Ok(p) if full("[0-9]+", value) && (1..=65535).contains(&p) => Ok(Value::Int(p)),
525                _ => fail(format!("must be a valid port (1-65535), got '{value}'")),
526            }
527        }
528        "ip" => full(r"([0-9]{1,3}\.){3}[0-9]{1,3}", value) || full("([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}", value),
529        "hostname" => full(r"[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*", value),
530        "url" => full(r"https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/(?s:.)*)?", value),
531        "email" => full(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", value),
532        "uuid" => full("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value),
533        "date:YYYY-MM-DD" => full("[0-9]{4}-[0-9]{2}-[0-9]{2}", value),
534        _ => true,
535    };
536    if !ok {
537        let what = match rule {
538            "ip" => "must be a valid IP address".to_string(),
539            "hostname" => "must be a valid hostname".to_string(),
540            "url" => "must be a valid URL".to_string(),
541            "email" => "must be a valid email address".to_string(),
542            "uuid" => "must be a valid UUID".to_string(),
543            _ => "must be in YYYY-MM-DD format".to_string(),
544        };
545        return fail(format!("{what}, got '{value}'"));
546    }
547
548    let p = Path::new(value);
549    match rule {
550        "file:exists" if !p.is_file() => return fail(format!("file does not exist: {value}")),
551        "file:readable" if !access(value, R_OK) => return fail(format!("file is not readable: {value}")),
552        "file:writable" => {
553            if p.symlink_metadata().is_ok() {
554                if !access(value, W_OK) {
555                    return fail(format!("file is not writable: {value}"));
556                }
557            } else {
558                let dir = dirname(value);
559                if !Path::new(&dir).is_dir() || !access(&dir, W_OK) {
560                    return fail(format!("directory is not writable: {dir}"));
561                }
562            }
563        }
564        "dir:exists" if !p.is_dir() => return fail(format!("directory does not exist: {value}")),
565        "dir:writable" if !p.is_dir() || !access(value, W_OK) => {
566            return fail(format!("directory does not exist or is not writable: {value}"))
567        }
568        _ => {}
569    }
570    Ok(Value::Str(value.into()))
571}
572
573fn dirname(path: &str) -> String {
574    match path.rfind('/') {
575        Some(0) => "/".into(),
576        Some(i) => path[..i].into(),
577        None => ".".into(),
578    }
579}
580
581/// Lexically join and normalize (no symlink resolution).
582fn join_norm(base: &str, value: &str) -> String {
583    let joined = if value.starts_with('/') { value.to_string() } else { format!("{base}/{value}") };
584    let mut parts: Vec<&str> = Vec::new();
585    for part in joined.split('/') {
586        match part {
587            "" | "." => {}
588            ".." => {
589                parts.pop();
590            }
591            p => parts.push(p),
592        }
593    }
594    format!("/{}", parts.join("/"))
595}
596
597/// Resolve a path value against `base` (spec section 6).
598pub fn resolve_path(value: &str, base: &str, search_dirs: &[String]) -> String {
599    if value.is_empty() || ["-", "disabled", "optional"].contains(&value) || value.starts_with('/') {
600        return value.to_string();
601    }
602    if re("^[A-Za-z][A-Za-z0-9+.-]+:").is_match(value) {
603        return value.to_string();
604    }
605    let from_base = join_norm(base, value);
606    let bare = !(value == "." || value == ".." || value.starts_with("./") || value.starts_with("../"));
607    if bare && !search_dirs.is_empty() && !Path::new(&from_base).exists() {
608        for dir in search_dirs {
609            let candidate = join_norm(dir, value);
610            if Path::new(&candidate).exists() {
611                return candidate;
612            }
613        }
614    }
615    from_base
616}
617
618/// Greedy word wrap that keeps existing line breaks (spec section 7).
619pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
620    let mut out = Vec::new();
621    for original in text.split('\n') {
622        let original = original.strip_suffix('\r').unwrap_or(original);
623        let mut line = String::new();
624        let mut any = false;
625        for word in original.split_whitespace() {
626            any = true;
627            if line.is_empty() {
628                line = word.to_string();
629            } else if line.chars().count() + 1 + word.chars().count() <= width {
630                line.push(' ');
631                line.push_str(word);
632            } else {
633                out.push(std::mem::replace(&mut line, word.to_string()));
634            }
635        }
636        out.push(if any { line } else { String::new() });
637    }
638    out
639}
640
641fn command_available(cmd: &str, path_var: &str) -> bool {
642    let is_exec = |p: &Path| -> bool {
643        match p.metadata() {
644            #[cfg(unix)]
645            Ok(m) => m.is_file() && std::os::unix::fs::PermissionsExt::mode(&m.permissions()) & 0o111 != 0,
646            #[cfg(not(unix))]
647            Ok(m) => m.is_file(),
648            Err(_) => false,
649        }
650    };
651    if cmd.contains('/') {
652        return is_exec(Path::new(cmd));
653    }
654    std::env::split_paths(path_var).any(|dir| is_exec(&dir.join(cmd)))
655}
656
657fn completion_kind(rule: &str, dirs: &[String]) -> (&'static str, String) {
658    if rule == "path" || rule.starts_with("file:") {
659        ("file", dirs.join(":"))
660    } else if rule.starts_with("dir:") {
661        ("dir", dirs.join(":"))
662    } else if let Some(c) = rule.strip_prefix("choice:") {
663        ("choice", c.to_string())
664    } else if rule == "bool" {
665        ("choice", "true,false".into())
666    } else if rule == "hostname" || rule == "ip" {
667        ("host", String::new())
668    } else if rule.is_empty() {
669        ("default", String::new())
670    } else {
671        ("none", String::new())
672    }
673}
674
675// ---------------------------------------------------------------------------
676// Cli
677// ---------------------------------------------------------------------------
678
679#[derive(Debug, Clone, Copy, PartialEq)]
680enum Kind {
681    Flag,
682    Value,
683    Array,
684}
685
686#[derive(Debug, Clone)]
687struct Opt {
688    var: String,
689    long: String,
690    short: String,
691    kind: Kind,
692    default: String,
693    required: bool,
694    description: String,
695    group: String,
696    rule: String,
697    search_dirs: Vec<String>,
698    secret: bool,
699}
700
701impl Opt {
702    fn label(&self) -> String {
703        let head =
704            if self.short.is_empty() { format!("    --{}", self.long) } else { format!("-{}, --{}", self.short, self.long) };
705        if self.kind == Kind::Flag {
706            head
707        } else {
708            head + "=<value>"
709        }
710    }
711    fn bool_like(&self) -> bool {
712        self.kind == Kind::Flag || ["bool", "choice:true,false", "choice:false,true"].contains(&self.rule.as_str())
713    }
714}
715
716#[derive(Debug, Clone)]
717struct Arg {
718    name: String,
719    description: String,
720    default: String,
721    rule: String,
722    variadic: bool,
723}
724
725#[derive(Debug, Clone)]
726enum Raw {
727    One(String),
728    Many(Vec<String>),
729}
730
731/// Outcome of [`Cli::parse`].
732#[derive(Debug, Clone, PartialEq)]
733pub enum Parsed {
734    /// Parsing succeeded; read resolved values from the CLI.
735    Ok,
736    /// Help was requested; the caller decides how to display it.
737    Help,
738    /// Parsing failed; contains the message and help-display policy.
739    Error {
740        /// Human-readable failure message.
741        message: String,
742        /// Whether usage should accompany the error.
743        show_usage: bool,
744        /// Extra details, such as installation hints for missing commands.
745        detail: Vec<String>,
746    },
747}
748
749impl Parsed {
750    fn err(message: String) -> Parsed {
751        Parsed::Error { message, show_usage: true, detail: Vec::new() }
752    }
753}
754
755/// Handle returned by [`Cli::opt`] and friends for optional settings on the same line.
756pub struct OptRef<'a> {
757    cli: &'a mut Cli,
758    index: usize,
759}
760
761impl<'a> OptRef<'a> {
762    /// Help section (default "Options").
763    pub fn group(self, group: &str) -> Self {
764        self.cli.options[self.index].group = group.to_string();
765        self
766    }
767    /// Validation rule (spec section 5); `secret` or `secret:RULE` marks a secret
768    /// (spec section 1.5). Panics on an unknown rule.
769    pub fn rule(self, rule: &str) -> Self {
770        let long = self.cli.options[self.index].long.clone();
771        let (secret, rule) = match rule.strip_prefix("secret") {
772            Some("") => (true, ""),
773            Some(r) if r.starts_with(':') => (true, &r[1..]),
774            _ => (false, rule),
775        };
776        if !known_rule(rule) {
777            panic!("Unknown validation rule '{rule}' for --{long}");
778        }
779        self.cli.options[self.index].rule = rule.to_string();
780        self.cli.options[self.index].secret = secret;
781        self
782    }
783}
784
785/// Handle returned by [`Cli::arg`] for an optional rule on the same line.
786pub struct ArgRef<'a> {
787    cli: &'a mut Cli,
788    index: usize,
789}
790
791impl<'a> ArgRef<'a> {
792    /// Set a positional validation rule. Panics if the rule is unknown; returns this argument handle.
793    pub fn rule(self, rule: &str) -> Self {
794        let name = self.cli.args[self.index].name.clone();
795        if !known_rule(rule) {
796            panic!("Unknown validation rule '{rule}' for {name}");
797        }
798        self.cli.args[self.index].rule = rule.to_string();
799        self
800    }
801}
802
803#[derive(Debug, Clone)]
804/// A program CLI. Register options and arguments before parsing; registration errors panic.
805pub struct Cli {
806    name: String,
807    root: String,
808    cwd: String,
809    env: HashMap<String, String>,
810    description: String,
811    epilog: String,
812    options: Vec<Opt>,
813    args: Vec<Arg>,
814    commands: Vec<(String, String, String)>,
815    config_option: String,
816    config_prefixes: Vec<String>,
817    raw: HashMap<String, Raw>,
818    arg_raw: HashMap<String, Raw>,
819    sources: HashMap<String, &'static str>,
820    config: HashMap<String, (String, String)>,
821    values: Values,
822    effects: Vec<String>,
823    stdin: Option<(String, String)>,
824    stdout: Option<(String, String)>,
825    constraints: Vec<(&'static str, Vec<String>)>,
826    children: Vec<Cli>,
827    word: String,
828    // The ancestors' option names, for checking relationships at registration.
829    inherited: Vec<String>,
830    // The command selected by the last parse, as indexes into `children`.
831    selected: Vec<usize>,
832}
833
834impl Default for Cli {
835    fn default() -> Self {
836        Self::new()
837    }
838}
839
840impl Cli {
841    /// A CLI named after `argv[0]`, rooted at the current directory, reading the process environment.
842    pub fn new() -> Cli {
843        let name = std::env::args().next().map(|a| a.rsplit('/').next().unwrap_or("").to_string()).unwrap_or_default();
844        let cwd = std::env::current_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| "/".into());
845        Cli {
846            name,
847            root: cwd.clone(),
848            cwd,
849            env: std::env::vars().collect(),
850            description: String::new(),
851            epilog: String::new(),
852            options: Vec::new(),
853            args: Vec::new(),
854            commands: Vec::new(),
855            config_option: String::new(),
856            config_prefixes: Vec::new(),
857            raw: HashMap::new(),
858            arg_raw: HashMap::new(),
859            sources: HashMap::new(),
860            config: HashMap::new(),
861            values: Values::default(),
862            effects: Vec::new(),
863            stdin: None,
864            stdout: None,
865            constraints: Vec::new(),
866            children: Vec::new(),
867            word: String::new(),
868            inherited: Vec::new(),
869            selected: Vec::new(),
870        }
871    }
872
873    /// Program name shown in usage.
874    pub fn name(&mut self, name: &str) -> &mut Self {
875        self.name = name.into();
876        self
877    }
878    /// Base directory for default/env path values and search dirs (relative to cwd).
879    pub fn root(&mut self, root: &str) -> &mut Self {
880        self.root = join_norm(&self.cwd, root);
881        self
882    }
883    /// Directory command-line paths are relative to.
884    pub fn cwd(&mut self, cwd: &str) -> &mut Self {
885        self.cwd = cwd.into();
886        self
887    }
888    /// Replace the environment options are read from.
889    pub fn env<I: IntoIterator<Item = (String, String)>>(&mut self, env: I) -> &mut Self {
890        self.env = env.into_iter().collect();
891        self
892    }
893    /// Set the description below the usage line and return this CLI.
894    pub fn description(&mut self, text: &str) -> &mut Self {
895        self.description = text.into();
896        self
897    }
898    /// Set the final help text and return this CLI.
899    pub fn epilog(&mut self, text: &str) -> &mut Self {
900        self.epilog = text.into();
901        self
902    }
903
904    /// What running the program does: read-only, idempotent, destructive, network.
905    pub fn effects(&mut self, effects: &[&str]) -> &mut Self {
906        for e in effects {
907            if !["read-only", "idempotent", "destructive", "network"].contains(e) {
908                panic!("Unknown effect '{e}'");
909            }
910        }
911        self.effects = effects.iter().map(|e| e.to_string()).collect();
912        self
913    }
914    /// What the program reads on stdin; `content_type` is a MIME type or a comma-separated list.
915    pub fn stdin(&mut self, description: &str, content_type: &str) -> &mut Self {
916        self.stdin = Some((description.into(), content_type.into()));
917        self
918    }
919    /// What the program writes on stdout; undeclared means text.
920    pub fn stdout(&mut self, description: &str, content_type: &str) -> &mut Self {
921        self.stdout = Some((description.into(), content_type.into()));
922        self
923    }
924    /// At most one of these options may be given.
925    pub fn exclusive(&mut self, longs: &[&str]) -> &mut Self {
926        self.add_constraint("exclusive", longs.iter().copied())
927    }
928    /// When `long` is given, the others must be too.
929    pub fn requires(&mut self, long: &str, longs: &[&str]) -> &mut Self {
930        self.add_constraint("requires", std::iter::once(long).chain(longs.iter().copied()))
931    }
932    /// At least one of these options must be given.
933    pub fn one_of(&mut self, longs: &[&str]) -> &mut Self {
934        self.add_constraint("oneOf", longs.iter().copied())
935    }
936
937    fn add_constraint<'a>(&mut self, kind: &'static str, longs: impl Iterator<Item = &'a str>) -> &mut Self {
938        let longs: Vec<String> = longs.map(String::from).collect();
939        for long in &longs {
940            if !self.options.iter().any(|o| &o.long == long) && !self.inherited.contains(long) {
941                panic!("Unknown option --{long} in constraint");
942            }
943        }
944        self.constraints.push((kind, longs));
945        self
946    }
947
948    /// Register a command (spec section 1.7) and return it, to register its options and arguments on.
949    pub fn command(&mut self, name: &str, description: &str) -> &mut Cli {
950        if !self.args.is_empty() {
951            panic!("Cannot mix commands and positional arguments");
952        }
953        if self.children.iter().any(|c| c.word == name) {
954            panic!("Duplicate command {name}");
955        }
956        let mut child = Cli::new();
957        child.name = format!("{} {name}", self.name);
958        child.root = self.root.clone();
959        child.cwd = self.cwd.clone();
960        child.description = description.into();
961        child.word = name.into();
962        child.inherited = self.inherited.iter().cloned().chain(self.options.iter().map(|o| o.long.clone())).collect();
963        self.children.push(child);
964        self.children.last_mut().expect("just pushed")
965    }
966
967    /// The command words selected by the last parse, e.g. `["db", "migrate"]`.
968    pub fn command_path(&self) -> Vec<String> {
969        let mut node = self;
970        let mut out = Vec::new();
971        for &i in &self.selected {
972            node = &node.children[i];
973            out.push(node.word.clone());
974        }
975        out
976    }
977
978    /// The selected command, its parent, ... up to this one.
979    fn chain(&self) -> Vec<&Cli> {
980        let mut node = self;
981        let mut out = vec![self];
982        for &i in &self.selected {
983            node = &node.children[i];
984            out.push(node);
985        }
986        out.reverse();
987        out
988    }
989
990    fn node(&self) -> &Cli {
991        self.chain()[0]
992    }
993
994    fn chain_options(&self) -> Vec<Opt> {
995        self.chain().iter().flat_map(|n| n.options.iter().cloned()).collect()
996    }
997
998    fn chain_find(&self, long: &str) -> Option<Opt> {
999        self.chain_options().into_iter().find(|o| o.long == long)
1000    }
1001
1002    /// `option` holds the config file path; `prefixes` is comma-separated.
1003    pub fn config(&mut self, option: &str, prefixes: &str) -> &mut Self {
1004        self.config_option = option.into();
1005        self.config_prefixes = prefixes.split(',').map(str::trim).filter(|p| !p.is_empty()).map(String::from).collect();
1006        self
1007    }
1008
1009    /// Require an executable on PATH when parsing, with a description and optional installation hint.
1010    pub fn require_command(&mut self, command: &str, description: &str, install_hint: &str) -> &mut Self {
1011        self.commands.push((command.into(), description.into(), install_hint.into()));
1012        self
1013    }
1014
1015    /// Fallback dirs (colon-separated, relative to root) for bare relative values of a path option.
1016    pub fn path_search(&mut self, long: &str, dirs: &str) -> &mut Self {
1017        let root = self.root.clone();
1018        let opt =
1019            self.options.iter_mut().find(|o| o.long == long).unwrap_or_else(|| panic!("path_search: unknown option --{long}"));
1020        opt.search_dirs = dirs.split(':').filter(|d| !d.is_empty()).map(|d| join_norm(&root, d)).collect();
1021        if opt.rule.is_empty() {
1022            opt.rule = "path".into();
1023        }
1024        self
1025    }
1026
1027    /// Register an option. `default` is a value, "flag", "optional", or "" (required).
1028    pub fn opt(&mut self, var: &str, long: &str, short: &str, default: &str, description: &str) -> OptRef<'_> {
1029        let kind = if default == "flag" { Kind::Flag } else { Kind::Value };
1030        let dflt = if default == "flag" || default == "optional" { "" } else { default };
1031        self.add_opt(var, long, short, kind, dflt, default.is_empty(), description)
1032    }
1033
1034    /// Register a repeatable option whose values accumulate into a list.
1035    pub fn opt_array(&mut self, var: &str, long: &str, short: &str, description: &str) -> OptRef<'_> {
1036        self.add_opt(var, long, short, Kind::Array, "", false, description)
1037    }
1038
1039    /// Register a positional argument. An empty default makes it required.
1040    pub fn arg(&mut self, name: &str, description: &str, default: &str, rule: &str) -> ArgRef<'_> {
1041        self.add_arg(name, description, default, false).rule(rule)
1042    }
1043
1044    /// Register a final positional argument that collects all remaining tokens.
1045    pub fn arg_variadic(&mut self, name: &str, description: &str, rule: &str) -> ArgRef<'_> {
1046        self.add_arg(name, description, "", true).rule(rule)
1047    }
1048
1049    #[allow(clippy::too_many_arguments)]
1050    fn add_opt(
1051        &mut self,
1052        var: &str,
1053        long: &str,
1054        short: &str,
1055        kind: Kind,
1056        default: &str,
1057        required: bool,
1058        description: &str,
1059    ) -> OptRef<'_> {
1060        if self.options.iter().any(|o| o.long == long) {
1061            panic!("Duplicate option --{long}");
1062        }
1063        if !short.is_empty() && (short.chars().count() != 1 || self.options.iter().any(|o| o.short == short)) {
1064            panic!("Invalid or duplicate short option -{short}");
1065        }
1066        self.options.push(Opt {
1067            var: var.into(),
1068            long: long.into(),
1069            short: short.into(),
1070            kind,
1071            default: default.into(),
1072            required,
1073            description: description.into(),
1074            group: "Options".into(),
1075            rule: String::new(),
1076            search_dirs: Vec::new(),
1077            secret: false,
1078        });
1079        let index = self.options.len() - 1;
1080        OptRef { cli: self, index }
1081    }
1082
1083    fn add_arg(&mut self, name: &str, description: &str, default: &str, variadic: bool) -> ArgRef<'_> {
1084        if !self.children.is_empty() {
1085            panic!("Cannot mix commands and positional arguments");
1086        }
1087        if self.args.iter().any(|a| a.variadic) {
1088            panic!("Argument {name} registered after a variadic argument");
1089        }
1090        self.args.push(Arg {
1091            name: name.into(),
1092            description: description.into(),
1093            default: default.into(),
1094            rule: String::new(),
1095            variadic,
1096        });
1097        let index = self.args.len() - 1;
1098        ArgRef { cli: self, index }
1099    }
1100
1101    fn ensure_help(&mut self) {
1102        if !self.options.iter().any(|o| o.long == "help") {
1103            let short = if self.options.iter().any(|o| o.short == "h") { "" } else { "h" };
1104            self.add_opt("HELP", "help", short, Kind::Flag, "", false, "Show this help message and exit").group("Global");
1105        }
1106    }
1107
1108    // -- parsing --------------------------------------------------------------
1109
1110    /// Parse without exiting. Values are available via [`Cli::values`] when the result is `Parsed::Ok`.
1111    pub fn parse<S: AsRef<str>>(&mut self, argv: &[S]) -> Parsed {
1112        let argv: Vec<String> = argv.iter().map(|s| s.as_ref().to_string()).collect();
1113        self.raw.clear();
1114        self.arg_raw.clear();
1115        self.sources.clear();
1116        self.config.clear();
1117        self.values = Values::default();
1118        self.selected.clear();
1119        self.ensure_help();
1120
1121        let mut result = self.scan(&argv);
1122        if result.is_ok() && !self.config_option.is_empty() {
1123            result = self.load_config();
1124        }
1125        if matches!(self.raw.get("help"), Some(Raw::One(v)) if v == "true") {
1126            return Parsed::Help;
1127        }
1128        if let Err(e) = result.and_then(|_| self.resolve()) {
1129            return Parsed::err(e);
1130        }
1131
1132        let path_var = self.env.get("PATH").cloned().unwrap_or_default();
1133        let required: Vec<(String, String, String)> =
1134            self.chain().iter().rev().flat_map(|n| n.commands.iter().cloned()).collect();
1135        let missing: Vec<&(String, String, String)> = required.iter().filter(|c| !command_available(&c.0, &path_var)).collect();
1136        if !missing.is_empty() {
1137            let mut detail = Vec::new();
1138            for (cmd, desc, hint) in &missing {
1139                detail.push(format!("  {cmd} - {desc}"));
1140                if !hint.is_empty() {
1141                    detail.push(format!("    Install: {hint}"));
1142                }
1143            }
1144            let names: Vec<&str> = missing.iter().map(|c| c.0.as_str()).collect();
1145            return Parsed::Error {
1146                message: format!("Missing required command(s): {}", names.join(", ")),
1147                show_usage: false,
1148                detail,
1149            };
1150        }
1151
1152        let missing: Vec<String> = self
1153            .chain_options()
1154            .iter()
1155            .filter(|o| o.required && !matches!(self.raw.get(&o.long), Some(Raw::One(v)) if !v.is_empty()))
1156            .map(|o| format!("--{}", o.long))
1157            .collect();
1158        if !missing.is_empty() {
1159            return Parsed::err(format!("Missing required argument(s): {}", missing.join(" ")));
1160        }
1161        match self.check_constraints() {
1162            Some(e) => Parsed::err(e),
1163            None => Parsed::Ok,
1164        }
1165    }
1166
1167    /// Spec section 1.6: the first relationship that fails, from the program down.
1168    fn check_constraints(&self) -> Option<String> {
1169        let given = |long: &str| {
1170            let var = self.chain_find(long).map(|o| o.var).unwrap_or_default();
1171            matches!(self.source(long), "cli" | "config" | "env")
1172                && !matches!(self.values.get(&var), Value::Bool(false))
1173                && !matches!(self.values.get(&var), Value::List(l) if l.is_empty())
1174        };
1175        for node in self.chain().iter().rev() {
1176            for (kind, longs) in &node.constraints {
1177                let on: Vec<&String> = longs.iter().filter(|l| given(l)).collect();
1178                match *kind {
1179                    "exclusive" if on.len() > 1 => {
1180                        return Some(format!("Options --{} and --{} cannot be used together", on[0], on[1]))
1181                    }
1182                    "requires" if given(&longs[0]) => {
1183                        if let Some(absent) = longs[1..].iter().find(|l| !given(l)) {
1184                            return Some(format!("Option --{} requires --{absent}", longs[0]));
1185                        }
1186                    }
1187                    "oneOf" if on.is_empty() => return Some(format!("One of --{} is required", longs.join(", --"))),
1188                    _ => {}
1189                }
1190            }
1191        }
1192        None
1193    }
1194
1195    fn set_cli(&mut self, opt: &Opt, value: String) {
1196        let long = opt.long.clone();
1197        if opt.kind == Kind::Array {
1198            let mut list = match (self.sources.get(&long), self.raw.remove(&long)) {
1199                (Some(&"cli"), Some(Raw::Many(l))) => l,
1200                _ => Vec::new(),
1201            };
1202            list.push(value);
1203            self.raw.insert(long.clone(), Raw::Many(list));
1204        } else {
1205            self.raw.insert(long.clone(), Raw::One(value));
1206        }
1207        self.sources.insert(long, "cli");
1208    }
1209
1210    fn scan(&mut self, argv: &[String]) -> Result<(), String> {
1211        let mut pos = 0;
1212        let mut rest: Option<Vec<String>> = None;
1213        let mut variadic_name = String::new();
1214        let mut end_of_options = false;
1215        let mut i = 0;
1216        let result = (|| {
1217            while i < argv.len() {
1218                let token = argv[i].clone();
1219                i += 1;
1220                if end_of_options || token == "-" || !token.starts_with('-') {
1221                    let node = self.node();
1222                    if let Some(r) = rest.as_mut() {
1223                        r.push(token);
1224                    } else if !node.children.is_empty() {
1225                        match node.children.iter().position(|c| c.word == token) {
1226                            Some(i) => self.selected.push(i),
1227                            None => return Err(format!("Unknown command: {token}")),
1228                        }
1229                    } else if pos >= node.args.len() {
1230                        return Err(format!("Unexpected argument: {token}"));
1231                    } else {
1232                        let arg = node.args[pos].clone();
1233                        pos += 1;
1234                        if arg.variadic {
1235                            variadic_name = arg.name.clone();
1236                            rest = Some(vec![token]);
1237                        } else {
1238                            self.arg_raw.insert(arg.name.clone(), Raw::One(token));
1239                        }
1240                    }
1241                } else if token == "--" {
1242                    end_of_options = true;
1243                } else if let Some(body) = token.strip_prefix("--") {
1244                    let (name, value) = match body.split_once('=') {
1245                        Some((n, v)) => (n.to_string(), Some(v.to_string())),
1246                        None => (body.to_string(), None),
1247                    };
1248                    if let Some(opt) = self.chain_find(&name) {
1249                        let kind = opt.kind;
1250                        match value {
1251                            Some(v) if kind == Kind::Flag => match bool_word(&v) {
1252                                Some(b) => self.set_cli(&opt, b.to_string()),
1253                                None => return Err(format!("Option --{name} expects a boolean value, got '{v}'")),
1254                            },
1255                            Some(v) => self.set_cli(&opt, v),
1256                            None if kind == Kind::Flag => self.set_cli(&opt, "true".into()),
1257                            None => match argv.get(i) {
1258                                Some(next) if !next.starts_with("--") => {
1259                                    let next = next.clone();
1260                                    self.set_cli(&opt, next);
1261                                    i += 1;
1262                                }
1263                                _ => return Err(format!("Option --{name} requires an argument")),
1264                            },
1265                        }
1266                    } else if let (Some(target), None) = (name.strip_prefix("no-").and_then(|n| self.chain_find(n)), &value) {
1267                        if !target.bool_like() {
1268                            return Err(format!("Option --{name} can only be used with flag/boolean options"));
1269                        }
1270                        self.set_cli(&target, "false".into());
1271                    } else {
1272                        return Err(format!("Unknown option: --{name}"));
1273                    }
1274                } else {
1275                    let cluster: Vec<char> = token[1..].chars().collect();
1276                    for (j, ch) in cluster.iter().enumerate() {
1277                        let opt = self
1278                            .chain_options()
1279                            .into_iter()
1280                            .find(|o| o.short == ch.to_string())
1281                            .ok_or(format!("Unknown option: -{ch}"))?;
1282                        if opt.kind == Kind::Flag {
1283                            self.set_cli(&opt, "true".into());
1284                            continue;
1285                        }
1286                        if j + 1 < cluster.len() {
1287                            self.set_cli(&opt, cluster[j + 1..].iter().collect());
1288                            break;
1289                        }
1290                        match argv.get(i) {
1291                            Some(next) if !next.starts_with('-') => {
1292                                let next = next.clone();
1293                                self.set_cli(&opt, next);
1294                                i += 1;
1295                            }
1296                            _ => return Err(format!("Option -{ch} requires an argument")),
1297                        }
1298                    }
1299                }
1300            }
1301            Ok(())
1302        })();
1303        if let Some(r) = rest {
1304            self.arg_raw.insert(variadic_name, Raw::Many(r));
1305        }
1306        result
1307    }
1308
1309    fn load_config(&mut self) -> Result<(), String> {
1310        let Some(opt) = self.chain_find(&self.config_option.clone()) else { return Ok(()) };
1311        let (path, source) = match self.raw.get(&opt.long) {
1312            Some(Raw::One(v)) => (v.clone(), "cli"),
1313            _ => match self.env.get(&opt.var).filter(|v| !v.is_empty()) {
1314                Some(v) => (v.clone(), "env"),
1315                None => (opt.default.clone(), "default"),
1316            },
1317        };
1318        if path.is_empty() || path == "disabled" {
1319            return Ok(());
1320        }
1321        let resolved = resolve_path(&path, &self.cwd, &opt.search_dirs);
1322        self.raw.insert(opt.long.clone(), Raw::One(resolved.clone()));
1323        self.sources.insert(opt.long.clone(), source);
1324        self.read_config(&resolved, 0, &mut HashSet::new())?;
1325
1326        let entries: Vec<(String, String)> = self.config.iter().map(|(k, (v, _))| (k.clone(), v.clone())).collect();
1327        for (key, value) in entries {
1328            let Some(t) = self.chain_find(&key) else { continue };
1329            if t.long == opt.long || self.sources.get(&key) == Some(&"cli") {
1330                continue;
1331            }
1332            let raw = match t.kind {
1333                Kind::Flag => match bool_word(&value) {
1334                    Some(b) => Raw::One(b.to_string()),
1335                    None => return Err(format!("Config value for --{key} must be a boolean, got '{value}'")),
1336                },
1337                Kind::Array => Raw::Many(vec![value]),
1338                Kind::Value => Raw::One(value),
1339            };
1340            self.raw.insert(key.clone(), raw);
1341            self.sources.insert(key, "config");
1342        }
1343        Ok(())
1344    }
1345
1346    fn read_config(&mut self, path: &str, depth: usize, stack: &mut HashSet<String>) -> Result<(), String> {
1347        if depth > 10 {
1348            return Err(format!("Config include depth exceeded (10) while processing: {path}"));
1349        }
1350        if !Path::new(path).is_file() {
1351            return Err(format!("Config file not found: {path}"));
1352        }
1353        if !stack.insert(path.to_string()) {
1354            return Err(format!("Circular config include detected: {path}"));
1355        }
1356        let dir = dirname(path);
1357        let text = std::fs::read_to_string(path).map_err(|_| format!("Config file not found: {path}"))?;
1358        let include = re(r"^\s*@include\s+(.+)$");
1359        for line in text.split('\n') {
1360            let line = line.strip_suffix('\r').unwrap_or(line);
1361            let trimmed = line.trim();
1362            if trimmed.is_empty() || trimmed.starts_with('#') {
1363                continue;
1364            }
1365            if let Some(cap) = include.captures(line) {
1366                let mut target = cap[1].trim();
1367                for q in ['"', '\''] {
1368                    if target.len() >= 2 && target.starts_with(q) && target.ends_with(q) {
1369                        target = &target[1..target.len() - 1];
1370                    }
1371                }
1372                self.read_config(&join_norm(&dir, target), depth + 1, stack)?;
1373                continue;
1374            }
1375            let body = if self.config_prefixes.is_empty() {
1376                line
1377            } else {
1378                match self.config_prefixes.iter().find(|p| line.starts_with(p.as_str())) {
1379                    Some(p) => &line[p.len()..],
1380                    None => continue,
1381                }
1382            };
1383            let Some((key, value)) = body.split_once('=') else { continue };
1384            let key = key.trim();
1385            let key = key.strip_prefix("--").unwrap_or(key);
1386            if !key.is_empty() {
1387                self.config.insert(key.to_string(), (value.trim().to_string(), dir.clone()));
1388            }
1389        }
1390        stack.remove(path);
1391        Ok(())
1392    }
1393
1394    fn resolve(&mut self) -> Result<(), String> {
1395        if !self.node().children.is_empty() {
1396            return Err("Missing command".into());
1397        }
1398        let options = self.chain_options();
1399        let args = self.node().args.clone();
1400        for arg in &args {
1401            if self.arg_raw.contains_key(&arg.name) {
1402                continue;
1403            }
1404            if arg.variadic {
1405                self.arg_raw.insert(arg.name.clone(), Raw::Many(Vec::new()));
1406            } else if arg.default.is_empty() {
1407                return Err(format!("Missing required positional argument: {}", arg.name));
1408            } else {
1409                self.arg_raw.insert(arg.name.clone(), Raw::One(arg.default.clone()));
1410            }
1411        }
1412
1413        for opt in &options {
1414            if self.sources.contains_key(&opt.long) {
1415                continue;
1416            }
1417            let env_value = if opt.kind == Kind::Array { None } else { self.env.get(&opt.var).filter(|v| !v.is_empty()) };
1418            if let Some(v) = env_value {
1419                let v = if opt.kind == Kind::Flag {
1420                    bool_word(v).ok_or(format!("Environment variable {} must be a boolean, got '{v}'", opt.var))?.to_string()
1421                } else {
1422                    v.clone()
1423                };
1424                self.raw.insert(opt.long.clone(), Raw::One(v));
1425                self.sources.insert(opt.long.clone(), "env");
1426            } else if opt.kind == Kind::Flag {
1427                self.raw.insert(opt.long.clone(), Raw::One("false".into()));
1428                self.sources.insert(opt.long.clone(), "default");
1429            } else if !opt.default.is_empty() {
1430                self.raw.insert(opt.long.clone(), Raw::One(opt.default.clone()));
1431                self.sources.insert(opt.long.clone(), "default");
1432            }
1433        }
1434
1435        // Path resolution: the base depends on where the value came from.
1436        for opt in &options {
1437            if !is_path_rule(&opt.rule) || opt.long == self.config_option {
1438                continue;
1439            }
1440            let Some(raw) = self.raw.get_mut(&opt.long) else { continue };
1441            let base = match self.sources.get(&opt.long) {
1442                Some(&"cli") => self.cwd.clone(),
1443                Some(&"config") => self.config[&opt.long].1.clone(),
1444                _ => self.root.clone(),
1445            };
1446            match raw {
1447                Raw::One(v) => *v = resolve_path(v, &base, &opt.search_dirs),
1448                Raw::Many(l) => l.iter_mut().for_each(|v| *v = resolve_path(v, &base, &opt.search_dirs)),
1449            }
1450        }
1451        for arg in &args {
1452            if !is_path_rule(&arg.rule) {
1453                continue;
1454            }
1455            match self.arg_raw.get_mut(&arg.name) {
1456                Some(Raw::One(v)) => *v = resolve_path(v, &self.cwd, &[]),
1457                Some(Raw::Many(l)) => l.iter_mut().for_each(|v| *v = resolve_path(v, &self.cwd, &[])),
1458                None => {}
1459            }
1460        }
1461
1462        let convert = |v: &str, rule: &str, name: &str| -> Result<Value, String> {
1463            if v.is_empty() || rule.is_empty() {
1464                Ok(Value::Str(v.into()))
1465            } else {
1466                validate(v, rule, name)
1467            }
1468        };
1469        let convert_raw = |raw: &Raw, rule: &str, name: &str| -> Result<Value, String> {
1470            match raw {
1471                Raw::One(v) => convert(v, rule, name),
1472                Raw::Many(l) => Ok(Value::List(l.iter().map(|v| convert(v, rule, name)).collect::<Result<_, _>>()?)),
1473            }
1474        };
1475        let mut values = Values::default();
1476        for opt in &options {
1477            let value = match (self.raw.get(&opt.long), opt.kind) {
1478                (None, Kind::Array) => Value::List(Vec::new()),
1479                (None, _) => Value::Null,
1480                (Some(Raw::One(v)), Kind::Flag) => Value::Bool(v == "true"),
1481                (Some(raw), _) => convert_raw(raw, &opt.rule, &format!("--{}", opt.long))?,
1482            };
1483            values.set(&opt.var, value);
1484        }
1485        for arg in &args {
1486            values.set(&arg.name, convert_raw(&self.arg_raw[&arg.name], &arg.rule, &arg.name)?);
1487        }
1488        if !self.children.is_empty() {
1489            values.set("command", Value::List(self.command_path().into_iter().map(Value::Str).collect()));
1490        }
1491        self.values = values;
1492        Ok(())
1493    }
1494
1495    /// Parse `std::env::args()` like a CLI: handles --help, --help-json-schema and
1496    /// --bash-completion, prints errors and exits on failure. Returns the values.
1497    pub fn run(&mut self) -> Values {
1498        let argv: Vec<String> = std::env::args().skip(1).collect();
1499        self.run_with(&argv)
1500    }
1501
1502    /// [`Cli::run`] with explicit arguments (excluding the program name).
1503    pub fn run_with<S: AsRef<str>>(&mut self, argv: &[S]) -> Values {
1504        let head = argv.iter().map(AsRef::as_ref).take_while(|a| *a != "--");
1505        let words: Vec<&str> = argv.iter().map(AsRef::as_ref).skip_while(|a| *a != "--").skip(1).collect();
1506        for a in head {
1507            if a == "--help-json-schema" {
1508                println!("{}", self.json_schema());
1509                std::process::exit(0);
1510            }
1511            if a == "--bash-completion" {
1512                print!("{}", self.completion_data_for(&words));
1513                std::process::exit(0);
1514            }
1515            if a == "--completion" {
1516                let shell = argv.iter().map(AsRef::as_ref).skip_while(|x| *x != "--completion").nth(1).unwrap_or("");
1517                match self.completion_script(shell) {
1518                    Some(script) => {
1519                        print!("{script}");
1520                        std::process::exit(0);
1521                    }
1522                    None => die(1, &format!("Unknown shell '{shell}' (expected bash, zsh or fish)")),
1523                }
1524            }
1525        }
1526        match self.parse(argv) {
1527            Parsed::Ok => self.values.clone(),
1528            Parsed::Help => {
1529                print!("{}", self.usage());
1530                std::process::exit(0)
1531            }
1532            Parsed::Error { message, show_usage, detail } => {
1533                emit("error", &message, true);
1534                for line in detail {
1535                    eprintln!("{line}");
1536                }
1537                if show_usage {
1538                    eprint!("{}", self.usage());
1539                }
1540                std::process::exit(1)
1541            }
1542        }
1543    }
1544
1545    // -- accessors ------------------------------------------------------------
1546
1547    /// Read resolved values from the last successful parse.
1548    pub fn values(&self) -> &Values {
1549        &self.values
1550    }
1551
1552    /// Where an option's value came from: cli, config, env, default or unset.
1553    pub fn source(&self, long: &str) -> &'static str {
1554        self.sources.get(long.strip_prefix("--").unwrap_or(long)).copied().unwrap_or("unset")
1555    }
1556
1557    /// Whether an option came from the command line.
1558    pub fn is_set(&self, long: &str) -> bool {
1559        self.source(long) == "cli"
1560    }
1561
1562    /// Whether an option came from CLI, config, or environment rather than a default.
1563    pub fn is_explicitly_set(&self, long: &str) -> bool {
1564        matches!(self.source(long), "cli" | "config" | "env")
1565    }
1566
1567    /// Resolved values as JSON (spec section 10).
1568    pub fn values_json(&self) -> String {
1569        let secrets: Vec<String> = self.chain_options().into_iter().filter(|o| o.secret).map(|o| o.var).collect();
1570        let mask = |k: &str, v: &Value| match v {
1571            _ if !secrets.iter().any(|s| s == k) => Json::from_value(v),
1572            Value::Null => Json::Null,
1573            Value::List(l) => Json::Arr(l.iter().map(|_| Json::Str("***".into())).collect()),
1574            _ => Json::Str("***".into()),
1575        };
1576        Json::Map(self.values.iter().map(|(k, v)| (k.to_string(), mask(k, v))).collect()).pretty()
1577    }
1578
1579    // -- output ---------------------------------------------------------------
1580
1581    /// Help text (spec section 7), for the selected command.
1582    pub fn usage(&mut self) -> String {
1583        self.ensure_help();
1584        let chain = self.chain();
1585        let node = chain[0];
1586        let options = self.chain_options();
1587        let max_width = self.env.get("CLYOPS_MAX_WIDTH").and_then(|w| w.parse::<usize>().ok()).filter(|w| *w > 0).unwrap_or(100);
1588        let longest = options
1589            .iter()
1590            .map(|o| o.label().chars().count())
1591            .chain(node.children.iter().map(|c| c.word.chars().count()))
1592            .max()
1593            .unwrap_or(0);
1594        let indent = (longest + 4).clamp(32, 50);
1595        let text_width = max_width.saturating_sub(indent).max(20);
1596
1597        let row = |label: &str, text: &str| -> Vec<String> {
1598            let mut left = format!("  {label}");
1599            let len = left.chars().count();
1600            if len < indent {
1601                left.push_str(&" ".repeat(indent - len))
1602            } else {
1603                left.push(' ')
1604            }
1605            let lines = wrap_text(text, text_width);
1606            let mut out = vec![format!("{left}{}", lines[0])];
1607            out.extend(lines[1..].iter().map(|l| format!("{}{l}", " ".repeat(indent))));
1608            out
1609        };
1610        let annotate = |text: &str, notes: &[String]| {
1611            if notes.is_empty() {
1612                text.to_string()
1613            } else {
1614                format!("{text} ({})", notes.join(", "))
1615            }
1616        };
1617
1618        let mut sections: Vec<Vec<String>> = Vec::new();
1619        let mut usage = format!("Usage: {}", node.name);
1620        if !node.children.is_empty() {
1621            usage += " <command>";
1622        }
1623        for a in &node.args {
1624            usage += &if a.variadic {
1625                format!(" [<{}...>]", a.name)
1626            } else if a.default.is_empty() {
1627                format!(" <{}>", a.name)
1628            } else {
1629                format!(" [<{}>]", a.name)
1630            };
1631        }
1632        sections.push(vec![usage + " [OPTIONS]"]);
1633
1634        if !node.description.is_empty() {
1635            sections.push(wrap_text(&node.description, max_width));
1636        }
1637
1638        let io: Vec<String> = [("Input:", &node.stdin), ("Output:", &node.stdout)]
1639            .iter()
1640            .filter_map(|(label, decl)| {
1641                let (d, t) = decl.as_ref()?;
1642                let mut line = label.to_string();
1643                if !d.is_empty() {
1644                    line += &format!(" {d}");
1645                }
1646                if !t.is_empty() {
1647                    line += &format!(" ({t})");
1648                }
1649                Some(line)
1650            })
1651            .collect();
1652        if !io.is_empty() {
1653            sections.push(io);
1654        }
1655
1656        if !node.children.is_empty() {
1657            let mut lines = vec!["Commands:".to_string()];
1658            for c in &node.children {
1659                lines.extend(row(&c.word, &c.description));
1660            }
1661            sections.push(lines);
1662        }
1663
1664        if !node.args.is_empty() {
1665            let mut lines = vec!["Positional Arguments:".to_string()];
1666            for a in &node.args {
1667                let mut notes = Vec::new();
1668                if a.variadic {
1669                    notes.push("variadic".to_string())
1670                }
1671                if !a.default.is_empty() {
1672                    notes.push(format!("default: {}", a.default))
1673                }
1674                if !a.rule.is_empty() {
1675                    notes.push(format!("accepts: {}", describe_rule(&a.rule)))
1676                }
1677                lines.extend(row(&a.name, &annotate(&a.description, &notes)));
1678            }
1679            sections.push(lines);
1680        }
1681
1682        let required: Vec<&(String, String, String)> = chain.iter().rev().flat_map(|n| n.commands.iter()).collect();
1683        if !required.is_empty() {
1684            let path_var = self.env.get("PATH").cloned().unwrap_or_default();
1685            let mut lines = vec!["Required Commands:".to_string()];
1686            for (cmd, desc, hint) in required {
1687                let status = if command_available(cmd, &path_var) { "installed" } else { "not found" };
1688                let text = if hint.is_empty() { desc.clone() } else { format!("{desc} ({hint})") };
1689                lines.extend(row(&format!("{cmd} [{status}]"), &text));
1690            }
1691            sections.push(lines);
1692        }
1693
1694        let constraints: Vec<&(&str, Vec<String>)> = chain.iter().rev().flat_map(|n| n.constraints.iter()).collect();
1695        let list = |longs: &[&String]| longs.iter().map(|l| format!("--{l}")).collect::<Vec<_>>().join(", ");
1696        let mut groups: Vec<&str> = Vec::new();
1697        for o in &options {
1698            if !groups.contains(&o.group.as_str()) {
1699                groups.push(&o.group);
1700            }
1701        }
1702        for group in groups {
1703            let mut lines = vec![format!("{group}:")];
1704            for o in options.iter().filter(|o| o.group == group) {
1705                let mut notes = Vec::new();
1706                if o.required {
1707                    notes.push("required".to_string())
1708                }
1709                if o.kind == Kind::Array {
1710                    notes.push("multiple".to_string())
1711                }
1712                if o.secret {
1713                    notes.push("secret".to_string())
1714                }
1715                if let Some((v, _)) = self.config.get(&o.long) {
1716                    notes.push(format!("config: {}", if o.secret { "***" } else { v }))
1717                }
1718                if !o.default.is_empty() {
1719                    notes.push(format!("default: {}", o.default))
1720                }
1721                if !o.rule.is_empty() {
1722                    notes.push(format!("accepts: {}", describe_rule(&o.rule)))
1723                }
1724                for (kind, longs) in constraints.iter().map(|c| (c.0, &c.1)) {
1725                    if !longs.contains(&o.long) {
1726                        continue;
1727                    }
1728                    match kind {
1729                        "exclusive" => notes.push(format!(
1730                            "conflicts with: {}",
1731                            list(&longs.iter().filter(|l| **l != o.long).collect::<Vec<_>>())
1732                        )),
1733                        "requires" if longs[0] == o.long => {
1734                            notes.push(format!("requires: {}", list(&longs[1..].iter().collect::<Vec<_>>())))
1735                        }
1736                        "oneOf" => notes.push(format!("one of: {}", list(&longs.iter().collect::<Vec<_>>()))),
1737                        _ => {}
1738                    }
1739                }
1740                lines.extend(row(&o.label(), &annotate(&o.description, &notes)));
1741            }
1742            sections.push(lines);
1743        }
1744
1745        if !node.epilog.is_empty() {
1746            sections.push(node.epilog.trim_end_matches('\n').split('\n').map(String::from).collect());
1747        }
1748
1749        let text = sections.iter().map(|s| s.join("\n")).collect::<Vec<_>>().join("\n\n");
1750        text.split('\n').map(str::trim_end).collect::<Vec<_>>().join("\n") + "\n"
1751    }
1752
1753    /// JSON description of the CLI (spec section 8).
1754    pub fn json_schema(&mut self) -> String {
1755        self.ensure_help();
1756        let mut fields = vec![("clyops", Json::Num("1".into())), ("script", Json::Str(self.name.clone()))];
1757        fields.extend(self.schema_node());
1758        Json::Obj(fields).pretty()
1759    }
1760
1761    fn schema_node(&self) -> Vec<(&'static str, Json)> {
1762        let type_of = |o: &Opt| {
1763            let r = o.rule.as_str();
1764            if o.kind == Kind::Flag || r == "bool" {
1765                "boolean"
1766            } else if r.starts_with("int") || r == "port" {
1767                "integer"
1768            } else if r.starts_with("float") {
1769                "number"
1770            } else if r.starts_with("choice:") {
1771                "choice"
1772            } else if is_path_rule(r) {
1773                "path"
1774            } else {
1775                "string"
1776            }
1777        };
1778        let s = |v: &str| Json::Str(v.to_string());
1779        let stream = |d: &Option<(String, String)>| match d {
1780            Some((d, t)) => Json::Obj(vec![("description", s(d)), ("contentType", s(t))]),
1781            None => Json::Null,
1782        };
1783        let mut fields = if self.word.is_empty() { Vec::new() } else { vec![("name", s(&self.word))] };
1784        fields.extend(vec![
1785            ("description", s(&self.description)),
1786            ("epilog", s(&self.epilog)),
1787            (
1788                "arguments",
1789                Json::Arr(
1790                    self.args
1791                        .iter()
1792                        .map(|a| {
1793                            Json::Obj(vec![
1794                                ("name", s(&a.name)),
1795                                ("description", s(&a.description)),
1796                                ("required", Json::Bool(!a.variadic && a.default.is_empty())),
1797                                ("isVariadic", Json::Bool(a.variadic)),
1798                                ("default", s(&a.default)),
1799                                ("validation", s(&a.rule)),
1800                            ])
1801                        })
1802                        .collect(),
1803                ),
1804            ),
1805            (
1806                "options",
1807                Json::Arr(
1808                    self.options
1809                        .iter()
1810                        .map(|o| {
1811                            Json::Obj(vec![
1812                                ("name", s(&o.long)),
1813                                ("shortName", s(&o.short)),
1814                                ("variableName", s(&o.var)),
1815                                ("description", s(&o.description)),
1816                                ("default", s(if o.kind == Kind::Flag { "false" } else { &o.default })),
1817                                ("group", s(&o.group)),
1818                                ("type", s(type_of(o))),
1819                                ("isFlag", Json::Bool(o.kind == Kind::Flag)),
1820                                ("isArray", Json::Bool(o.kind == Kind::Array)),
1821                                ("required", Json::Bool(o.required)),
1822                                ("validation", s(&o.rule)),
1823                                (
1824                                    "choices",
1825                                    Json::Arr(
1826                                        o.rule.strip_prefix("choice:").map(|c| c.split(',').map(s).collect()).unwrap_or_default(),
1827                                    ),
1828                                ),
1829                                ("secret", Json::Bool(o.secret)),
1830                            ])
1831                        })
1832                        .collect(),
1833                ),
1834            ),
1835            (
1836                "requiredCommands",
1837                Json::Arr(
1838                    self.commands
1839                        .iter()
1840                        .map(|(c, d, h)| Json::Obj(vec![("command", s(c)), ("description", s(d)), ("installHint", s(h))]))
1841                        .collect(),
1842                ),
1843            ),
1844            ("effects", Json::Arr(self.effects.iter().map(|e| s(e)).collect())),
1845            (
1846                "constraints",
1847                Json::Arr(
1848                    self.constraints
1849                        .iter()
1850                        .map(|(k, l)| Json::Obj(vec![("type", s(k)), ("options", Json::Arr(l.iter().map(|o| s(o)).collect()))]))
1851                        .collect(),
1852                ),
1853            ),
1854            ("stdin", stream(&self.stdin)),
1855            ("stdout", stream(&self.stdout)),
1856            ("commands", Json::Arr(self.children.iter().map(|c| Json::Obj(c.schema_node())).collect())),
1857        ]);
1858        fields
1859    }
1860
1861    /// Shell script that enables completion for this program (spec section 9):
1862    /// `eval "$(prog --completion bash)"`. `None` for an unknown shell.
1863    pub fn completion_script(&self, shell: &str) -> Option<String> {
1864        let template = match shell {
1865            "bash" => completions::BASH,
1866            "zsh" => completions::ZSH,
1867            "fish" => completions::FISH,
1868            _ => return None,
1869        };
1870        let func: String = self.name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect();
1871        Some(template.replace("__CLYOPS_FUNC__", &func).replace("__CLYOPS_PROG__", &self.name))
1872    }
1873
1874    /// Tab-separated completion records (spec section 9).
1875    pub fn completion_data(&mut self) -> String {
1876        self.completion_data_for(&[])
1877    }
1878
1879    /// Completion records for the words typed after the program name: a
1880    /// program with commands follows them (spec section 9).
1881    pub fn completion_data_for(&mut self, words: &[&str]) -> String {
1882        self.ensure_help();
1883        let clean = |s: &str| s.replace(['\t', '\n'], " ");
1884        let mut out = String::from("#clyops-completion 1\n");
1885        let mut chain = vec![&*self];
1886        if !self.children.is_empty() {
1887            let mut skip = 0;
1888            for w in words {
1889                match chain[0].children.iter().find(|c| c.word == *w) {
1890                    Some(c) => chain.insert(0, c),
1891                    None => break,
1892                }
1893                skip += 1;
1894            }
1895            if !chain[0].children.is_empty() && skip < words.len() && !words[skip].starts_with('-') {
1896                return out;
1897            }
1898            let _ = writeln!(out, "skip\t{skip}");
1899            for c in &chain[0].children {
1900                let _ = writeln!(out, "cmd\t{}\t{}", c.word, clean(&c.description));
1901            }
1902        }
1903        for o in chain.iter().flat_map(|n| n.options.iter()) {
1904            let short = if o.short.is_empty() { "-".to_string() } else { format!("-{}", o.short) };
1905            if o.kind == Kind::Flag {
1906                let _ = writeln!(out, "opt\t--{}\t{short}\tflag\tnone\t-\t{}", o.long, clean(&o.description));
1907            } else {
1908                let (kind, values) = completion_kind(&o.rule, &o.search_dirs);
1909                let values = if values.is_empty() { "-".to_string() } else { values };
1910                let _ = writeln!(out, "opt\t--{}\t{short}\tvalue\t{kind}\t{values}\t{}", o.long, clean(&o.description));
1911            }
1912            if o.bool_like() {
1913                let _ = writeln!(out, "opt\t--no-{}\t-\tflag\tnone\t-\t{}", o.long, clean(&o.description));
1914            }
1915        }
1916        for a in &chain[0].args {
1917            let (kind, values) = completion_kind(&a.rule, &[]);
1918            let values = if values.is_empty() { "-".to_string() } else { values };
1919            let arity = if a.variadic { "variadic" } else { "single" };
1920            let _ = writeln!(out, "arg\t{}\t{arity}\t{kind}\t{values}\t{}", a.name, clean(&a.description));
1921        }
1922        out
1923    }
1924}