1use crate::node::{CommandNode, Flag, Positional};
15use crate::provenance::{Axis, Provenance};
16use crate::text::Text;
17use std::collections::HashMap;
18use thiserror::Error;
19
20#[derive(Debug, Error, PartialEq, Eq)]
22pub enum MergeError {
23 #[error("cannot merge zero candidate nodes")]
25 Empty,
26}
27
28pub 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 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 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 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 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
177pub 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 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
303pub 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
345pub 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
383fn 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
407fn 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
451pub 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 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
480fn 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 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 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 #[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 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}