use crate::failure::Failure;
use std::collections::HashMap;
pub(crate) const SWITCHES: &[&str] = &[
"blocks",
"root",
"hook",
"json",
"all",
"full",
"force",
"cascade",
"off",
"reopen",
"gates",
"everywhere",
"no-open",
"yes",
"dry-run",
"undo",
];
#[derive(Debug, Default)]
pub struct Args {
pub positionals: Vec<String>,
opts: HashMap<String, Vec<String>>,
}
impl Args {
pub fn parse<I: IntoIterator<Item = String>>(it: I) -> Result<Args, Failure> {
let v: Vec<String> = it.into_iter().collect();
let mut a = Args::default();
let mut i = 0;
while i < v.len() {
if let Some(k) = v[i].strip_prefix("--") {
let (k, inline) = match k.split_once('=') {
Some((k, val)) => (k, Some(val.to_string())),
None => (k, None),
};
if SWITCHES.contains(&k) {
if let Some(val) = inline {
return Err(Failure::usage(format!(
"--{k} does not take a value: leave out \"={val}\"."
)));
}
a.opts.entry(k.to_string()).or_default();
} else {
let val = inline.or_else(|| {
v.get(i + 1).filter(|n| !n.starts_with("--")).map(|n| {
i += 1;
n.clone()
})
});
a.opts.entry(k.to_string()).or_default().extend(val);
}
} else {
a.positionals.push(v[i].clone());
}
i += 1;
}
Ok(a)
}
pub fn has(&self, k: &str) -> bool {
self.opts.contains_key(k)
}
pub fn opt(&self, k: &str) -> Option<&str> {
self.opts.get(k).and_then(|v| v.last()).map(|s| s.as_str())
}
pub fn opt_or(&self, k: &str) -> String {
self.opt(k).unwrap_or_default().to_string()
}
pub fn list(&self, k: &str) -> Vec<String> {
self.opts.get(k).cloned().unwrap_or_default()
}
pub fn positional(&self, i: usize) -> Option<&str> {
self.positionals.get(i).map(|s| s.as_str())
}
pub fn unknown(&self, allowed: &[&str]) -> Vec<&str> {
let mut v: Vec<&str> = self
.opts
.keys()
.map(|k| k.as_str())
.filter(|k| !allowed.contains(k))
.collect();
v.sort_unstable();
v
}
pub fn extra(&self, takes: usize) -> &[String] {
self.positionals.get(takes..).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(s: &str) -> Args {
Args::parse(s.split_whitespace().map(String::from)).unwrap()
}
#[test]
fn positionals_and_options() {
let a = p("title --why reason --blocks --ref one --ref two");
assert_eq!(a.positional(0), Some("title"));
assert_eq!(a.opt("why"), Some("reason"));
assert!(a.has("blocks"));
assert_eq!(a.list("ref"), vec!["one", "two"]);
}
#[test]
fn a_flag_next_to_another_flag() {
let a = p("--blocks --why x");
assert!(a.has("blocks"));
assert_eq!(a.opt("blocks"), None);
assert_eq!(a.opt("why"), Some("x"));
}
#[test]
fn an_option_that_does_not_exist_does_not_pass_in_silence() {
let a = p("x --kind finding --type task");
assert_eq!(a.unknown(&["type", "why"]), vec!["kind"]);
assert!(a.unknown(&["type", "kind"]).is_empty());
}
#[test]
fn equals_sign() {
let a = p("--type=decision");
assert_eq!(a.opt("type"), Some("decision"));
}
#[test]
fn a_flag_that_is_not_english_is_unknown_rather_than_ignored() {
let a = p("t --padre 3");
assert_eq!(a.opt("parent"), None);
assert_eq!(a.unknown(&["why", "parent"]), vec!["padre"]);
}
#[test]
fn an_unknown_flag_is_quoted_back_verbatim() {
assert_eq!(p("--nonesuch 1").unknown(&["why"]), vec!["nonesuch"]);
}
#[test]
fn a_positional_too_many_is_not_swallowed() {
assert!(p("title --why reason").extra(1).is_empty());
assert_eq!(p("title junk --why reason").extra(1), ["junk"]);
assert_eq!(p("title --governs a b").extra(1), ["b"]);
assert!(p("3 suspect --why reason").extra(2).is_empty());
}
#[test]
fn a_switch_never_eats_the_word_after_it() {
let a = p("--blocks t --why w");
assert!(a.has("blocks"));
assert_eq!(a.opt("blocks"), None);
assert_eq!(a.positional(0), Some("t"));
assert_eq!(a.opt("why"), Some("w"));
}
#[test]
fn equals_on_a_switch_is_a_usage_error() {
let e = Args::parse(vec!["--blocks=x".to_string()]).unwrap_err();
assert_eq!(e.code(), 2);
let msg = e.message();
assert!(msg.contains("--blocks"), "{msg}");
assert!(msg.contains("does not take a value"), "{msg}");
}
#[test]
fn a_switch_before_a_positional_leaves_the_positional_alone() {
let a = p("--yes claude-code");
assert!(a.has("yes"));
assert_eq!(a.opt("yes"), None);
assert_eq!(a.positional(0), Some("claude-code"));
}
#[test]
fn no_switch_is_ever_read_as_if_it_carried_a_value() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut pending = vec![root];
while let Some(dir) = pending.pop() {
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
pending.push(path);
continue;
}
if !path.extension().is_some_and(|x| x == "rs") {
continue;
}
let src = std::fs::read_to_string(&path).unwrap();
let code = src.split("#[cfg(test)]").next().unwrap_or(&src);
for switch in SWITCHES {
for accessor in [".opt(\"", ".opt_or(\"", ".list(\""] {
if code.contains(&format!("{accessor}{switch}\")")) {
offenders.push(format!("{switch} via {accessor}...) in {path:?}"));
}
}
}
}
}
assert!(offenders.is_empty(), "{offenders:#?}");
}
}