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    // Same rule as `negatable`, and for the same reason: one dash or
245    // two is a fact about how the tool spells this option, so a single
246    // source that saw the single-dash spelling is enough — no other
247    // source can have seen it spelled with two and be describing the
248    // same flag.
249    let single_dash = bucket.iter().any(|f| f.single_dash);
250    let hidden = bucket.iter().all(|f| f.hidden) && !bucket.is_empty();
251    let deprecated = pick_option(
252        bucket
253            .iter()
254            .map(|f| (&f.provenance, f.deprecated.as_ref())),
255        Axis::Prose,
256    );
257    let inherited = bucket.iter().any(|f| f.inherited);
258    let group = pick_option(
259        bucket.iter().map(|f| (&f.provenance, f.group.as_ref())),
260        Axis::Structural,
261    );
262    let description = pick_option(
263        bucket
264            .iter()
265            .map(|f| (&f.provenance, f.description.as_ref())),
266        Axis::Prose,
267    );
268    let default = pick_option(
269        bucket.iter().map(|f| (&f.provenance, f.default.as_ref())),
270        Axis::Prose,
271    );
272    let env_var = pick_option(
273        bucket.iter().map(|f| (&f.provenance, f.env_var.as_ref())),
274        Axis::Structural,
275    );
276
277    let mut provenance = Provenance::default();
278    for f in &bucket {
279        provenance.absorb(&f.provenance);
280    }
281
282    Flag {
283        short,
284        long,
285        value_name,
286        value_kind,
287        choices,
288        repeatable,
289        required,
290        negatable,
291        single_dash,
292        hidden,
293        deprecated,
294        inherited,
295        group,
296        description,
297        default,
298        env_var,
299        provenance,
300    }
301}
302
303/// Merge positionals across candidate lists by name identity.
304pub fn merge_positional_lists(lists: Vec<Vec<Positional>>) -> Vec<Positional> {
305    let mut order: Vec<String> = Vec::new();
306    let mut buckets: HashMap<String, Vec<Positional>> = HashMap::new();
307    for list in lists {
308        for p in list {
309            if !buckets.contains_key(&p.name) {
310                order.push(p.name.clone());
311            }
312            buckets.entry(p.name.clone()).or_default().push(p);
313        }
314    }
315    order
316        .into_iter()
317        .map(|name| {
318            let mut bucket = buckets.remove(&name).expect("key came from this map");
319            if bucket.len() == 1 {
320                return bucket.pop().expect("len checked");
321            }
322            let required = bucket.iter().any(|p| p.required);
323            let variadic = bucket.iter().any(|p| p.variadic);
324            let description = pick_option(
325                bucket
326                    .iter()
327                    .map(|p| (&p.provenance, p.description.as_ref())),
328                Axis::Prose,
329            );
330            let mut provenance = Provenance::default();
331            for p in &bucket {
332                provenance.absorb(&p.provenance);
333            }
334            Positional {
335                name,
336                required,
337                variadic,
338                description,
339                provenance,
340            }
341        })
342        .collect()
343}
344
345/// Merge subcommand lists recursively by name (spec §4.4: "Subcommands
346/// merge recursively by name").
347pub fn merge_subcommand_lists(
348    lists: Vec<Vec<CommandNode>>,
349) -> Result<Vec<CommandNode>, MergeError> {
350    let mut order: Vec<String> = Vec::new();
351    let mut buckets: HashMap<String, Vec<CommandNode>> = HashMap::new();
352    for list in lists {
353        for c in list {
354            if !buckets.contains_key(&c.name) {
355                order.push(c.name.clone());
356            }
357            buckets.entry(c.name.clone()).or_default().push(c);
358        }
359    }
360    order
361        .into_iter()
362        .map(|name| {
363            let bucket = buckets.remove(&name).expect("key came from this map");
364            merge_nodes(bucket)
365        })
366        .collect()
367}
368
369fn merge_examples(lists: Vec<Vec<crate::node::Example>>) -> Vec<crate::node::Example> {
370    let mut seen: Vec<Text> = Vec::new();
371    let mut out = Vec::new();
372    for list in lists {
373        for ex in list {
374            if !seen.contains(&ex.command) {
375                seen.push(ex.command.clone());
376                out.push(ex);
377            }
378        }
379    }
380    out
381}
382
383/// Pick the value from whichever candidate has the highest `axis` authority
384/// among candidates that have `Some`. `None` never displaces `Some`. Ties
385/// (equal authority) keep the earliest contributor.
386fn pick_option<'a, T, I>(candidates: I, axis: Axis) -> Option<T>
387where
388    T: Clone + 'a,
389    I: IntoIterator<Item = (&'a Provenance, Option<&'a T>)>,
390{
391    let mut best: Option<(u8, &'a T)> = None;
392    for (prov, val) in candidates {
393        if let Some(v) = val {
394            let auth = prov.effective_authority(axis);
395            let replace = match &best {
396                None => true,
397                Some((best_auth, _)) => auth > *best_auth,
398            };
399            if replace {
400                best = Some((auth, v));
401            }
402        }
403    }
404    best.map(|(_, v)| v.clone())
405}
406
407/// Same as [`pick_option`] but for `Vec<T>`, treating an empty vec like
408/// `None`.
409fn pick_vec<'a, T, I>(candidates: I, axis: Axis) -> Vec<T>
410where
411    T: Clone + 'a,
412    I: IntoIterator<Item = (&'a Provenance, &'a Vec<T>)>,
413{
414    let mut best: Option<(u8, &'a Vec<T>)> = None;
415    for (prov, val) in candidates {
416        if val.is_empty() {
417            continue;
418        }
419        let auth = prov.effective_authority(axis);
420        let replace = match &best {
421            None => true,
422            Some((best_auth, _)) => auth > *best_auth,
423        };
424        if replace {
425            best = Some((auth, val));
426        }
427    }
428    best.map(|(_, v)| v.clone()).unwrap_or_default()
429}
430
431fn best_index<'a, I>(provenances: I, axis: Axis) -> usize
432where
433    I: IntoIterator<Item = &'a Provenance>,
434{
435    let mut best_i = 0usize;
436    let mut best_auth: Option<u8> = None;
437    for (i, prov) in provenances.into_iter().enumerate() {
438        let auth = prov.effective_authority(axis);
439        let replace = match best_auth {
440            None => true,
441            Some(b) => auth > b,
442        };
443        if replace {
444            best_auth = Some(auth);
445            best_i = i;
446        }
447    }
448    best_i
449}
450
451/// Unify flags that arrived as separate short/long rows from the same
452/// source, per spec §4.4: sources legitimately emit a flag's short and long
453/// forms as distinct items (e.g. `gh __complete pr -` returns `--repo` and
454/// `-R` as separate rows with identical descriptions). Within one node's
455/// flag list, items whose descriptions match exactly and whose short/long
456/// slots are complementary unify into one `Flag`. Must run before merge.
457pub fn pair_aliases(flags: Vec<Flag>) -> Vec<Flag> {
458    let mut result: Vec<Flag> = Vec::with_capacity(flags.len());
459    'outer: for flag in flags {
460        if flag.short.is_some() && flag.long.is_some() {
461            result.push(flag);
462            continue;
463        }
464        // A flag with neither spelling can't be paired meaningfully.
465        if flag.short.is_none() && flag.long.is_none() {
466            result.push(flag);
467            continue;
468        }
469        for existing in result.iter_mut() {
470            if complementary(existing, &flag) && same_description(existing, &flag) {
471                absorb_pair(existing, flag);
472                continue 'outer;
473            }
474        }
475        result.push(flag);
476    }
477    result
478}
479
480/// Whether `a` and `b` fill each other's empty spelling slot — one
481/// short-only, one long-only.
482///
483/// **A single-dash long option is never the long half of such a pair.** The
484/// alias convention this function exists for is one dash and two
485/// (`-R, --repo`); a flag whose own spelling is already a single dash
486/// (`-help`, `-CC`, `-vv`; see [`crate::Flag::single_dash`]) has no short
487/// alias to be paired with, and offering it one is how `gcc`-family tools
488/// lose flags: `lto-dump` gives hundreds of its options the description
489/// `[disabled]`, so [`same_description`] matches almost anything and the
490/// real `-CC` was absorbed into an unrelated `-Wspeculative`, claiming a
491/// spelling that tool does not have. Measured on a full `PATH` sweep: two
492/// tools, four flags, and the only losses in the sweep-diff that found it.
493fn complementary(a: &Flag, b: &Flag) -> bool {
494    if a.single_dash || b.single_dash {
495        return false;
496    }
497    (a.short.is_some() && a.long.is_none() && b.short.is_none() && b.long.is_some())
498        || (a.long.is_some() && a.short.is_none() && b.long.is_none() && b.short.is_some())
499}
500
501fn same_description(a: &Flag, b: &Flag) -> bool {
502    match (&a.description, &b.description) {
503        (Some(x), Some(y)) => x == y,
504        _ => false,
505    }
506}
507
508fn absorb_pair(existing: &mut Flag, other: Flag) {
509    if existing.short.is_none() {
510        existing.short = other.short;
511    }
512    if existing.long.is_none() {
513        existing.long = other.long;
514    }
515    existing.value_name = existing.value_name.clone().or(other.value_name);
516    if matches!(existing.value_kind, crate::node::ValueKind::None) {
517        existing.value_kind = other.value_kind;
518    }
519    existing.repeatable |= other.repeatable;
520    existing.required |= other.required;
521    existing.hidden &= other.hidden;
522    existing.inherited |= other.inherited;
523    existing.default = existing.default.clone().or(other.default);
524    existing.env_var = existing.env_var.clone().or(other.env_var);
525    existing.provenance.absorb(&other.provenance);
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::provenance::Source;
532
533    fn node_from(source: Source, name: &str) -> CommandNode {
534        CommandNode::new(name, Provenance::single(source))
535    }
536
537    #[test]
538    fn merge_single_candidate_is_identity() {
539        let mut n = node_from(Source::HelpText, "git");
540        n.summary = Some(Text::sanitize("a vcs"));
541        let merged = merge_nodes(vec![n.clone()]).unwrap();
542        assert_eq!(merged.name, "git");
543        assert_eq!(merged.summary, n.summary);
544    }
545
546    #[test]
547    fn merge_empty_is_error() {
548        assert_eq!(merge_nodes(vec![]), Err(MergeError::Empty));
549    }
550
551    #[test]
552    fn prose_prefers_known_spec_over_help_text() {
553        let mut from_carapace = node_from(
554            Source::KnownSpec {
555                provider: "carapace".to_string(),
556            },
557            "git",
558        );
559        from_carapace.description = Some(Text::sanitize("rich carapace prose"));
560
561        let mut from_help = node_from(Source::HelpText, "git");
562        from_help.description = Some(Text::sanitize("terse help text"));
563
564        let merged = merge_nodes(vec![from_help, from_carapace]).unwrap();
565        assert_eq!(merged.description.unwrap().as_str(), "rich carapace prose");
566    }
567
568    #[test]
569    fn structure_prefers_native_dynamic_over_known_spec() {
570        let mut from_native = node_from(
571            Source::NativeDynamic {
572                protocol: "cobra-dunder-complete".to_string(),
573            },
574            "git",
575        );
576        from_native.usage = vec![Text::sanitize("git [--version] [--help] <command>")];
577
578        let mut from_carapace = node_from(
579            Source::KnownSpec {
580                provider: "carapace".to_string(),
581            },
582            "git",
583        );
584        from_carapace.usage = vec![Text::sanitize("git [OPTIONS]")];
585
586        let merged = merge_nodes(vec![from_carapace, from_native]).unwrap();
587        assert_eq!(
588            merged.usage[0].as_str(),
589            "git [--version] [--help] <command>"
590        );
591    }
592
593    #[test]
594    fn none_never_displaces_some() {
595        let from_native = node_from(
596            Source::NativeDynamic {
597                protocol: "cobra-dunder-complete".to_string(),
598            },
599            "git",
600        );
601        // from_native has no description (native tiers are prose-poor).
602        let mut from_help = node_from(Source::HelpText, "git");
603        from_help.description = Some(Text::sanitize("some description"));
604
605        let merged = merge_nodes(vec![from_native, from_help]).unwrap();
606        assert_eq!(merged.description.unwrap().as_str(), "some description");
607    }
608
609    #[test]
610    fn ties_break_toward_earlier_contributor() {
611        let mut a = node_from(Source::HelpText, "git");
612        a.summary = Some(Text::sanitize("from a"));
613        let mut b = node_from(Source::HelpText, "git");
614        b.summary = Some(Text::sanitize("from b"));
615        let merged = merge_nodes(vec![a, b]).unwrap();
616        assert_eq!(merged.summary.unwrap().as_str(), "from a");
617    }
618
619    #[test]
620    fn children_filled_is_logical_or() {
621        let mut a = node_from(Source::HelpText, "git");
622        a.children_filled = false;
623        let mut b = node_from(
624            Source::KnownSpec {
625                provider: "carapace".to_string(),
626            },
627            "git",
628        );
629        b.children_filled = true;
630        let merged = merge_nodes(vec![a, b]).unwrap();
631        assert!(merged.children_filled);
632    }
633
634    #[test]
635    fn subcommands_merge_recursively_by_name() {
636        let mut a = node_from(Source::HelpText, "git");
637        let mut a_rebase = node_from(Source::HelpText, "rebase");
638        a_rebase.summary = Some(Text::sanitize("terse"));
639        a.subcommands.push(a_rebase);
640
641        let mut b = node_from(
642            Source::KnownSpec {
643                provider: "carapace".to_string(),
644            },
645            "git",
646        );
647        let mut b_rebase = node_from(
648            Source::KnownSpec {
649                provider: "carapace".to_string(),
650            },
651            "rebase",
652        );
653        b_rebase.description = Some(Text::sanitize("rich"));
654        b.subcommands.push(b_rebase);
655
656        let merged = merge_nodes(vec![a, b]).unwrap();
657        assert_eq!(merged.subcommands.len(), 1);
658        let rebase = &merged.subcommands[0];
659        assert_eq!(rebase.summary.as_ref().unwrap().as_str(), "terse");
660        assert_eq!(rebase.description.as_ref().unwrap().as_str(), "rich");
661    }
662
663    #[test]
664    fn provenance_aggregates_all_contributors() {
665        let a = node_from(Source::HelpText, "git");
666        let b = node_from(
667            Source::KnownSpec {
668                provider: "carapace".to_string(),
669            },
670            "git",
671        );
672        let merged = merge_nodes(vec![a, b]).unwrap();
673        assert_eq!(merged.provenance.sources.len(), 2);
674    }
675
676    // --- alias pairing ---
677
678    fn paired_flag(short: Option<char>, long: Option<&str>, desc: &str) -> Flag {
679        let mut f = Flag::long(
680            long.unwrap_or_default(),
681            Provenance::single(Source::NativeDynamic {
682                protocol: "cobra-dunder-complete".to_string(),
683            }),
684        );
685        f.long = long.map(|s| s.to_string());
686        f.short = short;
687        f.description = Some(Text::sanitize(desc));
688        f
689    }
690
691    #[test]
692    fn pairs_short_and_long_with_identical_description() {
693        let flags = vec![
694            paired_flag(None, Some("repo"), "Select another repository"),
695            paired_flag(Some('R'), None, "Select another repository"),
696        ];
697        let paired = pair_aliases(flags);
698        assert_eq!(paired.len(), 1);
699        assert_eq!(paired[0].short, Some('R'));
700        assert_eq!(paired[0].long.as_deref(), Some("repo"));
701    }
702
703    /// A single-dash long option has no short alias to be paired with —
704    /// its own spelling is already the single-dash one. Left pairable, the
705    /// `gcc` family loses flags outright: `lto-dump` gives hundreds of its
706    /// options the description `[disabled]`, so `same_description` matches
707    /// almost anything and the real `-CC` was absorbed into an unrelated
708    /// `-Wspeculative`, which then claimed a spelling that tool does not
709    /// have. Found by `sweep-diff` on a full `PATH` sweep — two tools, four
710    /// flags, and the only losses in it.
711    #[test]
712    fn does_not_pair_a_single_dash_long_option_with_any_short_flag() {
713        let mut cc = paired_flag(None, Some("CC"), "[disabled]");
714        cc.single_dash = true;
715        let flags = vec![paired_flag(Some('W'), None, "[disabled]"), cc];
716        let paired = pair_aliases(flags);
717        assert_eq!(
718            paired.len(),
719            2,
720            "-CC must stay its own flag: {:?}",
721            paired.iter().map(|f| f.spelling()).collect::<Vec<_>>()
722        );
723        // ...and the identical pair *does* unify when the long half is a
724        // real `--` option, confirming `single_dash` is what rejected it.
725        let flags = vec![
726            paired_flag(Some('W'), None, "[disabled]"),
727            paired_flag(None, Some("CC"), "[disabled]"),
728        ];
729        assert_eq!(pair_aliases(flags).len(), 1);
730    }
731
732    #[test]
733    fn does_not_pair_different_descriptions() {
734        let flags = vec![
735            paired_flag(None, Some("repo"), "Select another repository"),
736            paired_flag(Some('R'), None, "Something totally different"),
737        ];
738        let paired = pair_aliases(flags);
739        assert_eq!(paired.len(), 2);
740    }
741
742    #[test]
743    fn does_not_pair_two_long_only_flags() {
744        let flags = vec![
745            paired_flag(None, Some("repo"), "same"),
746            paired_flag(None, Some("remote"), "same"),
747        ];
748        let paired = pair_aliases(flags);
749        assert_eq!(paired.len(), 2);
750    }
751
752    #[test]
753    fn pairing_is_idempotent() {
754        let flags = vec![
755            paired_flag(None, Some("repo"), "Select another repository"),
756            paired_flag(Some('R'), None, "Select another repository"),
757        ];
758        let once = pair_aliases(flags);
759        let twice = pair_aliases(once.clone());
760        assert_eq!(once, twice);
761    }
762
763    #[test]
764    fn merge_unifies_flags_by_identity_across_sources() {
765        let mut a = node_from(Source::HelpText, "git");
766        let mut fa = Flag::long("interactive", Provenance::single(Source::HelpText));
767        fa.short = Some('i');
768        fa.description = Some(Text::sanitize("terse"));
769        a.flags.push(fa);
770
771        let mut b = node_from(
772            Source::KnownSpec {
773                provider: "carapace".to_string(),
774            },
775            "git",
776        );
777        let mut fb = Flag::long(
778            "interactive",
779            Provenance::single(Source::KnownSpec {
780                provider: "carapace".to_string(),
781            }),
782        );
783        fb.short = Some('i');
784        fb.description = Some(Text::sanitize("rich"));
785        b.flags.push(fb);
786
787        let merged = merge_nodes(vec![a, b]).unwrap();
788        assert_eq!(merged.flags.len(), 1);
789        assert_eq!(
790            merged.flags[0].description.as_ref().unwrap().as_str(),
791            "rich"
792        );
793        assert_eq!(merged.flags[0].short, Some('i'));
794    }
795}