1use std::fmt::Display;
2
3use kdl::{KdlEntry, KdlNode};
4use serde::Serialize;
5
6use crate::error::Result;
7use crate::spec::context::ParsingContext;
8use crate::spec::helpers::{string_entry, NodeHelper};
9use crate::spec::is_false;
10
11#[derive(Debug, Default, Clone, Serialize)]
30#[non_exhaustive]
31pub struct SpecGroup {
32 pub name: String,
35 #[serde(skip_serializing_if = "Vec::is_empty")]
38 pub members: Vec<String>,
39 #[serde(skip_serializing_if = "is_false")]
41 pub required: bool,
42 #[serde(skip_serializing_if = "is_false")]
44 pub multiple: bool,
45}
46
47impl SpecGroup {
48 pub fn new(
51 name: impl Into<String>,
52 members: impl IntoIterator<Item = impl Into<String>>,
53 ) -> Self {
54 Self {
55 name: name.into(),
56 members: members.into_iter().map(Into::into).collect(),
57 required: false,
58 multiple: false,
59 }
60 }
61
62 pub fn required(mut self) -> Self {
64 self.required = true;
65 self
66 }
67
68 pub fn multiple(mut self) -> Self {
70 self.multiple = true;
71 self
72 }
73
74 pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self> {
75 let mut group = SpecGroup::default();
76 let mut args = node.args();
80 let Some(name) = args.next() else {
81 bail_parse!(ctx, node.span(), "a group needs a name");
82 };
83 group.name = name.ensure_string()?;
84 for arg in args {
85 group.members.push(arg.ensure_string()?);
86 }
87 for (k, v) in node.props() {
88 match k {
89 "required" => group.required = v.ensure_bool()?,
90 "multiple" => group.multiple = v.ensure_bool()?,
91 k => bail_parse!(ctx, v.entry.span(), "unsupported group key {k}"),
92 }
93 }
94 for child in node.children() {
95 match child.name() {
96 "required" => group.required = child.arg(0)?.ensure_bool()?,
97 "multiple" => group.multiple = child.arg(0)?.ensure_bool()?,
98 "flag" => {
101 for arg in child.args() {
102 group.members.push(arg.ensure_string()?);
103 }
104 }
105 k => bail_parse!(
106 ctx,
107 child.node.name().span(),
108 "unsupported group value key {k}"
109 ),
110 }
111 }
112 if group.name.is_empty() {
113 bail_parse!(ctx, node.span(), "a group needs a name");
114 }
115 if group.members.len() < 2 {
119 bail_parse!(
120 ctx,
121 node.span(),
122 "group {} needs at least two arguments; a rule about one argument belongs on it",
123 group.name
124 );
125 }
126 Ok(group)
127 }
128
129 pub fn usage(&self) -> String {
130 format!("group:{}", self.name)
131 }
132}
133
134impl From<&SpecGroup> for KdlNode {
135 fn from(group: &SpecGroup) -> KdlNode {
136 let mut node = KdlNode::new("group");
137 node.push(string_entry(None, &group.name));
138 for member in &group.members {
139 node.push(string_entry(None, member));
140 }
141 if group.required {
142 node.push(KdlEntry::new_prop("required", true));
143 }
144 if group.multiple {
145 node.push(KdlEntry::new_prop("multiple", true));
146 }
147 node
148 }
149}
150
151impl Display for SpecGroup {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 write!(f, "{}", self.usage())
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use crate::Spec;
160
161 #[test]
162 fn a_group_round_trips_through_kdl() {
163 let spec: Spec = "flag \"--file <f>\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\" required=#true\n"
164 .parse()
165 .unwrap();
166 let group = &spec.cmd.groups[0];
167 assert_eq!(group.name, "input");
168 assert_eq!(
169 group.members,
170 vec!["--file".to_string(), "--url".to_string()]
171 );
172 assert!(group.required);
173 assert!(!group.multiple);
174
175 let reparsed: Spec = spec.to_string().parse().unwrap();
176 let group = &reparsed.cmd.groups[0];
177 assert_eq!(group.name, "input", "{spec}");
178 assert_eq!(group.members.len(), 2, "{spec}");
179 assert!(group.required, "{spec}");
180 }
181
182 #[test]
183 fn a_group_of_fewer_than_two_arguments_is_refused() {
184 let err = "flag \"--file <f>\"\ngroup \"input\" \"--file\"\n"
192 .parse::<Spec>()
193 .unwrap_err();
194 assert!(format!("{err:?}").contains("at least two"), "{err:?}");
195
196 let err = "group \"input\"\n".parse::<Spec>().unwrap_err();
197 assert!(format!("{err:?}").contains("at least two"), "{err:?}");
198 }
199
200 #[test]
201 fn a_mount_replacing_the_flags_replaces_the_groups_with_them() {
202 let mut base: Spec = "flag \"--file <f>\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\" required=#true\n"
207 .parse()
208 .unwrap();
209 let mounted: Spec = "flag \"--other <o>\"\n".parse().unwrap();
210
211 base.cmd.merge(mounted.cmd);
212 assert!(
213 base.cmd.groups.is_empty(),
214 "a group naming flags that were replaced should not survive them"
215 );
216
217 let mut base: Spec =
220 "flag \"--file <f>\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\"\n"
221 .parse()
222 .unwrap();
223 let helpish: Spec = "name \"other\"\n".parse().unwrap();
224 base.cmd.merge(helpish.cmd);
225 assert_eq!(base.cmd.groups.len(), 1);
226 }
227
228 #[test]
229 fn a_group_comes_across_from_clap() {
230 let cmd = clap::Command::new("ex")
234 .arg(clap::Arg::new("file").long("file"))
235 .arg(clap::Arg::new("url").long("url"))
236 .group(
237 clap::ArgGroup::new("input")
238 .args(["file", "url"])
239 .required(true),
240 );
241 let spec = Spec::from(&cmd);
242 let group = spec
243 .cmd
244 .groups
245 .iter()
246 .find(|g| g.name == "input")
247 .expect("the group should have come across");
248 assert_eq!(
249 group.members,
250 vec!["--file".to_string(), "--url".to_string()]
251 );
252 assert!(group.required);
253 assert!(!group.multiple);
254 }
255
256 #[test]
257 fn the_group_clap_derive_invents_for_every_struct_is_not_carried() {
258 let cmd = clap::Command::new("ex")
267 .arg(clap::Arg::new("file").long("file"))
268 .arg(clap::Arg::new("url").long("url"))
269 .group(
270 clap::ArgGroup::new("Ex")
271 .args(["file", "url"])
272 .multiple(true),
273 );
274 assert!(
275 Spec::from(&cmd).cmd.groups.is_empty(),
276 "a group that enforces nothing should not reach the spec"
277 );
278
279 let cmd = clap::Command::new("ex")
282 .arg(clap::Arg::new("file").long("file"))
283 .arg(clap::Arg::new("url").long("url"))
284 .group(
285 clap::ArgGroup::new("input")
286 .args(["file", "url"])
287 .multiple(true)
288 .required(true),
289 );
290 let spec = Spec::from(&cmd);
291 assert_eq!(spec.cmd.groups.len(), 1);
292 assert!(spec.cmd.groups[0].multiple && spec.cmd.groups[0].required);
293 }
294
295 #[test]
296 fn a_clap_group_names_positional_members() {
297 let cmd = clap::Command::new("ex")
298 .arg(clap::Arg::new("file").long("file"))
299 .arg(clap::Arg::new("url").long("url"))
300 .arg(clap::Arg::new("target"))
301 .group(clap::ArgGroup::new("input").args(["file", "url", "target"]));
302 let spec = Spec::from(&cmd);
303 let group = spec.cmd.groups.iter().find(|g| g.name == "input").unwrap();
304 assert_eq!(
305 group.members,
306 vec![
307 "--file".to_string(),
308 "--url".to_string(),
309 "target".to_string()
310 ]
311 );
312 }
313}