1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
use std::fmt::Display;
use crate::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;
/// A set of flags that relate to one another as a set.
///
/// Everything here could be written as pairwise [`conflicts`](crate::SpecFlag::conflicts)
/// — for three members, three declarations, and for six, fifteen — except for the part
/// that cannot: "one of these is required" is a statement about the set, and no rule
/// written on an individual flag says it.
///
/// The two properties are clap's, and are read the same way:
///
/// - `multiple` (default `false`) — whether more than one member may be given. The
/// default is what makes a bare group mutual exclusion.
/// - `required` (default `false`) — whether at least one member must be given.
///
/// So the default group is "at most one of these", `required` alone is "exactly one of
/// these", and `multiple` with `required` is "at least one of these".
///
/// Members use the same selectors as other relationships: `--long` or `-s` for a flag,
/// and the bare argument name for a positional.
#[derive(Debug, Default, Clone, Serialize)]
#[non_exhaustive]
pub struct SpecGroup {
/// What this group is called. Used in messages, and it is how a reader tells two
/// groups apart when a command has several.
pub name: String,
/// The arguments in the group, as selectors. Flags use dashed spellings;
/// positionals use their bare names.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub members: Vec<String>,
/// Whether at least one member has to be given.
#[serde(skip_serializing_if = "is_false")]
pub required: bool,
/// Whether more than one member may be given.
#[serde(skip_serializing_if = "is_false")]
pub multiple: bool,
}
impl SpecGroup {
/// A group named `name` holding `members`, exclusive and not required — the
/// defaults clap uses.
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,
}
}
/// The same, but at least one member must be given.
pub fn required(mut self) -> Self {
self.required = true;
self
}
/// The same, but more than one member may be given.
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();
// The name first, then the members: `group "input" "--file" "--url"`. One node
// rather than a node and a child list, because a group is short by nature — a
// set large enough to be unreadable on one line is a set nobody wants to be in.
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()?,
// The child spelling for members, for a group whose selectors do not fit
// comfortably on the node itself.
"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");
}
// A group of one is an argument, and a group of none is nothing at all. Both are
// almost certainly a mistake in the writing rather than an intention, and
// neither can be enforced into meaning anything.
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() {
// A group of one is a rule about an argument, which belongs on that argument; a group of
// none is nothing at all. Both are a slip in the writing rather than a shape
// anyone means, and neither enforces anything, so they are refused where they
// are written rather than silently doing nothing at run time.
// The message is on the diagnostic's label rather than in `Display`, which
// renders every parse failure as "Invalid usage config" — so it is read the way
// the rest of the spec tests read one.
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() {
// A mounted spec's root flags *replace* the flags of the command the mount sits
// on. Groups name flags, so they have to go with them: keeping the old set would
// enforce exclusivity between flags that are no longer here, and a required group
// whose members nothing answers to would reject every invocation.
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"
);
// A merge that brings no flags leaves the groups alone, which is what makes this
// about *replacement* rather than about merging at all.
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() {
// Unlike `requires`, this one the bridge can read: `Command::get_groups` and
// `ArgGroup::get_args` are public, so a clap CLI's groups reach the spec — and
// every spec generated from a clap command was losing them before this.
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() {
// `clap_derive` builds `ArgGroup::new(<struct name>).multiple(true)` for every
// `#[derive(Args)]` type, holding all of its fields, so that `flatten` works.
// It states no rule — any number of members, none of them needed — and carrying
// it would put a `group Lint …` in the spec of every clap-derived CLI, this
// repository's own included, describing bookkeeping rather than a declaration.
//
// The test is written against the *shape* rather than against the derive, since
// it is the shape that means nothing: `multiple` without `required`.
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"
);
// `multiple` *with* `required` does say something — at least one of these — so
// that one is 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("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()
]
);
}
}