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