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
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 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
165pub 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
282pub 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
324pub 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
362fn 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
386fn 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
430pub 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 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 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 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}