use std::fmt::Display;
use kdl::{KdlEntry, KdlNode};
use serde::Serialize;
use crate::error::Result;
use crate::spec::context::ParsingContext;
use crate::spec::helpers::{string_entry, NodeHelper};
use crate::spec::is_false;
#[derive(Debug, Default, Clone, Serialize)]
#[non_exhaustive]
pub struct SpecGroup {
pub name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub members: Vec<String>,
#[serde(skip_serializing_if = "is_false")]
pub required: bool,
#[serde(skip_serializing_if = "is_false")]
pub multiple: bool,
}
impl SpecGroup {
pub fn new(
name: impl Into<String>,
members: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
name: name.into(),
members: members.into_iter().map(Into::into).collect(),
required: false,
multiple: false,
}
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn multiple(mut self) -> Self {
self.multiple = true;
self
}
pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self> {
let mut group = SpecGroup::default();
let mut args = node.args();
let Some(name) = args.next() else {
bail_parse!(ctx, node.span(), "a group needs a name");
};
group.name = name.ensure_string()?;
for arg in args {
group.members.push(arg.ensure_string()?);
}
for (k, v) in node.props() {
match k {
"required" => group.required = v.ensure_bool()?,
"multiple" => group.multiple = v.ensure_bool()?,
k => bail_parse!(ctx, v.entry.span(), "unsupported group key {k}"),
}
}
for child in node.children() {
match child.name() {
"required" => group.required = child.arg(0)?.ensure_bool()?,
"multiple" => group.multiple = child.arg(0)?.ensure_bool()?,
"flag" => {
for arg in child.args() {
group.members.push(arg.ensure_string()?);
}
}
k => bail_parse!(
ctx,
child.node.name().span(),
"unsupported group value key {k}"
),
}
}
if group.name.is_empty() {
bail_parse!(ctx, node.span(), "a group needs a name");
}
if group.members.len() < 2 {
bail_parse!(
ctx,
node.span(),
"group {} needs at least two arguments; a rule about one argument belongs on it",
group.name
);
}
Ok(group)
}
pub fn usage(&self) -> String {
format!("group:{}", self.name)
}
}
impl From<&SpecGroup> for KdlNode {
fn from(group: &SpecGroup) -> KdlNode {
let mut node = KdlNode::new("group");
node.push(string_entry(None, &group.name));
for member in &group.members {
node.push(string_entry(None, member));
}
if group.required {
node.push(KdlEntry::new_prop("required", true));
}
if group.multiple {
node.push(KdlEntry::new_prop("multiple", true));
}
node
}
}
impl Display for SpecGroup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.usage())
}
}
#[cfg(test)]
mod tests {
use crate::Spec;
#[test]
fn a_group_round_trips_through_kdl() {
let spec: Spec = "flag \"--file <f>\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\" required=#true\n"
.parse()
.unwrap();
let group = &spec.cmd.groups[0];
assert_eq!(group.name, "input");
assert_eq!(
group.members,
vec!["--file".to_string(), "--url".to_string()]
);
assert!(group.required);
assert!(!group.multiple);
let reparsed: Spec = spec.to_string().parse().unwrap();
let group = &reparsed.cmd.groups[0];
assert_eq!(group.name, "input", "{spec}");
assert_eq!(group.members.len(), 2, "{spec}");
assert!(group.required, "{spec}");
}
#[test]
fn a_group_of_fewer_than_two_arguments_is_refused() {
let err = "flag \"--file <f>\"\ngroup \"input\" \"--file\"\n"
.parse::<Spec>()
.unwrap_err();
assert!(format!("{err:?}").contains("at least two"), "{err:?}");
let err = "group \"input\"\n".parse::<Spec>().unwrap_err();
assert!(format!("{err:?}").contains("at least two"), "{err:?}");
}
#[test]
fn a_mount_replacing_the_flags_replaces_the_groups_with_them() {
let mut base: Spec = "flag \"--file <f>\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\" required=#true\n"
.parse()
.unwrap();
let mounted: Spec = "flag \"--other <o>\"\n".parse().unwrap();
base.cmd.merge(mounted.cmd);
assert!(
base.cmd.groups.is_empty(),
"a group naming flags that were replaced should not survive them"
);
let mut base: Spec =
"flag \"--file <f>\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\"\n"
.parse()
.unwrap();
let helpish: Spec = "name \"other\"\n".parse().unwrap();
base.cmd.merge(helpish.cmd);
assert_eq!(base.cmd.groups.len(), 1);
}
#[test]
fn a_group_comes_across_from_clap() {
let cmd = clap::Command::new("ex")
.arg(clap::Arg::new("file").long("file"))
.arg(clap::Arg::new("url").long("url"))
.group(
clap::ArgGroup::new("input")
.args(["file", "url"])
.required(true),
);
let spec = Spec::from(&cmd);
let group = spec
.cmd
.groups
.iter()
.find(|g| g.name == "input")
.expect("the group should have come across");
assert_eq!(
group.members,
vec!["--file".to_string(), "--url".to_string()]
);
assert!(group.required);
assert!(!group.multiple);
}
#[test]
fn the_group_clap_derive_invents_for_every_struct_is_not_carried() {
let cmd = clap::Command::new("ex")
.arg(clap::Arg::new("file").long("file"))
.arg(clap::Arg::new("url").long("url"))
.group(
clap::ArgGroup::new("Ex")
.args(["file", "url"])
.multiple(true),
);
assert!(
Spec::from(&cmd).cmd.groups.is_empty(),
"a group that enforces nothing should not reach the spec"
);
let cmd = clap::Command::new("ex")
.arg(clap::Arg::new("file").long("file"))
.arg(clap::Arg::new("url").long("url"))
.group(
clap::ArgGroup::new("input")
.args(["file", "url"])
.multiple(true)
.required(true),
);
let spec = Spec::from(&cmd);
assert_eq!(spec.cmd.groups.len(), 1);
assert!(spec.cmd.groups[0].multiple && spec.cmd.groups[0].required);
}
#[test]
fn a_clap_group_names_positional_members() {
let cmd = clap::Command::new("ex")
.arg(clap::Arg::new("file").long("file"))
.arg(clap::Arg::new("url").long("url"))
.arg(clap::Arg::new("target"))
.group(clap::ArgGroup::new("input").args(["file", "url", "target"]));
let spec = Spec::from(&cmd);
let group = spec.cmd.groups.iter().find(|g| g.name == "input").unwrap();
assert_eq!(
group.members,
vec![
"--file".to_string(),
"--url".to_string(),
"target".to_string()
]
);
}
}