1use std::sync::Arc;
31
32use crate::sparse_io_vector::RowNameCanonicalizer;
33use genomic_data::coordinates::{self, chr_stripped, PeakCoord};
34use rustc_hash::FxHashMap as HashMap;
35
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub enum FeatureNameKind {
42 #[default]
44 Exact,
45 Gene { delim: char },
49 Locus { merge_overlapping: bool },
56 Mixed,
62}
63
64impl FeatureNameKind {
65 pub fn canonicalize(&self, name: &str) -> Box<str> {
72 match self {
73 FeatureNameKind::Exact => name.into(),
74 FeatureNameKind::Gene { delim } => gene_canonicalize(name, *delim),
75 FeatureNameKind::Locus { .. } => locus_key(name).unwrap_or_else(|| name.into()),
76 FeatureNameKind::Mixed => mixed_canonicalize(name),
77 }
78 }
79
80 pub fn is_exact(&self) -> bool {
82 matches!(self, FeatureNameKind::Exact)
83 }
84
85 pub fn needs_global_pass(&self) -> bool {
89 matches!(
90 self,
91 FeatureNameKind::Locus {
92 merge_overlapping: true
93 } | FeatureNameKind::Mixed
94 )
95 }
96
97 pub fn auto_detect(names: &[Box<str>]) -> Self {
104 let n = names.len();
105 if n == 0 {
106 return Self::Exact;
107 }
108 let mut n_locus = 0usize;
109 let mut n_gene_like = 0usize;
110 for name in names {
111 if coordinates::is_locus(name) {
112 n_locus += 1;
113 } else if is_gene_like(name, '_') {
114 n_gene_like += 1;
115 }
116 }
117 let pct_locus = n_locus as f32 / n as f32;
118 let pct_gene = n_gene_like as f32 / n as f32;
119 if pct_locus < 0.50 {
120 warn_non_colon_loci(names);
121 }
122 if pct_locus >= 0.10 && pct_gene >= 0.10 {
123 Self::Mixed
124 } else if pct_locus >= 0.50 {
125 Self::Locus {
126 merge_overlapping: true,
127 }
128 } else if pct_gene >= 0.50 {
129 Self::Gene { delim: '_' }
130 } else {
131 Self::Exact
132 }
133 }
134
135 #[must_use]
150 pub fn reconcile(kinds: &[FeatureNameKind]) -> FeatureNameKind {
151 if kinds.iter().any(|k| matches!(k, FeatureNameKind::Mixed)) {
152 return FeatureNameKind::Mixed;
153 }
154 let gene = kinds
155 .iter()
156 .find(|k| matches!(k, FeatureNameKind::Gene { .. }));
157 let locus = kinds
158 .iter()
159 .find(|k| matches!(k, FeatureNameKind::Locus { .. }));
160 match (gene, locus) {
161 (Some(_), Some(_)) => FeatureNameKind::Mixed,
162 _ => gene.or(locus).cloned().unwrap_or(FeatureNameKind::Exact),
163 }
164 }
165
166 pub fn into_canonicalizer(self) -> Option<RowNameCanonicalizer> {
172 if self.is_exact() {
173 return None;
174 }
175 Some(Arc::new(move |name: &str| self.canonicalize(name)))
176 }
177}
178
179pub fn parse_locus(name: &str) -> Option<(Box<str>, u64, u64)> {
186 let (chr, start, end) = coordinates::split_interval(name)?;
187 Some((chr_stripped(chr).into(), start as u64, end as u64))
188}
189
190pub use genomic_data::coordinates::locus_key;
194
195fn mixed_canonicalize(name: &str) -> Box<str> {
198 let untagged = strip_feature_type_suffix(name, '_');
199 locus_key(untagged).unwrap_or_else(|| gene_canonicalize(name, '_'))
200}
201
202pub fn build_locus_overlap_canonical_map(names: &[Box<str>]) -> HashMap<Box<str>, Box<str>> {
213 let n = names.len();
214 let parsed: Vec<Option<PeakCoord>> = names
215 .iter()
216 .map(|n| coordinates::parse_interval(n))
217 .collect();
218
219 let mut by_chr: HashMap<&str, Vec<usize>> = HashMap::default();
221 for (i, p) in parsed.iter().enumerate() {
222 if let Some(p) = p {
223 by_chr.entry(chr_stripped(&p.chr)).or_default().push(i);
224 }
225 }
226
227 let mut parent: Vec<usize> = (0..n).collect();
229 fn find(p: &mut [usize], mut x: usize) -> usize {
230 while p[x] != x {
231 let g = p[p[x]];
232 p[x] = g;
233 x = g;
234 }
235 x
236 }
237
238 let mut cluster_extent: HashMap<usize, (i64, i64)> = HashMap::default();
240 for (_, mut idxs) in by_chr {
241 idxs.sort_by_key(|&i| parsed[i].as_ref().map_or(0, |p| p.start));
242 let mut current_root: Option<usize> = None;
243 let mut current_min_start: i64 = 0;
244 let mut current_max_end: i64 = 0;
245 for i in idxs {
246 let PeakCoord {
247 start: s, end: e, ..
248 } = parsed[i].as_ref().unwrap();
249 match current_root {
250 Some(root) if *s < current_max_end => {
251 let ra = find(&mut parent, root);
252 let rb = find(&mut parent, i);
253 if ra != rb {
254 parent[rb] = ra;
255 }
256 current_max_end = current_max_end.max(*e);
257 cluster_extent
258 .insert(find(&mut parent, i), (current_min_start, current_max_end));
259 }
260 _ => {
261 current_root = Some(i);
262 current_min_start = *s;
263 current_max_end = *e;
264 cluster_extent.insert(i, (*s, *e));
265 }
266 }
267 }
268 }
269
270 let mut out: HashMap<Box<str>, Box<str>> = HashMap::default();
272 for (i, p) in parsed.iter().enumerate() {
273 if let Some(p) = p {
274 let root = find(&mut parent, i);
275 let (start, end) = cluster_extent.get(&root).copied().unwrap_or((0, 0));
276 let cluster = PeakCoord {
279 chr: p.chr.clone(),
280 start,
281 end,
282 };
283 out.insert(names[i].clone(), cluster.locus_key());
284 }
285 }
286 if out.is_empty() {
287 warn_non_colon_loci(names);
288 }
289 out
290}
291
292fn warn_non_colon_loci(names: &[Box<str>]) {
295 let n_spelled = names
296 .iter()
297 .filter(|name| !coordinates::is_locus(name) && coordinates::import_interval(name).is_some())
298 .count();
299 if n_spelled > 0 && n_spelled * 10 >= names.len() {
300 log::warn!(
301 "{n_spelled} of {} row names read as intervals only in a non-colon spelling \
302 (e.g. `chr1-100-200`); they are not loci here. Re-import them so peaks are named \
303 `chr:start-end`.",
304 names.len()
305 );
306 }
307}
308
309pub fn build_locus_overlap_canonicalizer(names: &[Box<str>]) -> RowNameCanonicalizer {
315 let map = Arc::new(build_locus_overlap_canonical_map(names));
316 Arc::new(move |name: &str| {
317 map.get(name)
318 .cloned()
319 .or_else(|| locus_key(name))
320 .unwrap_or_else(|| name.into())
321 })
322}
323
324pub fn build_mixed_kind_canonicalizer(names: &[Box<str>]) -> RowNameCanonicalizer {
335 let map = Arc::new(build_locus_overlap_canonical_map(names));
336 Arc::new(move |name: &str| {
337 map.get(name)
338 .cloned()
339 .unwrap_or_else(|| mixed_canonicalize(name))
340 })
341}
342
343fn gene_canonicalize(name: &str, delim: char) -> Box<str> {
352 let (head, rest) = gene_part(name);
353 let untagged = strip_feature_type_suffix(head, delim);
356 let symbol = if coordinates::is_region(untagged) {
357 Some(untagged)
358 } else {
359 gene_symbol(head, delim)
360 };
361 match symbol {
362 Some(symbol) if rest.is_empty() => symbol.into(),
363 Some(symbol) => format!("{symbol}{rest}").into_boxed_str(),
364 None => name.into(),
365 }
366}
367
368fn is_gene_like(name: &str, delim: char) -> bool {
370 gene_symbol(gene_part(name).0, delim).is_some()
371}
372
373fn gene_part(name: &str) -> (&str, &str) {
378 name.find('/').map_or((name, ""), |i| name.split_at(i))
379}
380
381fn gene_symbol(head: &str, delim: char) -> Option<&str> {
390 if !head.contains(delim) {
391 return None;
392 }
393 let stripped = strip_feature_type_suffix(head, delim);
394 if coordinates::is_region(stripped) {
395 return None;
396 }
397 let symbol = stripped.rsplit(delim).next().unwrap_or(stripped);
398 (!symbol.bytes().all(|b| b.is_ascii_digit())).then_some(symbol)
399}
400
401fn strip_feature_type_suffix(name: &str, delim: char) -> &str {
407 const TAGS: &[&str] = &[
412 "Gene_Expression",
413 "Gene",
414 "Antibody_Capture",
415 "CRISPR_Guide_Capture",
416 "Multiplexing_Capture",
417 "Custom",
418 "Peaks",
419 ];
420 for tag in TAGS {
421 if let Some(rest) = name.strip_suffix(tag).and_then(|r| r.strip_suffix(delim)) {
424 return rest;
425 }
426 }
427 name
428}
429
430#[derive(clap::ValueEnum, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
437#[serde(rename_all = "kebab-case")]
438pub enum FeatureNameKindArg {
439 #[default]
440 Auto,
441 Exact,
442 Gene,
443 Locus,
444 LocusOverlap,
445 Mixed,
446}
447
448impl FeatureNameKindArg {
449 pub fn resolve_or_gene(&self) -> FeatureNameKind {
453 Option::<FeatureNameKind>::from(self.clone())
454 .unwrap_or(FeatureNameKind::Gene { delim: '_' })
455 }
456}
457
458impl From<FeatureNameKindArg> for Option<FeatureNameKind> {
459 fn from(arg: FeatureNameKindArg) -> Self {
460 match arg {
461 FeatureNameKindArg::Auto => None,
462 FeatureNameKindArg::Exact => Some(FeatureNameKind::Exact),
463 FeatureNameKindArg::Gene => Some(FeatureNameKind::Gene { delim: '_' }),
464 FeatureNameKindArg::Locus => Some(FeatureNameKind::Locus {
465 merge_overlapping: false,
466 }),
467 FeatureNameKindArg::LocusOverlap => Some(FeatureNameKind::Locus {
468 merge_overlapping: true,
469 }),
470 FeatureNameKindArg::Mixed => Some(FeatureNameKind::Mixed),
471 }
472 }
473}
474
475#[cfg(test)]
476#[path = "feature_names_tests.rs"]
477mod feature_names_tests;
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn exact_passthrough() {
485 let k = FeatureNameKind::Exact;
486 assert_eq!(
487 k.canonicalize("ENSG00000000003_TSPAN6").as_ref(),
488 "ENSG00000000003_TSPAN6"
489 );
490 assert!(k.is_exact());
491 assert!(k.into_canonicalizer().is_none());
492 }
493
494 #[test]
495 fn gene_takes_last_underscore_component() {
496 let k = FeatureNameKind::Gene { delim: '_' };
497 assert_eq!(k.canonicalize("ENSG00000000003_TSPAN6").as_ref(), "TSPAN6");
498 assert_eq!(k.canonicalize("TSPAN6").as_ref(), "TSPAN6");
500 assert_eq!(k.canonicalize("A_B_C").as_ref(), "C");
503 assert!(!k.is_exact());
504 assert!(k.into_canonicalizer().is_some());
505 }
506
507 #[test]
508 fn gene_strips_cell_ranger_feature_type_suffix() {
509 let k = FeatureNameKind::Gene { delim: '_' };
510 assert_eq!(
513 k.canonicalize("ENSG00000187634_SAMD11_Gene").as_ref(),
514 "SAMD11"
515 );
516 assert_eq!(
518 k.canonicalize("ENSG00000187634_SAMD11_Gene_Expression")
519 .as_ref(),
520 "SAMD11"
521 );
522 assert_eq!(k.canonicalize("FakeGene").as_ref(), "FakeGene");
525 }
526
527 #[test]
528 fn locus_strips_chr_and_leaves_other_spellings_alone() {
529 let k = FeatureNameKind::Locus {
530 merge_overlapping: false,
531 };
532 assert_eq!(k.canonicalize("chr1:1000-2000").as_ref(), "1:1000-2000");
533 assert_eq!(k.canonicalize("ChrX:5000-6000").as_ref(), "X:5000-6000");
534 assert_eq!(k.canonicalize("1_1000_2000").as_ref(), "1_1000_2000");
536 }
537
538 #[test]
541 fn parse_locus_accepts_common_formats() {
542 assert_eq!(
544 parse_locus("chr1:1000-2000"),
545 Some(("1".into(), 1000, 2000))
546 );
547 assert_eq!(parse_locus("1:1000-2000"), Some(("1".into(), 1000, 2000)));
548 assert_eq!(
549 parse_locus("CHR1:1000-2000"),
550 Some(("1".into(), 1000, 2000))
551 );
552 assert_eq!(
553 parse_locus("chrX:5000-6000"),
554 Some(("X".into(), 5000, 6000))
555 );
556 assert_eq!(parse_locus("chrMT:1-100"), Some(("MT".into(), 1, 100)));
557 }
558
559 #[test]
560 fn parse_locus_keeps_contig_names_with_separators() {
561 assert_eq!(
562 parse_locus("chrUn_CTG1v1:0-100"),
563 Some(("Un_CTG1v1".into(), 0, 100))
564 );
565 assert_eq!(
567 parse_locus("Un_CTG1v1:0-100"),
568 Some(("Un_CTG1v1".into(), 0, 100))
569 );
570 }
571
572 #[test]
573 fn contig_peaks_stay_loci_on_a_mixed_axis() {
574 let names: Vec<Box<str>> = vec![
575 "chr1_CTG1v1_random:5-10".into(),
576 "chr4_CTG2v2_random:5-10".into(),
577 "ENSG000_GENE1".into(),
578 ];
579 let canon = build_mixed_kind_canonicalizer(&names);
580 assert_eq!(canon(&names[0]).as_ref(), "1_CTG1v1_random:5-10");
581 assert_eq!(canon(&names[1]).as_ref(), "4_CTG2v2_random:5-10");
582 assert_eq!(canon(&names[2]).as_ref(), "GENE1");
583 }
584
585 #[test]
586 fn every_locus_path_gives_one_key() {
587 let names: Vec<Box<str>> = vec!["chrChr1:0-100".into(), "chr1:0-100".into()];
588 let map = build_locus_overlap_canonical_map(&names);
589 let k = FeatureNameKind::Locus {
590 merge_overlapping: false,
591 };
592 for name in &names {
593 assert_eq!(map.get(name).unwrap(), &k.canonicalize(name));
594 }
595 }
596
597 #[test]
598 fn locus_canonical_keeps_case_on_every_path() {
599 let names: Vec<Box<str>> = vec!["chrX:0-100".into(), "chr1:0-100".into()];
600 let map = build_locus_overlap_canonical_map(&names);
601 assert_eq!(map.get(&names[0]).unwrap().as_ref(), "X:0-100");
602 assert_eq!(map.get(&names[1]).unwrap().as_ref(), "1:0-100");
603 let canon = build_locus_overlap_canonicalizer(&names);
605 assert_eq!(canon("chrX:200-300").as_ref(), "X:200-300");
606 let mixed = build_mixed_kind_canonicalizer(&names);
607 assert_eq!(mixed("chrX:0-100").as_ref(), "X:0-100");
608 assert_eq!(mixed("chrM:200-300").as_ref(), "M:200-300");
609 let k = FeatureNameKind::Locus {
610 merge_overlapping: false,
611 };
612 assert_eq!(k.canonicalize("chrM:0-100").as_ref(), "M:0-100");
613 }
614
615 #[test]
616 fn parse_locus_rejects_non_loci() {
617 assert!(parse_locus("TGFB1").is_none()); assert!(parse_locus("ENSG00000105329").is_none()); assert!(parse_locus("chr1:bad-2000").is_none()); assert!(parse_locus("chr1:1000").is_none()); assert!(parse_locus("chr1:2000-1000").is_none()); assert!(parse_locus("").is_none()); assert!(parse_locus("chr1").is_none()); assert!(parse_locus("chr:1-2").is_none()); assert!(parse_locus("ENSG000_GENE1").is_none()); assert!(parse_locus("GENE1-AS1").is_none()); assert!(parse_locus("chr1_1000_2000").is_none()); assert!(parse_locus("chr1-1000-2000").is_none()); }
630
631 #[test]
632 fn overlap_map_merges_two_overlapping_intervals() {
633 let names = vec![
635 "chr1:1-20".to_string().into_boxed_str(),
636 "chr1:15-30".to_string().into_boxed_str(),
637 ];
638 let map = build_locus_overlap_canonical_map(&names);
639 let c0 = map.get(&names[0]).unwrap();
640 let c1 = map.get(&names[1]).unwrap();
641 assert_eq!(c0, c1, "both inputs should map to the same canonical");
642 assert_eq!(c0.as_ref(), "1:1-30"); }
644
645 #[test]
646 fn overlap_map_keeps_non_overlapping_separate() {
647 let names = vec![
648 "chr1:1-20".to_string().into_boxed_str(),
649 "chr1:100-200".to_string().into_boxed_str(),
650 "chr2:1-20".to_string().into_boxed_str(),
651 ];
652 let map = build_locus_overlap_canonical_map(&names);
653 assert_eq!(map.get(&names[0]).unwrap().as_ref(), "1:1-20");
654 assert_eq!(map.get(&names[1]).unwrap().as_ref(), "1:100-200");
655 assert_eq!(map.get(&names[2]).unwrap().as_ref(), "2:1-20");
657 }
658
659 #[test]
660 fn overlap_map_handles_transitive_chain() {
661 let names = vec![
665 "chr1:1-20".to_string().into_boxed_str(),
666 "chr1:15-30".to_string().into_boxed_str(),
667 "chr1:25-40".to_string().into_boxed_str(),
668 ];
669 let map = build_locus_overlap_canonical_map(&names);
670 let c0 = map.get(&names[0]).unwrap();
671 let c1 = map.get(&names[1]).unwrap();
672 let c2 = map.get(&names[2]).unwrap();
673 assert_eq!(c0, c1);
674 assert_eq!(c1, c2);
675 assert_eq!(c0.as_ref(), "1:1-40"); }
677
678 #[test]
679 fn overlap_map_handles_full_containment() {
680 let names = vec![
682 "chr1:1-100".to_string().into_boxed_str(),
683 "chr1:30-50".to_string().into_boxed_str(),
684 ];
685 let map = build_locus_overlap_canonical_map(&names);
686 let c0 = map.get(&names[0]).unwrap();
687 let c1 = map.get(&names[1]).unwrap();
688 assert_eq!(c0, c1);
689 assert_eq!(c0.as_ref(), "1:1-100");
690 }
691
692 #[test]
693 fn overlap_map_treats_adjacent_as_separate() {
694 let names = vec![
697 "chr1:1-20".to_string().into_boxed_str(),
698 "chr1:20-30".to_string().into_boxed_str(),
699 ];
700 let map = build_locus_overlap_canonical_map(&names);
701 assert_ne!(map.get(&names[0]).unwrap(), map.get(&names[1]).unwrap());
702 }
703
704 #[test]
705 fn overlap_map_normalizes_chr_prefix_within_cluster() {
706 let names = vec![
709 "chr1:1-20".to_string().into_boxed_str(),
710 "1:15-30".to_string().into_boxed_str(),
711 ];
712 let map = build_locus_overlap_canonical_map(&names);
713 let c0 = map.get(&names[0]).unwrap();
714 let c1 = map.get(&names[1]).unwrap();
715 assert_eq!(c0, c1);
716 assert_eq!(c0.as_ref(), "1:1-30");
717 }
718
719 #[test]
720 fn overlap_map_leaves_non_colon_spellings_out() {
721 let names = vec![
723 "chr1:1-20".to_string().into_boxed_str(),
724 "chr1_15_30".to_string().into_boxed_str(),
725 ];
726 let map = build_locus_overlap_canonical_map(&names);
727 assert_eq!(map.get(&names[0]).unwrap().as_ref(), "1:1-20");
728 assert!(!map.contains_key(&names[1]));
729 }
730
731 #[test]
732 fn underscore_peaks_are_not_collapsed_by_the_gene_rule() {
733 let names: Vec<Box<str>> = (1..=20)
736 .map(|i| format!("chr{i}_100_200").into_boxed_str())
737 .collect();
738 assert_eq!(FeatureNameKind::auto_detect(&names), FeatureNameKind::Exact);
739 let mixed = build_mixed_kind_canonicalizer(&names);
740 assert_eq!(mixed("chr2_100_200").as_ref(), "chr2_100_200");
741 let gene = FeatureNameKind::Gene { delim: '_' };
742 assert_eq!(gene.canonicalize("chr2_100_200").as_ref(), "chr2_100_200");
743 assert_eq!(gene.canonicalize("ENSG000_GENE1").as_ref(), "GENE1");
744 assert_eq!(gene.canonicalize("GENE1_Gene").as_ref(), "GENE1");
745 assert_eq!(
746 gene.canonicalize("chr2_100_200_Peaks").as_ref(),
747 "chr2_100_200_Peaks"
748 );
749 assert_eq!(
752 gene.canonicalize("chr1_CTG1v1_random:12345/baf/alt")
753 .as_ref(),
754 "chr1_CTG1v1_random:12345/baf/alt"
755 );
756 assert_eq!(
757 gene.canonicalize("ENSG000_GENE1/m6a/chr1_CTG1v1_random:123/methylated")
758 .as_ref(),
759 "GENE1/m6a/chr1_CTG1v1_random:123/methylated"
760 );
761 assert_eq!(
762 gene.canonicalize("ENSG000_GENE1/count/spliced").as_ref(),
763 "GENE1/count/spliced"
764 );
765 assert_eq!(gene.canonicalize("chr1:0-100_Peaks").as_ref(), "chr1:0-100");
767 assert_eq!(
768 FeatureNameKind::Mixed
769 .canonicalize("chr1:0-100_Peaks")
770 .as_ref(),
771 "1:0-100"
772 );
773 assert_eq!(
776 gene.canonicalize("chrUn_CTG1v1:0-100_Peaks").as_ref(),
777 "chrUn_CTG1v1:0-100"
778 );
779 assert_eq!(
780 gene.canonicalize("chrUn_CTG1v1:0-100").as_ref(),
781 "chrUn_CTG1v1:0-100"
782 );
783 }
784
785 #[test]
786 fn overlap_map_ignores_non_locus_names() {
787 let names = vec![
790 "TGFB1".to_string().into_boxed_str(),
791 "chr1:1-20".to_string().into_boxed_str(),
792 ];
793 let map = build_locus_overlap_canonical_map(&names);
794 assert!(!map.contains_key(&names[0]));
795 assert!(map.contains_key(&names[1]));
796 }
797
798 #[test]
799 fn overlap_map_skips_empty_intervals() {
800 let names = vec!["chr1:1000-1000".to_string().into_boxed_str()];
802 let map = build_locus_overlap_canonical_map(&names);
803 assert!(map.is_empty());
804 }
805
806 #[test]
807 fn overlap_canonicalizer_falls_back_for_unmatched() {
808 let names = vec!["chr1:1-20".to_string().into_boxed_str()];
809 let canon = build_locus_overlap_canonicalizer(&names);
810 assert_eq!(canon("chr1:1-20").as_ref(), "1:1-20");
812 assert_eq!(canon("chr2:500-600").as_ref(), "2:500-600");
814 assert_eq!(canon("GENE1").as_ref(), "GENE1");
816 }
817
818 #[test]
821 fn auto_detect_pure_locus_axis() {
822 let names: Vec<Box<str>> = (0..100)
823 .map(|i| format!("chr1:{}-{}", i * 100, i * 100 + 50).into_boxed_str())
824 .collect();
825 assert!(matches!(
826 FeatureNameKind::auto_detect(&names),
827 FeatureNameKind::Locus {
828 merge_overlapping: true
829 }
830 ));
831 }
832
833 #[test]
834 fn auto_detect_pure_gene_axis() {
835 let names: Vec<Box<str>> = (0..100)
836 .map(|i| format!("ENSG000_GENE{}", i).into_boxed_str())
837 .collect();
838 assert!(matches!(
839 FeatureNameKind::auto_detect(&names),
840 FeatureNameKind::Gene { delim: '_' }
841 ));
842 }
843
844 #[test]
845 fn auto_detect_mixed_axis() {
846 let mut names: Vec<Box<str>> = (0..80)
848 .map(|i| format!("chr1:{}-{}", i * 1000, i * 1000 + 500).into_boxed_str())
849 .collect();
850 names.extend((0..20).map(|i| format!("ENSG000_GENE{}", i).into_boxed_str()));
851 assert!(matches!(
852 FeatureNameKind::auto_detect(&names),
853 FeatureNameKind::Mixed
854 ));
855 }
856
857 #[test]
858 fn auto_detect_empty_or_exact() {
859 assert!(matches!(
860 FeatureNameKind::auto_detect(&[]),
861 FeatureNameKind::Exact
862 ));
863 let names = vec!["TGFB1".into(), "CD4".into(), "IL2".into(), "GAPDH".into()];
864 assert!(matches!(
865 FeatureNameKind::auto_detect(&names),
866 FeatureNameKind::Exact
867 ));
868 }
869
870 #[test]
871 fn mixed_dispatcher_canonicalizes_each_name_by_kind() {
872 let names: Vec<Box<str>> = vec![
873 "chr1:1-20".into(), "chr1:15-30".into(), "ENSG000_TGFB1".into(), "CD4".into(), ];
878 let canon = build_mixed_kind_canonicalizer(&names);
879 assert_eq!(canon("chr1:1-20").as_ref(), "1:1-30");
880 assert_eq!(canon("chr1:15-30").as_ref(), "1:1-30");
881 assert_eq!(canon("ENSG000_TGFB1").as_ref(), "TGFB1");
882 assert_eq!(canon("CD4").as_ref(), "CD4");
883 }
884}