Skip to main content

mandible_core/
merge.rs

1//! Two-axis merge (spec §4.4) and alias pairing (spec §4.4, "Flags unify by
2//! alias pairing").
3//!
4//! Merge combines several extraction results for *the same logical node*
5//! (one [`CommandNode`] per contributing tier) into one. Each field is
6//! resolved against whichever axis governs it — [`Axis::Structural`] for
7//! names/nesting/arity/existence, [`Axis::Prose`] for descriptions/
8//! summaries/examples — using the highest-authority contributing source on
9//! that axis. `None`/empty never displaces a value. Ties break toward the
10//! earlier contributor. This is deliberately *not* "first tier in attempt
11//! order wins": attempt order is a cost ordering (spec §7); conflict
12//! resolution is authority (spec §4.4).
13
14use crate::node::{CommandNode, Flag, Positional};
15use crate::provenance::{Axis, Provenance};
16use crate::text::Text;
17use std::collections::HashMap;
18use thiserror::Error;
19
20/// Errors merge can return.
21#[derive(Debug, Error, PartialEq, Eq)]
22pub enum MergeError {
23    /// `merge_nodes` was called with no candidates.
24    #[error("cannot merge zero candidate nodes")]
25    Empty,
26}
27
28/// Merge several same-node extraction results into one, per spec §4.4.
29///
30/// `candidates` should all represent the same logical command (same name /
31/// same position in the tree), each typically produced by a single
32/// [`ExtractionTier`](../mandible_extract/trait.ExtractionTier.html) and
33/// carrying that tier's `Source` in its own `provenance`. Order matters only
34/// for tie-breaking (earlier wins).
35pub fn merge_nodes(mut candidates: Vec<CommandNode>) -> Result<CommandNode, MergeError> {
36    if candidates.is_empty() {
37        return Err(MergeError::Empty);
38    }
39    if candidates.len() == 1 {
40        let mut only = candidates.pop().expect("len checked above");
41        only.flags = pair_aliases(only.flags);
42        return Ok(only);
43    }
44
45    // Alias-pair each candidate's own flags before it participates in
46    // cross-source merge (spec §4.4: "Pairing runs before merge").
47    for c in &mut candidates {
48        let flags = std::mem::take(&mut c.flags);
49        c.flags = pair_aliases(flags);
50    }
51
52    let name = pick_option(
53        candidates.iter().map(|c| {
54            (
55                &c.provenance,
56                if c.name.is_empty() {
57                    None
58                } else {
59                    Some(&c.name)
60                },
61            )
62        }),
63        Axis::Structural,
64    )
65    .unwrap_or_else(|| candidates[0].name.clone());
66
67    let mut aliases: Vec<String> = Vec::new();
68    for c in &candidates {
69        for a in &c.aliases {
70            if !aliases.contains(a) {
71                aliases.push(a.clone());
72            }
73        }
74    }
75
76    let summary = pick_option(
77        candidates
78            .iter()
79            .map(|c| (&c.provenance, c.summary.as_ref())),
80        Axis::Prose,
81    );
82    let description = pick_option(
83        candidates
84            .iter()
85            .map(|c| (&c.provenance, c.description.as_ref())),
86        Axis::Prose,
87    );
88    let usage = pick_vec(
89        candidates.iter().map(|c| (&c.provenance, &c.usage)),
90        Axis::Structural,
91    );
92    let deprecated = pick_option(
93        candidates
94            .iter()
95            .map(|c| (&c.provenance, c.deprecated.as_ref())),
96        Axis::Prose,
97    );
98    let group = pick_option(
99        candidates.iter().map(|c| (&c.provenance, c.group.as_ref())),
100        Axis::Structural,
101    );
102    // `unparsed` (spec §7 Tier B step 3 / batch 6 part 4) is honesty
103    // metadata about *how little* a source understood, not structure to
104    // merge piecewise — picking the highest-structural-authority
105    // non-empty contributor (like `usage`) means a candidate that actually
106    // parsed real structure always wins over one that gave up, since a
107    // node only ever carries `unparsed` when it has nothing else.
108    let unparsed = pick_vec(
109        candidates.iter().map(|c| (&c.provenance, &c.unparsed)),
110        Axis::Structural,
111    );
112    let detected_framework = pick_option(
113        candidates
114            .iter()
115            .map(|c| (&c.provenance, c.detected_framework.as_ref())),
116        Axis::Structural,
117    );
118    // Same authority reasoning as `detected_framework` immediately above:
119    // this is a fact about how a contributor's *own* text was obtained
120    // (spec §6 rule 2b), never something to merge piecewise across
121    // sources. Only `HelpTextTier` ever sets this, so in practice there is
122    // rarely more than one non-`None` candidate to pick between at all.
123    let confession = pick_option(
124        candidates
125            .iter()
126            .map(|c| (&c.provenance, c.confession.as_ref())),
127        Axis::Structural,
128    );
129
130    let structural_winner_idx =
131        best_index(candidates.iter().map(|c| &c.provenance), Axis::Structural);
132    let hidden = candidates[structural_winner_idx].hidden;
133    let children_filled = candidates.iter().any(|c| c.children_filled);
134    // Same "any contributor is enough" reasoning as `children_filled`:
135    // this is positive evidence the node names a real command (spec
136    // §13.1's structure-sanity check), and a merge can only ever add
137    // evidence, never take it away — if one source recovered this node
138    // from a recognized command heading, that fact doesn't stop being
139    // true just because another, lower-authority source also contributed
140    // a field.
141    let heading_attested = candidates.iter().any(|c| c.heading_attested);
142
143    let mut provenance = Provenance::default();
144    for c in &candidates {
145        provenance.absorb(&c.provenance);
146    }
147
148    let flags = merge_flag_lists(candidates.iter().map(|c| c.flags.clone()).collect());
149    let positionals =
150        merge_positional_lists(candidates.iter().map(|c| c.positionals.clone()).collect());
151    let subcommands =
152        merge_subcommand_lists(candidates.iter().map(|c| c.subcommands.clone()).collect())?;
153    let examples = merge_examples(candidates.iter().map(|c| c.examples.clone()).collect());
154
155    Ok(CommandNode {
156        name,
157        aliases,
158        summary,
159        description,
160        usage,
161        flags,
162        positionals,
163        subcommands,
164        examples,
165        hidden,
166        deprecated,
167        children_filled,
168        group,
169        unparsed,
170        detected_framework,
171        provenance,
172        heading_attested,
173        confession,
174    })
175}
176
177/// Merge flags by identity (long name, else short letter, else
178/// description-as-fallback) across several already alias-paired flag lists,
179/// applying the same two-axis authority resolution per field.
180pub fn merge_flag_lists(lists: Vec<Vec<Flag>>) -> Vec<Flag> {
181    let mut order: Vec<String> = Vec::new();
182    let mut buckets: HashMap<String, Vec<Flag>> = HashMap::new();
183    for list in lists {
184        for flag in list {
185            let key = flag_identity(&flag);
186            if !buckets.contains_key(&key) {
187                order.push(key.clone());
188            }
189            buckets.entry(key).or_default().push(flag);
190        }
191    }
192    order
193        .into_iter()
194        .map(|key| {
195            let bucket = buckets.remove(&key).expect("key came from this map");
196            merge_flag_bucket(bucket)
197        })
198        .collect()
199}
200
201fn flag_identity(f: &Flag) -> String {
202    match (&f.long, f.short) {
203        (Some(l), _) => format!("L:{l}"),
204        (None, Some(s)) => format!("S:{s}"),
205        (None, None) => format!(
206            "D:{}",
207            f.description.as_ref().map(|d| d.as_str()).unwrap_or("")
208        ),
209    }
210}
211
212fn merge_flag_bucket(mut bucket: Vec<Flag>) -> Flag {
213    if bucket.len() == 1 {
214        return bucket.pop().expect("len checked");
215    }
216
217    let short = bucket.iter().find_map(|f| f.short);
218    let long = pick_option(
219        bucket.iter().map(|f| (&f.provenance, f.long.as_ref())),
220        Axis::Structural,
221    );
222    let value_name = pick_option(
223        bucket
224            .iter()
225            .map(|f| (&f.provenance, f.value_name.as_ref())),
226        Axis::Structural,
227    );
228    let value_kind = bucket
229        .iter()
230        .map(|f| f.value_kind)
231        .max_by_key(|k| match k {
232            crate::node::ValueKind::None => 0,
233            crate::node::ValueKind::Optional => 1,
234            crate::node::ValueKind::Required => 2,
235        })
236        .unwrap_or(crate::node::ValueKind::None);
237    let choices = pick_vec(
238        bucket.iter().map(|f| (&f.provenance, &f.choices)),
239        Axis::Prose,
240    );
241    let repeatable = bucket.iter().any(|f| f.repeatable);
242    let required = bucket.iter().any(|f| f.required);
243    let negatable = bucket.iter().any(|f| f.negatable);
244    let hidden = bucket.iter().all(|f| f.hidden) && !bucket.is_empty();
245    let deprecated = pick_option(
246        bucket
247            .iter()
248            .map(|f| (&f.provenance, f.deprecated.as_ref())),
249        Axis::Prose,
250    );
251    let inherited = bucket.iter().any(|f| f.inherited);
252    let group = pick_option(
253        bucket.iter().map(|f| (&f.provenance, f.group.as_ref())),
254        Axis::Structural,
255    );
256    let description = pick_option(
257        bucket
258            .iter()
259            .map(|f| (&f.provenance, f.description.as_ref())),
260        Axis::Prose,
261    );
262    let default = pick_option(
263        bucket.iter().map(|f| (&f.provenance, f.default.as_ref())),
264        Axis::Prose,
265    );
266    let env_var = pick_option(
267        bucket.iter().map(|f| (&f.provenance, f.env_var.as_ref())),
268        Axis::Structural,
269    );
270
271    let mut provenance = Provenance::default();
272    for f in &bucket {
273        provenance.absorb(&f.provenance);
274    }
275
276    Flag {
277        short,
278        long,
279        value_name,
280        value_kind,
281        choices,
282        repeatable,
283        required,
284        negatable,
285        hidden,
286        deprecated,
287        inherited,
288        group,
289        description,
290        default,
291        env_var,
292        provenance,
293    }
294}
295
296/// Merge positionals across candidate lists by name identity.
297pub fn merge_positional_lists(lists: Vec<Vec<Positional>>) -> Vec<Positional> {
298    let mut order: Vec<String> = Vec::new();
299    let mut buckets: HashMap<String, Vec<Positional>> = HashMap::new();
300    for list in lists {
301        for p in list {
302            if !buckets.contains_key(&p.name) {
303                order.push(p.name.clone());
304            }
305            buckets.entry(p.name.clone()).or_default().push(p);
306        }
307    }
308    order
309        .into_iter()
310        .map(|name| {
311            let mut bucket = buckets.remove(&name).expect("key came from this map");
312            if bucket.len() == 1 {
313                return bucket.pop().expect("len checked");
314            }
315            let required = bucket.iter().any(|p| p.required);
316            let variadic = bucket.iter().any(|p| p.variadic);
317            let description = pick_option(
318                bucket
319                    .iter()
320                    .map(|p| (&p.provenance, p.description.as_ref())),
321                Axis::Prose,
322            );
323            let mut provenance = Provenance::default();
324            for p in &bucket {
325                provenance.absorb(&p.provenance);
326            }
327            Positional {
328                name,
329                required,
330                variadic,
331                description,
332                provenance,
333            }
334        })
335        .collect()
336}
337
338/// Merge subcommand lists recursively by name (spec §4.4: "Subcommands
339/// merge recursively by name").
340pub fn merge_subcommand_lists(
341    lists: Vec<Vec<CommandNode>>,
342) -> Result<Vec<CommandNode>, MergeError> {
343    let mut order: Vec<String> = Vec::new();
344    let mut buckets: HashMap<String, Vec<CommandNode>> = HashMap::new();
345    for list in lists {
346        for c in list {
347            if !buckets.contains_key(&c.name) {
348                order.push(c.name.clone());
349            }
350            buckets.entry(c.name.clone()).or_default().push(c);
351        }
352    }
353    order
354        .into_iter()
355        .map(|name| {
356            let bucket = buckets.remove(&name).expect("key came from this map");
357            merge_nodes(bucket)
358        })
359        .collect()
360}
361
362fn merge_examples(lists: Vec<Vec<crate::node::Example>>) -> Vec<crate::node::Example> {
363    let mut seen: Vec<Text> = Vec::new();
364    let mut out = Vec::new();
365    for list in lists {
366        for ex in list {
367            if !seen.contains(&ex.command) {
368                seen.push(ex.command.clone());
369                out.push(ex);
370            }
371        }
372    }
373    out
374}
375
376/// Pick the value from whichever candidate has the highest `axis` authority
377/// among candidates that have `Some`. `None` never displaces `Some`. Ties
378/// (equal authority) keep the earliest contributor.
379fn pick_option<'a, T, I>(candidates: I, axis: Axis) -> Option<T>
380where
381    T: Clone + 'a,
382    I: IntoIterator<Item = (&'a Provenance, Option<&'a T>)>,
383{
384    let mut best: Option<(u8, &'a T)> = None;
385    for (prov, val) in candidates {
386        if let Some(v) = val {
387            let auth = prov.effective_authority(axis);
388            let replace = match &best {
389                None => true,
390                Some((best_auth, _)) => auth > *best_auth,
391            };
392            if replace {
393                best = Some((auth, v));
394            }
395        }
396    }
397    best.map(|(_, v)| v.clone())
398}
399
400/// Same as [`pick_option`] but for `Vec<T>`, treating an empty vec like
401/// `None`.
402fn pick_vec<'a, T, I>(candidates: I, axis: Axis) -> Vec<T>
403where
404    T: Clone + 'a,
405    I: IntoIterator<Item = (&'a Provenance, &'a Vec<T>)>,
406{
407    let mut best: Option<(u8, &'a Vec<T>)> = None;
408    for (prov, val) in candidates {
409        if val.is_empty() {
410            continue;
411        }
412        let auth = prov.effective_authority(axis);
413        let replace = match &best {
414            None => true,
415            Some((best_auth, _)) => auth > *best_auth,
416        };
417        if replace {
418            best = Some((auth, val));
419        }
420    }
421    best.map(|(_, v)| v.clone()).unwrap_or_default()
422}
423
424fn best_index<'a, I>(provenances: I, axis: Axis) -> usize
425where
426    I: IntoIterator<Item = &'a Provenance>,
427{
428    let mut best_i = 0usize;
429    let mut best_auth: Option<u8> = None;
430    for (i, prov) in provenances.into_iter().enumerate() {
431        let auth = prov.effective_authority(axis);
432        let replace = match best_auth {
433            None => true,
434            Some(b) => auth > b,
435        };
436        if replace {
437            best_auth = Some(auth);
438            best_i = i;
439        }
440    }
441    best_i
442}
443
444/// Unify flags that arrived as separate short/long rows from the same
445/// source, per spec §4.4: sources legitimately emit a flag's short and long
446/// forms as distinct items (e.g. `gh __complete pr -` returns `--repo` and
447/// `-R` as separate rows with identical descriptions). Within one node's
448/// flag list, items whose descriptions match exactly and whose short/long
449/// slots are complementary unify into one `Flag`. Must run before merge.
450pub fn pair_aliases(flags: Vec<Flag>) -> Vec<Flag> {
451    let mut result: Vec<Flag> = Vec::with_capacity(flags.len());
452    'outer: for flag in flags {
453        if flag.short.is_some() && flag.long.is_some() {
454            result.push(flag);
455            continue;
456        }
457        // A flag with neither spelling can't be paired meaningfully.
458        if flag.short.is_none() && flag.long.is_none() {
459            result.push(flag);
460            continue;
461        }
462        for existing in result.iter_mut() {
463            if complementary(existing, &flag) && same_description(existing, &flag) {
464                absorb_pair(existing, flag);
465                continue 'outer;
466            }
467        }
468        result.push(flag);
469    }
470    result
471}
472
473fn complementary(a: &Flag, b: &Flag) -> bool {
474    (a.short.is_some() && a.long.is_none() && b.short.is_none() && b.long.is_some())
475        || (a.long.is_some() && a.short.is_none() && b.long.is_none() && b.short.is_some())
476}
477
478fn same_description(a: &Flag, b: &Flag) -> bool {
479    match (&a.description, &b.description) {
480        (Some(x), Some(y)) => x == y,
481        _ => false,
482    }
483}
484
485fn absorb_pair(existing: &mut Flag, other: Flag) {
486    if existing.short.is_none() {
487        existing.short = other.short;
488    }
489    if existing.long.is_none() {
490        existing.long = other.long;
491    }
492    existing.value_name = existing.value_name.clone().or(other.value_name);
493    if matches!(existing.value_kind, crate::node::ValueKind::None) {
494        existing.value_kind = other.value_kind;
495    }
496    existing.repeatable |= other.repeatable;
497    existing.required |= other.required;
498    existing.hidden &= other.hidden;
499    existing.inherited |= other.inherited;
500    existing.default = existing.default.clone().or(other.default);
501    existing.env_var = existing.env_var.clone().or(other.env_var);
502    existing.provenance.absorb(&other.provenance);
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::provenance::Source;
509
510    fn node_from(source: Source, name: &str) -> CommandNode {
511        CommandNode::new(name, Provenance::single(source))
512    }
513
514    #[test]
515    fn merge_single_candidate_is_identity() {
516        let mut n = node_from(Source::HelpText, "git");
517        n.summary = Some(Text::sanitize("a vcs"));
518        let merged = merge_nodes(vec![n.clone()]).unwrap();
519        assert_eq!(merged.name, "git");
520        assert_eq!(merged.summary, n.summary);
521    }
522
523    #[test]
524    fn merge_empty_is_error() {
525        assert_eq!(merge_nodes(vec![]), Err(MergeError::Empty));
526    }
527
528    #[test]
529    fn prose_prefers_known_spec_over_help_text() {
530        let mut from_carapace = node_from(
531            Source::KnownSpec {
532                provider: "carapace".to_string(),
533            },
534            "git",
535        );
536        from_carapace.description = Some(Text::sanitize("rich carapace prose"));
537
538        let mut from_help = node_from(Source::HelpText, "git");
539        from_help.description = Some(Text::sanitize("terse help text"));
540
541        let merged = merge_nodes(vec![from_help, from_carapace]).unwrap();
542        assert_eq!(merged.description.unwrap().as_str(), "rich carapace prose");
543    }
544
545    #[test]
546    fn structure_prefers_native_dynamic_over_known_spec() {
547        let mut from_native = node_from(
548            Source::NativeDynamic {
549                protocol: "cobra-dunder-complete".to_string(),
550            },
551            "git",
552        );
553        from_native.usage = vec![Text::sanitize("git [--version] [--help] <command>")];
554
555        let mut from_carapace = node_from(
556            Source::KnownSpec {
557                provider: "carapace".to_string(),
558            },
559            "git",
560        );
561        from_carapace.usage = vec![Text::sanitize("git [OPTIONS]")];
562
563        let merged = merge_nodes(vec![from_carapace, from_native]).unwrap();
564        assert_eq!(
565            merged.usage[0].as_str(),
566            "git [--version] [--help] <command>"
567        );
568    }
569
570    #[test]
571    fn none_never_displaces_some() {
572        let from_native = node_from(
573            Source::NativeDynamic {
574                protocol: "cobra-dunder-complete".to_string(),
575            },
576            "git",
577        );
578        // from_native has no description (native tiers are prose-poor).
579        let mut from_help = node_from(Source::HelpText, "git");
580        from_help.description = Some(Text::sanitize("some description"));
581
582        let merged = merge_nodes(vec![from_native, from_help]).unwrap();
583        assert_eq!(merged.description.unwrap().as_str(), "some description");
584    }
585
586    #[test]
587    fn ties_break_toward_earlier_contributor() {
588        let mut a = node_from(Source::HelpText, "git");
589        a.summary = Some(Text::sanitize("from a"));
590        let mut b = node_from(Source::HelpText, "git");
591        b.summary = Some(Text::sanitize("from b"));
592        let merged = merge_nodes(vec![a, b]).unwrap();
593        assert_eq!(merged.summary.unwrap().as_str(), "from a");
594    }
595
596    #[test]
597    fn children_filled_is_logical_or() {
598        let mut a = node_from(Source::HelpText, "git");
599        a.children_filled = false;
600        let mut b = node_from(
601            Source::KnownSpec {
602                provider: "carapace".to_string(),
603            },
604            "git",
605        );
606        b.children_filled = true;
607        let merged = merge_nodes(vec![a, b]).unwrap();
608        assert!(merged.children_filled);
609    }
610
611    #[test]
612    fn subcommands_merge_recursively_by_name() {
613        let mut a = node_from(Source::HelpText, "git");
614        let mut a_rebase = node_from(Source::HelpText, "rebase");
615        a_rebase.summary = Some(Text::sanitize("terse"));
616        a.subcommands.push(a_rebase);
617
618        let mut b = node_from(
619            Source::KnownSpec {
620                provider: "carapace".to_string(),
621            },
622            "git",
623        );
624        let mut b_rebase = node_from(
625            Source::KnownSpec {
626                provider: "carapace".to_string(),
627            },
628            "rebase",
629        );
630        b_rebase.description = Some(Text::sanitize("rich"));
631        b.subcommands.push(b_rebase);
632
633        let merged = merge_nodes(vec![a, b]).unwrap();
634        assert_eq!(merged.subcommands.len(), 1);
635        let rebase = &merged.subcommands[0];
636        assert_eq!(rebase.summary.as_ref().unwrap().as_str(), "terse");
637        assert_eq!(rebase.description.as_ref().unwrap().as_str(), "rich");
638    }
639
640    #[test]
641    fn provenance_aggregates_all_contributors() {
642        let a = node_from(Source::HelpText, "git");
643        let b = node_from(
644            Source::KnownSpec {
645                provider: "carapace".to_string(),
646            },
647            "git",
648        );
649        let merged = merge_nodes(vec![a, b]).unwrap();
650        assert_eq!(merged.provenance.sources.len(), 2);
651    }
652
653    // --- alias pairing ---
654
655    fn paired_flag(short: Option<char>, long: Option<&str>, desc: &str) -> Flag {
656        let mut f = Flag::long(
657            long.unwrap_or_default(),
658            Provenance::single(Source::NativeDynamic {
659                protocol: "cobra-dunder-complete".to_string(),
660            }),
661        );
662        f.long = long.map(|s| s.to_string());
663        f.short = short;
664        f.description = Some(Text::sanitize(desc));
665        f
666    }
667
668    #[test]
669    fn pairs_short_and_long_with_identical_description() {
670        let flags = vec![
671            paired_flag(None, Some("repo"), "Select another repository"),
672            paired_flag(Some('R'), None, "Select another repository"),
673        ];
674        let paired = pair_aliases(flags);
675        assert_eq!(paired.len(), 1);
676        assert_eq!(paired[0].short, Some('R'));
677        assert_eq!(paired[0].long.as_deref(), Some("repo"));
678    }
679
680    #[test]
681    fn does_not_pair_different_descriptions() {
682        let flags = vec![
683            paired_flag(None, Some("repo"), "Select another repository"),
684            paired_flag(Some('R'), None, "Something totally different"),
685        ];
686        let paired = pair_aliases(flags);
687        assert_eq!(paired.len(), 2);
688    }
689
690    #[test]
691    fn does_not_pair_two_long_only_flags() {
692        let flags = vec![
693            paired_flag(None, Some("repo"), "same"),
694            paired_flag(None, Some("remote"), "same"),
695        ];
696        let paired = pair_aliases(flags);
697        assert_eq!(paired.len(), 2);
698    }
699
700    #[test]
701    fn pairing_is_idempotent() {
702        let flags = vec![
703            paired_flag(None, Some("repo"), "Select another repository"),
704            paired_flag(Some('R'), None, "Select another repository"),
705        ];
706        let once = pair_aliases(flags);
707        let twice = pair_aliases(once.clone());
708        assert_eq!(once, twice);
709    }
710
711    #[test]
712    fn merge_unifies_flags_by_identity_across_sources() {
713        let mut a = node_from(Source::HelpText, "git");
714        let mut fa = Flag::long("interactive", Provenance::single(Source::HelpText));
715        fa.short = Some('i');
716        fa.description = Some(Text::sanitize("terse"));
717        a.flags.push(fa);
718
719        let mut b = node_from(
720            Source::KnownSpec {
721                provider: "carapace".to_string(),
722            },
723            "git",
724        );
725        let mut fb = Flag::long(
726            "interactive",
727            Provenance::single(Source::KnownSpec {
728                provider: "carapace".to_string(),
729            }),
730        );
731        fb.short = Some('i');
732        fb.description = Some(Text::sanitize("rich"));
733        b.flags.push(fb);
734
735        let merged = merge_nodes(vec![a, b]).unwrap();
736        assert_eq!(merged.flags.len(), 1);
737        assert_eq!(
738            merged.flags[0].description.as_ref().unwrap().as_str(),
739            "rich"
740        );
741        assert_eq!(merged.flags[0].short, Some('i'));
742    }
743}