use std::collections::HashMap;
use crate::ValueProvider;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OptionDef {
pub name: String,
pub short: Option<char>,
pub takes_value: bool,
pub multiple: bool,
pub value_name: Option<String>,
pub help: Option<String>,
pub conflicts_with: Vec<String>,
}
impl OptionDef {
pub fn value(name: impl Into<String>) -> Self {
OptionDef {
name: name.into(),
short: None,
takes_value: true,
multiple: false,
value_name: None,
help: None,
conflicts_with: Vec::new(),
}
}
pub fn flag(name: impl Into<String>) -> Self {
OptionDef {
name: name.into(),
short: None,
takes_value: false,
multiple: false,
value_name: None,
help: None,
conflicts_with: Vec::new(),
}
}
pub fn conflicts_with(mut self, others: &[&str]) -> Self {
for o in others {
if !self.conflicts_with.iter().any(|c| c == o) {
self.conflicts_with.push((*o).to_string());
}
}
self
}
pub fn short(mut self, c: char) -> Self {
self.short = Some(c);
self
}
pub fn multiple(mut self, yes: bool) -> Self {
self.multiple = yes;
self
}
pub fn value_name(mut self, n: impl Into<String>) -> Self {
self.value_name = Some(n.into());
self
}
pub fn help(mut self, h: impl Into<String>) -> Self {
self.help = Some(h.into());
self
}
pub fn parse_sig(&self) -> (bool, bool, Option<char>) {
(self.takes_value, self.multiple, self.short)
}
}
#[derive(Clone, Debug)]
pub struct ParseMismatch {
pub name: String,
pub occurrences: Vec<(String, OptionDef)>,
}
impl std::fmt::Display for ParseMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
"option '{}' parses inconsistently across commands (must be one parse-definition):",
self.name
)?;
for (path, def) in &self.occurrences {
let (takes_value, multiple, short) = def.parse_sig();
writeln!(
f,
" {:<28} takes_value={takes_value} multiple={multiple} short={short:?}",
if path.is_empty() { "<root>" } else { path }
)?;
}
Ok(())
}
}
pub trait CommandOption {
fn definition(&self) -> OptionDef;
fn value_resolver(&self) -> Option<ValueProvider> {
None
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OptionConflict {
pub name: String,
pub existing: Box<OptionDef>,
pub attempted: Box<OptionDef>,
}
impl std::fmt::Display for OptionConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"option '{}' is already defined with a different parse structure; \
every command attaching '{}' must use one identical definition.\n \
existing: {:?}\n attempted: {:?}",
self.name, self.name, self.existing, self.attempted
)
}
}
impl std::error::Error for OptionConflict {}
#[derive(Default, Debug)]
pub struct OptionRegistry {
defs: HashMap<String, OptionDef>,
}
impl OptionRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn define(&mut self, def: &OptionDef) -> Result<(), OptionConflict> {
match self.defs.get(&def.name) {
Some(existing) if existing != def => Err(OptionConflict {
name: def.name.clone(),
existing: Box::new(existing.clone()),
attempted: Box::new(def.clone()),
}),
Some(_) => Ok(()),
None => {
self.defs.insert(def.name.clone(), def.clone());
Ok(())
}
}
}
pub fn attach(
&mut self,
opt: &dyn CommandOption,
) -> Result<(OptionDef, Option<ValueProvider>), OptionConflict> {
let def = opt.definition();
self.define(&def)?;
Ok((def, opt.value_resolver()))
}
pub fn get(&self, name: &str) -> Option<&OptionDef> {
self.defs.get(name)
}
pub fn len(&self) -> usize {
self.defs.len()
}
pub fn is_empty(&self) -> bool {
self.defs.is_empty()
}
pub fn audit(&self, observed: &[(String, OptionDef)]) -> Vec<ParseMismatch> {
let mut by_name: std::collections::BTreeMap<String, Vec<(String, OptionDef)>> =
std::collections::BTreeMap::new();
for (path, def) in observed {
by_name
.entry(def.name.clone())
.or_default()
.push((path.clone(), def.clone()));
}
let mut mismatches = Vec::new();
for (name, mut occs) in by_name {
let mut sigs: std::collections::HashSet<(bool, bool, Option<char>)> =
occs.iter().map(|(_, d)| d.parse_sig()).collect();
if let Some(reg) = self.defs.get(&name)
&& sigs.insert(reg.parse_sig()) {
occs.push(("<registered>".to_string(), reg.clone()));
}
if sigs.len() > 1 {
occs.sort_by(|a, b| a.0.cmp(&b.0));
mismatches.push(ParseMismatch { name, occurrences: occs });
}
}
mismatches
}
}
#[cfg(test)]
mod tests {
use super::*;
struct PingAt;
impl CommandOption for PingAt {
fn definition(&self) -> OptionDef {
OptionDef::value("--at").value_name("URL").help("Pin to a catalog location")
}
fn value_resolver(&self) -> Option<ValueProvider> {
Some(std::sync::Arc::new(|_p: &str, _c: &[&str]| vec!["ping-catalog".to_string()]))
}
}
struct PrecacheAt;
impl CommandOption for PrecacheAt {
fn definition(&self) -> OptionDef {
OptionDef::value("--at").value_name("URL").help("Pin to a catalog location")
}
fn value_resolver(&self) -> Option<ValueProvider> {
Some(std::sync::Arc::new(|_p: &str, _c: &[&str]| vec!["precache-catalog".to_string()]))
}
}
struct BadAt;
impl CommandOption for BadAt {
fn definition(&self) -> OptionDef {
OptionDef::flag("--at") }
}
#[test]
fn same_name_same_definition_attaches_from_multiple_commands() {
let mut reg = OptionRegistry::new();
let (d1, r1) = reg.attach(&PingAt).expect("first attach ok");
let (d2, r2) = reg.attach(&PrecacheAt).expect("second attach (shared def) ok");
assert_eq!(d1, d2, "shared definition");
assert_eq!(reg.len(), 1, "one definition recorded for the shared name");
assert_eq!(r1.unwrap()("", &[]), vec!["ping-catalog"]);
assert_eq!(r2.unwrap()("", &[]), vec!["precache-catalog"]);
}
#[test]
fn same_name_conflicting_definition_is_a_runtime_error() {
let mut reg = OptionRegistry::new();
reg.attach(&PingAt).expect("first attach ok");
let err = match reg.attach(&BadAt) {
Err(e) => e,
Ok(_) => panic!("conflicting --at must error"),
};
assert_eq!(err.name, "--at");
assert!(err.to_string().contains("already defined with a different parse structure"));
}
#[test]
fn distinct_names_coexist() {
let mut reg = OptionRegistry::new();
reg.define(&OptionDef::value("--at")).unwrap();
reg.define(&OptionDef::value("--dataset")).unwrap();
reg.define(&OptionDef::flag("--recursive")).unwrap();
assert_eq!(reg.len(), 3);
}
#[test]
fn audit_flags_same_name_with_different_arity_across_commands() {
let reg = OptionRegistry::new();
let observed = vec![
("datasets ping".to_string(), OptionDef::value("--at").multiple(true)),
("datasets precache".to_string(), OptionDef::value("--at").multiple(true)),
("config catalog remove".to_string(), OptionDef::value("--at")),
];
let mismatches = reg.audit(&observed);
assert_eq!(mismatches.len(), 1);
assert_eq!(mismatches[0].name, "--at");
assert_eq!(mismatches[0].occurrences.len(), 3);
}
#[test]
fn audit_passes_when_every_occurrence_parses_identically() {
let mut reg = OptionRegistry::new();
reg.define(&OptionDef::value("--at").multiple(true)).unwrap();
let observed = vec![
("datasets ping".to_string(), OptionDef::value("--at").multiple(true)),
("datasets precache".to_string(), OptionDef::value("--at").multiple(true)),
];
assert!(reg.audit(&observed).is_empty());
}
}