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 }