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 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
296pub 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
338pub 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
376fn 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
400fn 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
444pub 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 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 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 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}