1use std::path::{Path, PathBuf};
2
3use clap::Args;
4
5use crate::catalog::hierarchical::HierarchicalCatalog;
6use crate::catalog::store::ReferenceCatalog;
7use crate::cli::OutputFormat;
8use crate::core::header::QueryHeader;
9use crate::core::types::Confidence;
10use crate::matching::engine::{MatchResult, MatchingConfig, MatchingEngine, ScoringWeights};
11use crate::matching::hierarchical_engine::{HierarchicalMatchResult, HierarchicalMatchingEngine};
12use crate::matching::Suggestion;
13use crate::parsing;
14use crate::refget::{EnrichedContig, RefgetConfig, RefgetLookupResult};
15
16#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
19pub enum MissingContigHandling {
20 #[default]
22 Warn,
23 Strict,
25 Silent,
27}
28
29#[derive(Args)]
30pub struct IdentifyArgs {
31 #[arg(required = true)]
34 pub input: PathBuf,
35
36 #[arg(long)]
38 pub input_format: Option<InputFormat>,
39
40 #[arg(short = 'n', long, default_value = "5")]
42 pub max_matches: usize,
43
44 #[arg(long)]
46 pub exact_only: bool,
47
48 #[arg(long)]
50 pub catalog: Option<PathBuf>,
51
52 #[arg(long)]
54 pub hierarchical: bool,
55
56 #[arg(long, value_enum, default_value = "warn")]
59 pub missing_contig_handling: MissingContigHandling,
60
61 #[arg(long, default_value = "70", value_parser = clap::value_parser!(u32).range(0..=100))]
65 pub weight_match: u32,
66
67 #[arg(long, default_value = "20", value_parser = clap::value_parser!(u32).range(0..=100))]
70 pub weight_coverage: u32,
71
72 #[arg(long, default_value = "10", value_parser = clap::value_parser!(u32).range(0..=100))]
75 pub weight_order: u32,
76
77 #[arg(long)]
81 pub refget_server: Option<String>,
82}
83
84#[derive(Clone, Copy, Debug, clap::ValueEnum)]
85pub enum InputFormat {
86 Sam,
87 Bam,
88 Cram,
89 Dict,
90 Fai,
91 Fasta,
92 Vcf,
93 Tsv,
94 Csv,
95}
96
97#[allow(clippy::needless_pass_by_value)] pub fn run(args: IdentifyArgs, format: OutputFormat, verbose: bool) -> anyhow::Result<()> {
104 let query = parse_input(&args)?;
106
107 if verbose {
108 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let md5_pct = (query.md5_coverage() * 100.0) as u32;
110 eprintln!(
111 "Parsed {} contigs from input ({md5_pct}% have MD5)",
112 query.contigs.len(),
113 );
114 }
115
116 if args.hierarchical {
118 run_hierarchical(&args, &query, format, verbose)
119 } else {
120 run_flat(&args, &query, format, verbose)
121 }
122}
123
124fn run_flat(
125 args: &IdentifyArgs,
126 query: &QueryHeader,
127 format: OutputFormat,
128 verbose: bool,
129) -> anyhow::Result<()> {
130 let catalog = if let Some(path) = &args.catalog {
132 ReferenceCatalog::load_from_file(path)?
133 } else {
134 ReferenceCatalog::load_embedded()?
135 };
136
137 if verbose {
138 eprintln!("Loaded flat catalog with {} references", catalog.len());
139 }
140
141 if catalog.is_empty() {
142 eprintln!("Warning: Catalog is empty, no references to match against.");
143 return Ok(());
144 }
145
146 let scoring_weights = ScoringWeights {
148 contig_match: f64::from(args.weight_match) / 100.0,
149 coverage: f64::from(args.weight_coverage) / 100.0,
150 order: f64::from(args.weight_order) / 100.0,
151 conflict_penalty: 0.1, };
153
154 if verbose {
155 eprintln!(
156 "Scoring weights: {:.0}% match, {:.0}% coverage, {:.0}% order",
157 scoring_weights.contig_match * 100.0,
158 scoring_weights.coverage * 100.0,
159 scoring_weights.order * 100.0,
160 );
161 }
162
163 let config = MatchingConfig {
165 min_score: 0.1,
166 scoring_weights: scoring_weights.clone(),
167 };
168 let engine = MatchingEngine::new(&catalog, config);
169 let matches = engine.find_matches(query, args.max_matches);
170
171 if matches.is_empty() {
172 eprintln!("No matching references found.");
173 return Ok(());
174 }
175
176 let enriched = if let Some(ref server_url) = args.refget_server {
178 let config = RefgetConfig::new(server_url);
179 let unmatched_contigs: Vec<_> = matches
181 .first()
182 .map(|m| m.diagnosis.query_only.clone())
183 .unwrap_or_default();
184
185 if unmatched_contigs.is_empty() {
186 None
187 } else {
188 if verbose {
189 eprintln!(
190 "Querying refget server for {} unmatched contigs...",
191 unmatched_contigs.len()
192 );
193 }
194 let rt = tokio::runtime::Runtime::new()?;
195 let results = rt.block_on(crate::refget::enrichment::enrich_contigs(
196 &unmatched_contigs,
197 &config,
198 ));
199 Some(results)
200 }
201 } else {
202 None
203 };
204
205 match format {
207 OutputFormat::Text => {
208 print_text_results(
209 &matches,
210 query,
211 verbose,
212 args.missing_contig_handling,
213 &scoring_weights,
214 );
215 if let Some(ref enriched) = enriched {
216 print_refget_text_results(enriched);
217 }
218 }
219 OutputFormat::Json => {
220 print_json_results(
221 &matches,
222 args.missing_contig_handling,
223 &scoring_weights,
224 enriched.as_deref(),
225 )?;
226 }
227 OutputFormat::Tsv => {
228 print_tsv_results(&matches, &scoring_weights);
229 if let Some(ref enriched) = enriched {
230 print_refget_tsv_results(enriched);
231 }
232 }
233 }
234
235 Ok(())
236}
237
238fn run_hierarchical(
239 args: &IdentifyArgs,
240 query: &QueryHeader,
241 format: OutputFormat,
242 verbose: bool,
243) -> anyhow::Result<()> {
244 let catalog_path = args
246 .catalog
247 .as_ref()
248 .ok_or_else(|| anyhow::anyhow!("--catalog is required when using --hierarchical"))?;
249
250 let catalog = HierarchicalCatalog::load(catalog_path)?;
251
252 if verbose {
253 eprintln!(
254 "Loaded hierarchical catalog v{} with {} assemblies",
255 catalog.version,
256 catalog.assemblies.len()
257 );
258 }
259
260 let engine = HierarchicalMatchingEngine::new(&catalog);
262 let matches = engine.find_matches(query, args.max_matches);
263
264 if matches.is_empty() {
265 eprintln!("No matching references found.");
266 return Ok(());
267 }
268
269 match format {
271 OutputFormat::Text => print_hierarchical_text_results(&matches, query, verbose),
272 OutputFormat::Json => print_hierarchical_json_results(&matches)?,
273 OutputFormat::Tsv => print_hierarchical_tsv_results(&matches),
274 }
275
276 Ok(())
277}
278
279fn parse_input(args: &IdentifyArgs) -> anyhow::Result<QueryHeader> {
280 use std::io::{self, Read};
281
282 if args.input.to_string_lossy() == "-" {
284 let mut buffer = String::new();
285 io::stdin().read_to_string(&mut buffer)?;
286 return Ok(parsing::sam::parse_header_text(&buffer)?);
287 }
288
289 let format = args
291 .input_format
292 .unwrap_or_else(|| detect_format(&args.input));
293
294 match format {
295 InputFormat::Sam | InputFormat::Bam | InputFormat::Cram => {
296 Ok(parsing::sam::parse_file(&args.input)?)
297 }
298 InputFormat::Dict => Ok(parsing::dict::parse_dict_file(&args.input)?),
299 InputFormat::Fai => Ok(parsing::fai::parse_fai_file(&args.input)?),
300 InputFormat::Fasta => Ok(parsing::fasta::parse_fasta_file(&args.input)?),
301 InputFormat::Vcf => Ok(parsing::vcf::parse_vcf_file(&args.input)?),
302 InputFormat::Tsv => Ok(parsing::tsv::parse_tsv_file(&args.input, '\t')?),
303 InputFormat::Csv => Ok(parsing::tsv::parse_tsv_file(&args.input, ',')?),
304 }
305}
306
307fn detect_format(path: &Path) -> InputFormat {
309 let path_str = path.to_string_lossy().to_lowercase();
310
311 if parsing::fasta::is_fasta_file(path) {
313 return InputFormat::Fasta;
314 }
315
316 if path_str.ends_with(".vcf.gz") || path_str.ends_with(".vcf.bgz") {
318 return InputFormat::Vcf;
319 }
320
321 let ext = path
323 .extension()
324 .and_then(|e| e.to_str())
325 .map(str::to_lowercase);
326
327 match ext.as_deref() {
328 Some("bam") => InputFormat::Bam,
329 Some("cram") => InputFormat::Cram,
330 Some("dict") => InputFormat::Dict,
331 Some("fai") => InputFormat::Fai,
332 Some("vcf") => InputFormat::Vcf,
333 Some("tsv") => InputFormat::Tsv,
334 Some("csv") => InputFormat::Csv,
335 _ => InputFormat::Sam, }
337}
338
339#[allow(clippy::too_many_lines)] fn print_text_results(
341 matches: &[MatchResult],
342 query: &QueryHeader,
343 verbose: bool,
344 missing_handling: MissingContigHandling,
345 weights: &ScoringWeights,
346) {
347 for (i, result) in matches.iter().enumerate() {
348 if i > 0 {
349 println!("\n{}", "─".repeat(60));
350 }
351
352 let confidence_str = match result.score.confidence {
354 Confidence::Exact => "EXACT",
355 Confidence::High => "HIGH",
356 Confidence::Medium => "MEDIUM",
357 Confidence::Low => "LOW",
358 };
359
360 println!(
361 "\n#{} {} ({})",
362 i + 1,
363 result.reference.display_name,
364 confidence_str
365 );
366 println!(" ID: {}", result.reference.id);
367 println!(" Assembly: {}", result.reference.assembly);
368 println!(" Source: {}", result.reference.source);
369 println!(" Match Type: {:?}", result.diagnosis.match_type);
370
371 let norm = weights.normalized();
374 println!(
375 "\n Score: {:.1}% = {:.0}%×match + {:.0}%×coverage + {:.0}%×order",
376 result.score.composite * 100.0,
377 result.score.match_quality * 100.0,
378 result.score.coverage_score * 100.0,
379 result.score.order_score * 100.0,
380 );
381 println!(
382 " (weights: {:.0}% match, {:.0}% coverage, {:.0}% order)",
383 norm.contig_match * 100.0,
384 norm.coverage * 100.0,
385 norm.order * 100.0,
386 );
387
388 if !result.reference.contigs_missing_from_fasta.is_empty() {
390 let missing_set: std::collections::HashSet<String> = result
392 .reference
393 .contigs_missing_from_fasta
394 .iter()
395 .map(|s| s.to_lowercase())
396 .collect();
397
398 let query_has_missing: Vec<&str> = query
399 .contigs
400 .iter()
401 .filter(|c| missing_set.contains(&c.name.to_lowercase()))
402 .map(|c| c.name.as_str())
403 .collect();
404
405 match missing_handling {
406 MissingContigHandling::Silent => {}
407 MissingContigHandling::Warn => {
408 if !query_has_missing.is_empty() {
409 println!(
410 "\n Warning: Query has contig(s) not in reference FASTA: {}",
411 query_has_missing.join(", ")
412 );
413 println!(
414 " Note: {} uses external sequence(s) for: {}",
415 result.reference.display_name,
416 result.reference.contigs_missing_from_fasta.join(", ")
417 );
418 }
419 }
420 MissingContigHandling::Strict => {
421 if !query_has_missing.is_empty() {
422 println!(
423 "\n ERROR: Query has contig(s) not in reference FASTA: {}",
424 query_has_missing.join(", ")
425 );
426 println!(
427 " The reference {} does not include: {}",
428 result.reference.display_name,
429 result.reference.contigs_missing_from_fasta.join(", ")
430 );
431 }
432 }
433 }
434 }
435
436 let total_query = query.contigs.len();
438 let exact = result.score.exact_matches;
439 let name_len = result.score.name_length_matches;
440 let conflicts = result.score.md5_conflicts;
441 let unmatched = result.score.unmatched;
442
443 println!(
444 "\n Query contigs: {total_query} total → {exact} exact, {name_len} name+length, {conflicts} conflicts, {unmatched} unmatched"
445 );
446
447 let total_ref = result.reference.contigs.len();
449 let matched_ref = exact + name_len; let uncovered_ref = total_ref.saturating_sub(matched_ref);
451 println!(
452 " Reference contigs: {total_ref} total, {matched_ref} matched, {uncovered_ref} not in query"
453 );
454
455 if result.diagnosis.reordered {
456 println!(" Order: DIFFERENT from reference");
457 }
458
459 if !result.diagnosis.conflicts.is_empty() {
461 println!("\n Conflicts:");
462 for conflict in &result.diagnosis.conflicts {
463 println!(" - {}", conflict.description);
464 }
465 }
466
467 if !result.diagnosis.suggestions.is_empty() {
469 println!("\n Suggestions:");
470 for suggestion in &result.diagnosis.suggestions {
471 match suggestion {
472 Suggestion::RenameContigs { command_hint, .. } => {
473 println!(" - Rename contigs:");
474 for line in command_hint.lines() {
475 println!(" {line}");
476 }
477 }
478 Suggestion::ReorderContigs { command_hint } => {
479 println!(" - Reorder contigs:");
480 for line in command_hint.lines() {
481 println!(" {line}");
482 }
483 }
484 Suggestion::ReplaceContig {
485 contig_name,
486 reason,
487 ..
488 } => {
489 println!(" - Replace {contig_name}: {reason}");
490 }
491 Suggestion::UseAsIs { warnings } => {
492 if warnings.is_empty() {
493 println!(" - Safe to use as-is");
494 } else {
495 println!(" - Safe to use with warnings:");
496 for w in warnings {
497 println!(" - {w}");
498 }
499 }
500 }
501 Suggestion::Realign {
502 reason,
503 suggested_reference,
504 } => {
505 println!(" - Realignment needed: {reason}");
506 println!(" Suggested reference: {suggested_reference}");
507 }
508 }
509 }
510 }
511
512 if let Some(url) = &result.reference.download_url {
514 println!("\n Download: {url}");
515 }
516
517 if verbose && !result.diagnosis.renamed_matches.is_empty() {
519 println!("\n Rename mappings:");
520 for r in &result.diagnosis.renamed_matches {
521 println!(" {} -> {}", r.query_name, r.reference_name);
522 }
523 }
524 }
525
526 println!();
527}
528
529fn print_json_results(
530 matches: &[MatchResult],
531 missing_handling: MissingContigHandling,
532 weights: &ScoringWeights,
533 enriched: Option<&[EnrichedContig]>,
534) -> anyhow::Result<()> {
535 let norm = weights.normalized();
536 let results: Vec<serde_json::Value> = matches
538 .iter()
539 .map(|m| {
540 let ref_total = m.reference.contigs.len();
542 let ref_matched = m.score.exact_matches + m.score.name_length_matches;
543 let ref_uncovered = ref_total.saturating_sub(ref_matched);
544
545 let mut json = serde_json::json!({
546 "reference": {
547 "id": m.reference.id.0,
548 "display_name": m.reference.display_name,
549 "assembly": format!("{}", m.reference.assembly),
550 "source": format!("{}", m.reference.source),
551 "download_url": m.reference.download_url,
552 "total_contigs": ref_total,
553 },
554 "score": {
555 "composite": m.score.composite,
556 "confidence": format!("{:?}", m.score.confidence),
557 "match_quality": m.score.match_quality,
559 "coverage_score": m.score.coverage_score,
560 "order_score": m.score.order_score,
561 "weights": {
563 "match": norm.contig_match,
564 "coverage": norm.coverage,
565 "order": norm.order,
566 },
567 },
568 "query_contigs": {
569 "exact_matches": m.score.exact_matches,
570 "name_length_matches": m.score.name_length_matches,
571 "md5_conflicts": m.score.md5_conflicts,
572 "unmatched": m.score.unmatched,
573 },
574 "reference_coverage": {
575 "total": ref_total,
576 "matched": ref_matched,
577 "not_in_query": ref_uncovered,
578 },
579 "match_type": format!("{:?}", m.diagnosis.match_type),
580 "reordered": m.diagnosis.reordered,
581 });
582
583 if !matches!(missing_handling, MissingContigHandling::Silent)
585 && !m.reference.contigs_missing_from_fasta.is_empty()
586 {
587 json["reference"]["contigs_missing_from_fasta"] =
588 serde_json::json!(&m.reference.contigs_missing_from_fasta);
589 }
590
591 json
592 })
593 .collect();
594
595 let mut output = serde_json::json!({ "matches": results });
596
597 if let Some(enriched) = enriched {
598 output["refget_enrichment"] = serde_json::json!(enriched);
599 }
600
601 println!("{}", serde_json::to_string_pretty(&output)?);
602 Ok(())
603}
604
605fn print_tsv_results(matches: &[MatchResult], weights: &ScoringWeights) {
606 let norm = weights.normalized();
607 println!(
609 "rank\tid\tdisplay_name\tassembly\tsource\tmatch_type\tscore\tmatch_score\tcoverage_score\torder_score\tweight_match\tweight_coverage\tweight_order\tconfidence\texact\tname_length\tconflicts\tunmatched\tref_total\tref_matched\tref_uncovered"
610 );
611 for (i, m) in matches.iter().enumerate() {
612 let ref_total = m.reference.contigs.len();
613 let ref_matched = m.score.exact_matches + m.score.name_length_matches;
614 let ref_uncovered = ref_total.saturating_sub(ref_matched);
615
616 println!(
617 "{}\t{}\t{}\t{}\t{}\t{:?}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.2}\t{:.2}\t{:.2}\t{:?}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
618 i + 1,
619 m.reference.id,
620 m.reference.display_name,
621 m.reference.assembly,
622 m.reference.source,
623 m.diagnosis.match_type,
624 m.score.composite,
625 m.score.match_quality,
626 m.score.coverage_score,
627 m.score.order_score,
628 norm.contig_match,
629 norm.coverage,
630 norm.order,
631 m.score.confidence,
632 m.score.exact_matches,
633 m.score.name_length_matches,
634 m.score.md5_conflicts,
635 m.score.unmatched,
636 ref_total,
637 ref_matched,
638 ref_uncovered,
639 );
640 }
641}
642
643fn print_refget_text_results(enriched: &[EnrichedContig]) {
648 let found: Vec<_> = enriched
649 .iter()
650 .filter(|e| matches!(e.refget_metadata, RefgetLookupResult::Found { .. }))
651 .collect();
652
653 if found.is_empty() {
654 println!("Refget: no unmatched contigs found in refget server.");
655 return;
656 }
657
658 println!("\nRefget Aliases for Unmatched Contigs:");
659 println!("{}", "─".repeat(60));
660 for entry in &found {
661 if let RefgetLookupResult::Found {
662 aliases,
663 sha512t24u,
664 circular,
665 } = &entry.refget_metadata
666 {
667 print!(" {} ", entry.name);
668 if *circular {
669 print!("(circular) ");
670 }
671 println!("[sha512t24u: {sha512t24u}]");
672 if aliases.is_empty() {
673 println!(" (no aliases)");
674 } else {
675 for alias in aliases {
676 println!(" {}: {}", alias.naming_authority, alias.value);
677 }
678 }
679 }
680 }
681 println!();
682}
683
684fn print_refget_tsv_results(enriched: &[EnrichedContig]) {
685 println!("\n# Refget enrichment for unmatched contigs");
686 println!("contig\tmd5\tstatus\tsha512t24u\tcircular\taliases");
687 for entry in enriched {
688 match &entry.refget_metadata {
689 RefgetLookupResult::Found {
690 aliases,
691 sha512t24u,
692 circular,
693 } => {
694 let alias_str: Vec<String> = aliases
695 .iter()
696 .map(|a| format!("{}={}", a.naming_authority, a.value))
697 .collect();
698 println!(
699 "{}\t{}\tfound\t{}\t{}\t{}",
700 entry.name,
701 entry.md5.as_deref().unwrap_or(""),
702 sha512t24u,
703 circular,
704 alias_str.join(";"),
705 );
706 }
707 RefgetLookupResult::NotFound => {
708 println!(
709 "{}\t{}\tnot_found\t\t\t",
710 entry.name,
711 entry.md5.as_deref().unwrap_or(""),
712 );
713 }
714 RefgetLookupResult::Error { message } => {
715 println!(
716 "{}\t{}\terror\t\t\t{}",
717 entry.name,
718 entry.md5.as_deref().unwrap_or(""),
719 message,
720 );
721 }
722 }
723 }
724}
725
726fn print_hierarchical_text_results(
731 matches: &[HierarchicalMatchResult],
732 query: &QueryHeader,
733 verbose: bool,
734) {
735 for (i, result) in matches.iter().enumerate() {
736 if i > 0 {
737 println!("\n{}", "─".repeat(60));
738 }
739
740 let match_str = format!("{:?}", result.match_type).to_uppercase();
742 println!("\n#{} {} ({})", i + 1, result.display_name, match_str);
743
744 println!(" Distribution ID: {}", result.distribution_id);
746
747 if !result.assembly_id.is_empty() {
749 println!(
750 " Assembly: {} ({})",
751 result.assembly_name, result.assembly_id
752 );
753 if !result.version_string.is_empty() {
754 println!(
755 " Version: {} ({})",
756 result.version_string, result.version_id
757 );
758 }
759 }
760
761 println!(" Score: {:.1}%", result.match_percentage());
763
764 println!("\n Contig Summary:");
766 println!(" - Your file: {} contigs", result.total_query_contigs);
767 println!(
768 " - This distribution: {} contigs",
769 result.total_distribution_contigs
770 );
771 println!(" - Matched: {} contigs", result.matched_contigs);
772
773 if result.extra_in_query > 0 {
774 println!(" - Extra in your file: {}", result.extra_in_query);
775 }
776 if result.missing_from_query > 0 {
777 println!(" - Missing from your file: {}", result.missing_from_query);
778 }
779
780 let counts = &result.presence_counts;
782 if counts.in_both > 0 || counts.fasta_only > 0 || counts.report_only > 0 {
783 println!("\n Presence Breakdown:");
784 if counts.in_both > 0 {
785 println!(" - In both (FASTA + report): {} contigs", counts.in_both);
786 }
787 if counts.fasta_only > 0 {
788 println!(" - FASTA-only (decoy/HLA): {} contigs", counts.fasta_only);
789 }
790 if counts.report_only > 0 {
791 println!(
792 " - Report-only (not in FASTA): {} contigs",
793 counts.report_only
794 );
795 }
796 }
797
798 if verbose {
800 println!("\n Query contigs: {}", query.contigs.len());
801 let md5_count = query.contigs.iter().filter(|c| c.md5.is_some()).count();
802 println!(" Query contigs with MD5: {md5_count}");
803 }
804 }
805
806 println!();
807}
808
809fn print_hierarchical_json_results(matches: &[HierarchicalMatchResult]) -> anyhow::Result<()> {
810 let output: Vec<serde_json::Value> = matches
811 .iter()
812 .map(|m| {
813 serde_json::json!({
814 "distribution": {
815 "id": m.distribution_id,
816 "display_name": m.display_name,
817 },
818 "assembly": {
819 "id": m.assembly_id,
820 "name": m.assembly_name,
821 "version_id": m.version_id,
822 "version": m.version_string,
823 },
824 "match_type": format!("{:?}", m.match_type),
825 "score": m.score,
826 "matched_contigs": m.matched_contigs,
827 "total_query_contigs": m.total_query_contigs,
828 "total_distribution_contigs": m.total_distribution_contigs,
829 "extra_in_query": m.extra_in_query,
830 "missing_from_query": m.missing_from_query,
831 "presence_counts": {
832 "in_both": m.presence_counts.in_both,
833 "fasta_only": m.presence_counts.fasta_only,
834 "report_only": m.presence_counts.report_only,
835 },
836 })
837 })
838 .collect();
839
840 println!("{}", serde_json::to_string_pretty(&output)?);
841 Ok(())
842}
843
844fn print_hierarchical_tsv_results(matches: &[HierarchicalMatchResult]) {
845 println!("rank\tdistribution_id\tdisplay_name\tassembly_id\tversion_id\tmatch_type\tscore\tmatched\tquery_total\tdist_total\tin_both\tfasta_only");
846 for (i, m) in matches.iter().enumerate() {
847 println!(
848 "{}\t{}\t{}\t{}\t{}\t{:?}\t{:.4}\t{}\t{}\t{}\t{}\t{}",
849 i + 1,
850 m.distribution_id,
851 m.display_name,
852 m.assembly_id,
853 m.version_id,
854 m.match_type,
855 m.score,
856 m.matched_contigs,
857 m.total_query_contigs,
858 m.total_distribution_contigs,
859 m.presence_counts.in_both,
860 m.presence_counts.fasta_only,
861 );
862 }
863}