Skip to main content

usage/spec/
group.rs

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/// A set of flags that relate to one another as a set.
12///
13/// Everything here could be written as pairwise [`conflicts`](crate::SpecFlag::conflicts)
14/// — for three members, three declarations, and for six, fifteen — except for the part
15/// that cannot: "one of these is required" is a statement about the set, and no rule
16/// written on an individual flag says it.
17///
18/// The two properties are clap's, and are read the same way:
19///
20/// - `multiple` (default `false`) — whether more than one member may be given. The
21///   default is what makes a bare group mutual exclusion.
22/// - `required` (default `false`) — whether at least one member must be given.
23///
24/// So the default group is "at most one of these", `required` alone is "exactly one of
25/// these", and `multiple` with `required` is "at least one of these".
26///
27/// Members use the same selectors as other relationships: `--long` or `-s` for a flag,
28/// and the bare argument name for a positional.
29#[derive(Debug, Default, Clone, Serialize)]
30#[non_exhaustive]
31pub struct SpecGroup {
32    /// What this group is called. Used in messages, and it is how a reader tells two
33    /// groups apart when a command has several.
34    pub name: String,
35    /// The arguments in the group, as selectors. Flags use dashed spellings;
36    /// positionals use their bare names.
37    #[serde(skip_serializing_if = "Vec::is_empty")]
38    pub members: Vec<String>,
39    /// Whether at least one member has to be given.
40    #[serde(skip_serializing_if = "is_false")]
41    pub required: bool,
42    /// Whether more than one member may be given.
43    #[serde(skip_serializing_if = "is_false")]
44    pub multiple: bool,
45}
46
47impl SpecGroup {
48    /// A group named `name` holding `members`, exclusive and not required — the
49    /// defaults clap uses.
50    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    /// The same, but at least one member must be given.
63    pub fn required(mut self) -> Self {
64        self.required = true;
65        self
66    }
67
68    /// The same, but more than one member may be given.
69    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        // The name first, then the members: `group "input" "--file" "--url"`. One node
77        // rather than a node and a child list, because a group is short by nature — a
78        // set large enough to be unreadable on one line is a set nobody wants to be in.
79        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                // The child spelling for members, for a group whose selectors do not fit
99                // comfortably on the node itself.
100                "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        // A group of one is an argument, and a group of none is nothing at all. Both are
116        // almost certainly a mistake in the writing rather than an intention, and
117        // neither can be enforced into meaning anything.
118        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        // A group of one is a rule about an argument, which belongs on that argument; a group of
185        // none is nothing at all. Both are a slip in the writing rather than a shape
186        // anyone means, and neither enforces anything, so they are refused where they
187        // are written rather than silently doing nothing at run time.
188        // The message is on the diagnostic's label rather than in `Display`, which
189        // renders every parse failure as "Invalid usage config" — so it is read the way
190        // the rest of the spec tests read one.
191        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        // A mounted spec's root flags *replace* the flags of the command the mount sits
203        // on. Groups name flags, so they have to go with them: keeping the old set would
204        // enforce exclusivity between flags that are no longer here, and a required group
205        // whose members nothing answers to would reject every invocation.
206        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        // A merge that brings no flags leaves the groups alone, which is what makes this
218        // about *replacement* rather than about merging at all.
219        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        // Unlike `requires`, this one the bridge can read: `Command::get_groups` and
231        // `ArgGroup::get_args` are public, so a clap CLI's groups reach the spec — and
232        // every spec generated from a clap command was losing them before this.
233        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        // `clap_derive` builds `ArgGroup::new(<struct name>).multiple(true)` for every
259        // `#[derive(Args)]` type, holding all of its fields, so that `flatten` works.
260        // It states no rule — any number of members, none of them needed — and carrying
261        // it would put a `group Lint …` in the spec of every clap-derived CLI, this
262        // repository's own included, describing bookkeeping rather than a declaration.
263        //
264        // The test is written against the *shape* rather than against the derive, since
265        // it is the shape that means nothing: `multiple` without `required`.
266        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        // `multiple` *with* `required` does say something — at least one of these — so
280        // that one is carried.
281        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}