crates/cli/src/args.rs 8.5 KiB 273 lines Raw
1 #![forbid(unsafe_code)]2 #![allow(dead_code)]3 //! Table-driven flag parser (~200 lines). No clap.4 //! Feature agents: extend a per-command SPEC table, call parse_flags.5 //! Globals (--json/--jq/--skill/-h) are parsed by parse_global only.6 7 use crate::CliError;8 use std::collections::BTreeMap;9 10 // ---- flag tables ----11 12 /// One flag row. Feature agents copy this pattern per subcommand.13 #[derive(Debug, Clone, Copy)]14 pub struct FlagSpec {15     pub name: &'static str, // without leading --16     pub takes_value: bool,17 }18 19 /// gh-literal value flags shared by entity commands.20 pub const VALUE_FLAGS: &[&str] =21     &["title", "body", "label", "assignee", "milestone", "base", "head", "jq"];22 /// Bool flags shared by entity commands.23 pub const BOOL_FLAGS: &[&str] = &["json", "draft", "skill"];24 25 /// Full spec for PLAN M2 global surface.26 pub const GLOBAL_SPEC: &[FlagSpec] = &[27     FlagSpec { name: "json", takes_value: false },28     FlagSpec { name: "jq", takes_value: true },29     FlagSpec { name: "skill", takes_value: false },30 ];31 32 /// Parsed flag set: bools, single values, positionals. Multi-use flags33 /// (label/assignee) append with comma-joined repeats via get_all.34 #[derive(Debug, Clone, Default)]35 pub struct FlagSet {36     pub bools: BTreeMap<String, bool>,37     pub vals: BTreeMap<String, String>,38     pub multi: BTreeMap<String, Vec<String>>,39     pub positional: Vec<String>,40 }41 42 impl FlagSet {43     pub fn is(&self, name: &str) -> bool {44         self.bools.get(name).copied().unwrap_or(false)45     }46     pub fn get(&self, name: &str) -> Option<&str> {47         self.vals.get(name).map(String::as_str)48     }49     pub fn get_all(&self, name: &str) -> Vec<&str> {50         if let Some(v) = self.multi.get(name) {51             v.iter().map(String::as_str).collect()52         } else if let Some(s) = self.vals.get(name) {53             vec![s.as_str()]54         } else {55             Vec::new()56         }57     }58     pub fn has(&self, name: &str) -> bool {59         self.vals.contains_key(name) || self.multi.contains_key(name)60     }61 }62 63 fn find_spec<'a>(specs: &'a [FlagSpec], name: &str) -> Option<&'a FlagSpec> {64     specs.iter().find(|s| s.name == name)65 }66 67 fn base_name_only(s: &str) -> &str {68     s.rsplit('/').next().unwrap_or(s)69 }70 71 /// Parse argv against a spec table. Unknown flag -> Usage (exit 2).72 /// Supports `--flag val`, `--flag=val`, bool `--flag`. Stops at `--`.73 /// Single `-h` is recorded as bool `help`; other `-x` -> Usage.74 pub fn parse_flags(args: &[String], specs: &[FlagSpec]) -> Result<FlagSet, CliError> {75     let mut out = FlagSet::default();76     let mut i = 0;77     while i < args.len() {78         let a = &args[i];79         if a == "--" {80             out.positional.extend(args[i + 1..].iter().cloned());81             break;82         }83         if a == "-h" || a == "--help" {84             out.bools.insert("help".to_string(), true);85             i += 1;86             continue;87         }88         if let Some(rest) = a.strip_prefix("--") {89             let (name, inline) = match rest.find('=') {90                 Some(k) => (&rest[..k], Some(rest[k + 1..].to_string())),91                 None => (rest, None),92             };93             let Some(spec) = find_spec(specs, name) else {94                 return Err(CliError::Usage(format!("unknown flag: --{name}")));95             };96             if spec.takes_value {97                 if let Some(v) = inline {98                     if v.is_empty() {99                         return Err(CliError::Usage(format!("--{name}: needs value")));100                     }101                     push_val(&mut out, name, v);102                 } else {103                     i += 1;104                     if i >= args.len() {105                         return Err(CliError::Usage(format!("--{name}: needs value")));106                     }107                     push_val(&mut out, name, args[i].clone());108                 }109             } else {110                 if inline.is_some() {111                     return Err(CliError::Usage(format!("--{name}: no value")));112                 }113                 out.bools.insert(name.to_string(), true);114             }115             i += 1;116             continue;117         }118         if a.starts_with('-') && a.len() > 1 {119             return Err(CliError::Usage(format!("unknown flag: {a}")));120         }121         out.positional.push(a.clone());122         i += 1;123     }124     Ok(out)125 }126 127 fn push_val(out: &mut FlagSet, name: &str, v: String) {128     // repeatable flags accumulate; others last-wins.129     if name == "label" || name == "assignee" {130         out.multi.entry(name.to_string()).or_default().push(v);131     } else {132         out.vals.insert(name.to_string(), v);133     }134 }135 136 /// Build a spec table from value/bool name lists (for feature cmds).137 pub fn spec_from_lists(values: &[&'static str], bools: &[&'static str]) -> Vec<FlagSpec> {138     let mut v: Vec<FlagSpec> = Vec::with_capacity(values.len() + bools.len());139     for b in bools {140         v.push(FlagSpec { name: b, takes_value: false });141     }142     for name in values {143         v.push(FlagSpec { name, takes_value: true });144     }145     v146 }147 148 // ---- globals: flags before the subcommand only ----149 150 /// Global view: --json/--jq/--skill + sub + rest.151 #[derive(Debug, Clone, Default)]152 pub struct Global {153     pub json: bool,154     pub jq: Option<String>,155     pub skill: bool,156     pub version: bool,157     pub sub: Option<String>,158     pub rest: Vec<String>,159 }160 161 /// Split argv into globals + subcommand. Unknown --flag before the162 /// subcommand -> Usage. Flags after the subcommand pass through.163 pub fn parse_global(argv: &[String]) -> Result<Global, CliError> {164     let mut g = Global::default();165     let mut i = 0;166     while i < argv.len() {167         let a = &argv[i];168         if a == "--" {169             // everything after is positional; first is sub.170             if i + 1 < argv.len() {171                 g.sub = Some(argv[i + 1].clone());172                 g.rest = argv[i + 2..].to_vec();173             }174             break;175         }176         if a == "-h" || a == "--help" {177             // global help: same as no args (root help).178             i += 1;179             continue;180         }181         if a == "--json" {182             g.json = true;183             i += 1;184             continue;185         }186         if a == "--skill" {187             g.skill = true;188             i += 1;189             continue;190         }191         if a == "-V" || a == "--version" {192             g.version = true;193             i += 1;194             continue;195         }196         if a == "--jq" {197             i += 1;198             if i >= argv.len() {199                 return Err(CliError::Usage("--jq: needs value".to_string()));200             }201             g.jq = Some(argv[i].clone());202             i += 1;203             continue;204         }205         if let Some(rest) = a.strip_prefix("--jq=") {206             if rest.is_empty() {207                 return Err(CliError::Usage("--jq: needs value".to_string()));208             }209             g.jq = Some(rest.to_string());210             i += 1;211             continue;212         }213         if a.starts_with("--") {214             let name = a.trim_start_matches('-');215             let short = base_name_only(name);216             return Err(CliError::Usage(format!("unknown flag: --{short}")));217         }218         if a.starts_with('-') && a.len() > 1 {219             return Err(CliError::Usage(format!("unknown flag: {a}")));220         }221         // first positional = subcommand.222         g.sub = Some(a.clone());223         g.rest = argv[i + 1..].to_vec();224         break;225     }226     // validate subcommand name characters (no shell, no path).227     if let Some(s) = &g.sub {228         if !is_sub_name(s) {229             return Err(CliError::Usage(format!("unknown command: {s}")));230         }231     }232     Ok(g)233 }234 235 fn is_sub_name(s: &str) -> bool {236     !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase() || b == b'-')237 }238 239 #[cfg(test)]240 mod tests {241     use super::*;242 243     #[test]244     fn global_flags_before_sub() {245         let a = vec!["--json".to_string(), "issue".to_string(), "list".to_string()];246         let g = parse_global(&a).unwrap();247         assert!(g.json && g.sub.as_deref() == Some("issue"));248         assert_eq!(g.rest, vec!["list".to_string()]);249     }250 251     #[test]252     fn version_global() {253         let a = vec!["--version".to_string()];254         let g = parse_global(&a).unwrap();255         assert!(g.version);256         let a = vec!["-V".to_string()];257         assert!(parse_global(&a).unwrap().version);258     }259 260     #[test]261     fn jq_needs_value() {262         let a = vec!["--jq".to_string()];263         assert!(parse_global(&a).is_err());264     }265 266     #[test]267     fn unknown_global_flag_is_usage() {268         let a = vec!["--bogus".to_string()];269         let e = parse_global(&a).unwrap_err();270         assert_eq!(e.code(), 2);271         assert!(!e.to_string().contains('\n'));272     }273 }