rust-annovar 0.1.0.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/src/database.rs ADDED
@@ -0,0 +1,592 @@
1
+ use crate::model::{Annotation, AnnotationKind, Variant, normalize_chrom};
2
+ use anyhow::{Context, Result, bail};
3
+ use serde::{Deserialize, Serialize};
4
+ use sha2::{Digest, Sha256};
5
+ use std::collections::{BTreeMap, HashMap, HashSet};
6
+ use std::fs::{self, File};
7
+ use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
8
+ use std::path::{Path, PathBuf};
9
+ use std::time::UNIX_EPOCH;
10
+
11
+ #[derive(Debug, Clone)]
12
+ pub struct FilterRecord {
13
+ pub variant: Variant,
14
+ pub values: Vec<String>,
15
+ }
16
+
17
+ #[derive(Debug, Clone)]
18
+ pub struct RegionRecord {
19
+ pub chrom: String,
20
+ pub start: u64,
21
+ pub end: u64,
22
+ pub values: Vec<String>,
23
+ pub score: Option<f64>,
24
+ pub name: Option<String>,
25
+ }
26
+
27
+ #[derive(Debug)]
28
+ pub struct FilterDatabase {
29
+ pub headers: Vec<String>,
30
+ records: HashMap<OwnedKey, Vec<Vec<String>>>,
31
+ }
32
+
33
+ #[derive(Debug, Clone, PartialEq, Eq, Hash)]
34
+ struct OwnedKey {
35
+ chrom: String,
36
+ start: u64,
37
+ end: u64,
38
+ reference: String,
39
+ alternate: String,
40
+ }
41
+
42
+ impl FilterDatabase {
43
+ pub fn load(path: &Path) -> Result<Self> {
44
+ let reader = open_text(path)?;
45
+ let mut headers = Vec::new();
46
+ let mut records: HashMap<OwnedKey, Vec<Vec<String>>> = HashMap::new();
47
+ for (line_no, line) in reader.lines().enumerate() {
48
+ let line = line?;
49
+ if line.trim().is_empty() {
50
+ continue;
51
+ }
52
+ if line.starts_with('#') {
53
+ if headers.is_empty() && !line.starts_with("##") {
54
+ headers = line
55
+ .trim_start_matches('#')
56
+ .split('\t')
57
+ .skip(5)
58
+ .map(str::to_string)
59
+ .collect();
60
+ }
61
+ continue;
62
+ }
63
+ let fields: Vec<&str> = line.split('\t').collect();
64
+ if fields.len() < 5 {
65
+ bail!(
66
+ "{}:{}: filter database needs Chr,Start,End,Ref,Alt",
67
+ path.display(),
68
+ line_no + 1
69
+ );
70
+ }
71
+ let start: u64 = fields[1]
72
+ .parse()
73
+ .with_context(|| format!("invalid database start at line {}", line_no + 1))?;
74
+ let end: u64 = fields[2]
75
+ .parse()
76
+ .with_context(|| format!("invalid database end at line {}", line_no + 1))?;
77
+ let reference = db_allele(fields[3]);
78
+ let alternate = db_allele(fields[4]);
79
+ let internal_start = if reference.is_empty() {
80
+ start
81
+ } else {
82
+ start
83
+ .checked_sub(1)
84
+ .context("database coordinates must be one-based")?
85
+ };
86
+ let internal_end = if reference.is_empty() { start } else { end };
87
+ let key = OwnedKey {
88
+ chrom: normalize_chrom(fields[0]),
89
+ start: internal_start,
90
+ end: internal_end,
91
+ reference,
92
+ alternate,
93
+ };
94
+ records
95
+ .entry(key)
96
+ .or_default()
97
+ .push(fields[5..].iter().map(|v| (*v).to_string()).collect());
98
+ }
99
+ if headers.is_empty() {
100
+ let width = records
101
+ .values()
102
+ .next()
103
+ .and_then(|v| v.first())
104
+ .map_or(1, Vec::len);
105
+ headers = (1..=width)
106
+ .map(|i| {
107
+ if width == 1 {
108
+ "value".into()
109
+ } else {
110
+ format!("value{i}")
111
+ }
112
+ })
113
+ .collect();
114
+ if headers.is_empty() {
115
+ headers.push("match".to_string());
116
+ }
117
+ }
118
+ Ok(Self { headers, records })
119
+ }
120
+
121
+ /// Load only source-file bins needed by the query when a current
122
+ /// `.fai.json` sidecar is available. Falls back to a full load otherwise.
123
+ pub fn load_for_variants(path: &Path, variants: &[Variant]) -> Result<Self> {
124
+ let index_path = path.with_extension("fai.json");
125
+ let manifest = File::open(&index_path)
126
+ .ok()
127
+ .and_then(|file| serde_json::from_reader::<_, IndexManifest>(file).ok());
128
+ let Some(manifest) = manifest else {
129
+ return Self::load(path);
130
+ };
131
+ if manifest.bins.is_empty()
132
+ || !manifest.is_current()?
133
+ || path.extension().is_some_and(|ext| ext == "gz")
134
+ {
135
+ return Self::load(path);
136
+ }
137
+
138
+ let mut requested = HashSet::new();
139
+ for variant in variants {
140
+ requested.insert((variant.chrom.clone(), variant.start / manifest.bin_size));
141
+ requested.insert((
142
+ variant.chrom.clone(),
143
+ variant.start.saturating_sub(1) / manifest.bin_size,
144
+ ));
145
+ }
146
+ let mut ranges = requested
147
+ .into_iter()
148
+ .filter_map(|(chrom, bin)| manifest.bins.get(&chrom)?.get(&bin).copied())
149
+ .collect::<Vec<_>>();
150
+ ranges.sort_by_key(|range| range.start);
151
+ ranges.dedup();
152
+
153
+ let headers = read_filter_headers(path)?;
154
+ let mut records: HashMap<OwnedKey, Vec<Vec<String>>> = HashMap::new();
155
+ let mut file = File::open(path)?;
156
+ for range in ranges {
157
+ file.seek(SeekFrom::Start(range.start))?;
158
+ let mut bytes = vec![0; usize::try_from(range.end - range.start)?];
159
+ file.read_exact(&mut bytes)?;
160
+ for line in String::from_utf8(bytes)?.lines() {
161
+ if line.is_empty() || line.starts_with('#') {
162
+ continue;
163
+ }
164
+ let (key, values) = parse_filter_record(line)
165
+ .with_context(|| format!("invalid indexed record in {}", path.display()))?;
166
+ records.entry(key).or_default().push(values);
167
+ }
168
+ }
169
+ let headers = infer_filter_headers(headers, &records);
170
+ Ok(Self { headers, records })
171
+ }
172
+
173
+ pub fn annotate(&self, variant: &Variant, protocol: &str) -> Option<Annotation> {
174
+ let key = OwnedKey {
175
+ chrom: variant.chrom.clone(),
176
+ start: variant.start,
177
+ end: variant.end,
178
+ reference: variant.reference.clone(),
179
+ alternate: variant.alternate.clone(),
180
+ };
181
+ self.records.get(&key).map(|matches| Annotation {
182
+ protocol: protocol.to_string(),
183
+ kind: AnnotationKind::Filter,
184
+ values: transpose_join(matches),
185
+ })
186
+ }
187
+ }
188
+
189
+ #[derive(Debug)]
190
+ pub struct RegionDatabase {
191
+ pub headers: Vec<String>,
192
+ by_chrom: HashMap<String, Vec<RegionRecord>>,
193
+ gff3: bool,
194
+ }
195
+
196
+ impl RegionDatabase {
197
+ pub fn load(path: &Path) -> Result<Self> {
198
+ let reader = open_text(path)?;
199
+ let mut headers = Vec::new();
200
+ let mut by_chrom: HashMap<String, Vec<RegionRecord>> = HashMap::new();
201
+ let mut value_width = 0usize;
202
+ let mut gff3 = false;
203
+ for (line_no, line) in reader.lines().enumerate() {
204
+ let line = line?;
205
+ if line.trim().is_empty() {
206
+ continue;
207
+ }
208
+ if line.starts_with('#') {
209
+ if headers.is_empty() {
210
+ let columns = line.trim_start_matches('#').split('\t').collect::<Vec<_>>();
211
+ let coordinate_columns = if columns.first().is_some_and(|value| *value == "bin")
212
+ {
213
+ 4
214
+ } else {
215
+ 3
216
+ };
217
+ headers = columns
218
+ .into_iter()
219
+ .skip(coordinate_columns)
220
+ .map(str::to_string)
221
+ .collect();
222
+ }
223
+ continue;
224
+ }
225
+ let fields: Vec<&str> = line.split('\t').collect();
226
+ if fields.len() < 3 {
227
+ bail!(
228
+ "{}:{}: region database needs at least three columns",
229
+ path.display(),
230
+ line_no + 1
231
+ );
232
+ }
233
+ let (chrom, start, end, values, score, name) = if is_gff3(&fields) {
234
+ gff3 = true;
235
+ let start = fields[3]
236
+ .parse::<u64>()?
237
+ .checked_sub(1)
238
+ .context("GFF3 coordinates must be one-based")?;
239
+ (
240
+ normalize_chrom(fields[0]),
241
+ start,
242
+ fields[4].parse()?,
243
+ Vec::new(),
244
+ fields[5].parse::<f64>().ok(),
245
+ Some(gff3_id(fields[8])),
246
+ )
247
+ } else {
248
+ let (chrom_col, start_col, end_col, value_col) = detect_region_columns(&fields)?;
249
+ (
250
+ normalize_chrom(fields[chrom_col]),
251
+ fields[start_col].parse()?,
252
+ fields[end_col].parse()?,
253
+ fields[value_col..]
254
+ .iter()
255
+ .map(|v| (*v).to_string())
256
+ .collect(),
257
+ None,
258
+ None,
259
+ )
260
+ };
261
+ value_width = value_width.max(values.len());
262
+ by_chrom
263
+ .entry(chrom.clone())
264
+ .or_default()
265
+ .push(RegionRecord {
266
+ chrom,
267
+ start,
268
+ end,
269
+ values,
270
+ score,
271
+ name,
272
+ });
273
+ }
274
+ for records in by_chrom.values_mut() {
275
+ records.sort_by_key(|record| record.start);
276
+ }
277
+ if gff3 {
278
+ headers = vec!["value".to_string()];
279
+ } else if headers.is_empty() {
280
+ headers = (1..=value_width.max(1))
281
+ .map(|index| {
282
+ if value_width <= 1 {
283
+ "value".to_string()
284
+ } else {
285
+ format!("value{index}")
286
+ }
287
+ })
288
+ .collect();
289
+ } else {
290
+ while headers.len() < value_width {
291
+ headers.push(format!("value{}", headers.len() + 1));
292
+ }
293
+ }
294
+ Ok(Self {
295
+ headers,
296
+ by_chrom,
297
+ gff3,
298
+ })
299
+ }
300
+
301
+ pub fn annotate(
302
+ &self,
303
+ variant: &Variant,
304
+ protocol: &str,
305
+ min_overlap: f64,
306
+ ) -> Option<Annotation> {
307
+ let records = self.by_chrom.get(&variant.chrom)?;
308
+ let query_len = (variant.end.saturating_sub(variant.start)).max(1) as f64;
309
+ let mut hits: Vec<&RegionRecord> = Vec::new();
310
+ for record in records {
311
+ if record.start > variant.end {
312
+ break;
313
+ }
314
+ if variant.overlaps(record.start, record.end) {
315
+ let overlap = variant
316
+ .end
317
+ .min(record.end)
318
+ .saturating_sub(variant.start.max(record.start))
319
+ .max(1) as f64;
320
+ if overlap / query_len >= min_overlap {
321
+ hits.push(record);
322
+ }
323
+ }
324
+ }
325
+ if hits.is_empty() {
326
+ return None;
327
+ }
328
+ let values = if self.gff3 {
329
+ let max_score = hits
330
+ .iter()
331
+ .filter_map(|record| record.score)
332
+ .max_by(f64::total_cmp);
333
+ let names = hits
334
+ .iter()
335
+ .filter(|record| max_score.is_none() || record.score == max_score)
336
+ .filter_map(|record| record.name.as_deref())
337
+ .collect::<Vec<_>>()
338
+ .join(",");
339
+ let mut value = String::new();
340
+ if let Some(score) = max_score {
341
+ value.push_str(&format!("Score={score};"));
342
+ }
343
+ if !names.is_empty() {
344
+ value.push_str(&format!("Name={names}"));
345
+ }
346
+ vec![value]
347
+ } else {
348
+ transpose_join(
349
+ &hits
350
+ .iter()
351
+ .map(|record| record.values.clone())
352
+ .collect::<Vec<_>>(),
353
+ )
354
+ };
355
+ Some(Annotation {
356
+ protocol: protocol.to_string(),
357
+ kind: AnnotationKind::Region,
358
+ values,
359
+ })
360
+ }
361
+ }
362
+
363
+ #[derive(Debug, Serialize, Deserialize)]
364
+ pub struct IndexManifest {
365
+ pub format_version: u32,
366
+ pub source: PathBuf,
367
+ pub source_size: u64,
368
+ pub modified_unix_seconds: u64,
369
+ pub prefix_sha256: String,
370
+ pub kind: String,
371
+ pub bin_size: u64,
372
+ pub bins: HashMap<String, BTreeMap<u64, ByteRange>>,
373
+ }
374
+
375
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
376
+ pub struct ByteRange {
377
+ pub start: u64,
378
+ pub end: u64,
379
+ }
380
+
381
+ impl IndexManifest {
382
+ pub fn build(source: &Path, kind: &str) -> Result<Self> {
383
+ let (source_size, modified_unix_seconds, prefix_sha256) = source_fingerprint(source)?;
384
+ let bin_size = 1_000_000;
385
+ let bins = if matches!(kind, "filter" | "generic")
386
+ && source.extension().is_none_or(|ext| ext != "gz")
387
+ {
388
+ build_filter_bins(source, bin_size)?
389
+ } else {
390
+ HashMap::new()
391
+ };
392
+ Ok(Self {
393
+ format_version: 1,
394
+ source: source.to_path_buf(),
395
+ source_size,
396
+ modified_unix_seconds,
397
+ prefix_sha256,
398
+ kind: kind.to_string(),
399
+ bin_size,
400
+ bins,
401
+ })
402
+ }
403
+
404
+ pub fn write(&self, path: &Path) -> Result<()> {
405
+ let temp = path.with_extension("tmp");
406
+ let mut writer = File::create(&temp)?;
407
+ serde_json::to_writer_pretty(&mut writer, self)?;
408
+ writeln!(writer)?;
409
+ fs::rename(temp, path)?;
410
+ Ok(())
411
+ }
412
+
413
+ pub fn is_current(&self) -> Result<bool> {
414
+ let (size, modified, sha256) = source_fingerprint(&self.source)?;
415
+ Ok(size == self.source_size
416
+ && modified == self.modified_unix_seconds
417
+ && sha256 == self.prefix_sha256)
418
+ }
419
+ }
420
+
421
+ fn source_fingerprint(source: &Path) -> Result<(u64, u64, String)> {
422
+ let metadata = fs::metadata(source)?;
423
+ let modified = metadata.modified()?.duration_since(UNIX_EPOCH)?.as_secs();
424
+ let mut reader = File::open(source)?;
425
+ let mut limited = std::io::Read::by_ref(&mut reader).take(1024 * 1024);
426
+ let mut hasher = Sha256::new();
427
+ std::io::copy(&mut limited, &mut hasher)?;
428
+ Ok((metadata.len(), modified, format!("{:x}", hasher.finalize())))
429
+ }
430
+
431
+ fn build_filter_bins(
432
+ source: &Path,
433
+ bin_size: u64,
434
+ ) -> Result<HashMap<String, BTreeMap<u64, ByteRange>>> {
435
+ let mut reader = BufReader::new(File::open(source)?);
436
+ let mut bins: HashMap<String, BTreeMap<u64, ByteRange>> = HashMap::new();
437
+ let mut line = String::new();
438
+ loop {
439
+ let start = reader.stream_position()?;
440
+ line.clear();
441
+ if reader.read_line(&mut line)? == 0 {
442
+ break;
443
+ }
444
+ let end = reader.stream_position()?;
445
+ if line.trim().is_empty() || line.starts_with('#') {
446
+ continue;
447
+ }
448
+ let fields = line.split('\t').collect::<Vec<_>>();
449
+ if fields.len() < 2 {
450
+ continue;
451
+ }
452
+ let Ok(position) = fields[1].parse::<u64>() else {
453
+ continue;
454
+ };
455
+ let chrom = normalize_chrom(fields[0]);
456
+ let bin = position.saturating_sub(1) / bin_size;
457
+ bins.entry(chrom)
458
+ .or_default()
459
+ .entry(bin)
460
+ .and_modify(|range| range.end = end)
461
+ .or_insert(ByteRange { start, end });
462
+ }
463
+ Ok(bins)
464
+ }
465
+
466
+ fn open_text(path: &Path) -> Result<Box<dyn BufRead>> {
467
+ crate::io::open_reader(path)
468
+ }
469
+
470
+ fn parse_filter_record(line: &str) -> Result<(OwnedKey, Vec<String>)> {
471
+ let fields: Vec<&str> = line.split('\t').collect();
472
+ if fields.len() < 5 {
473
+ bail!("filter database needs Chr,Start,End,Ref,Alt");
474
+ }
475
+ let start: u64 = fields[1].parse()?;
476
+ let end: u64 = fields[2].parse()?;
477
+ let reference = db_allele(fields[3]);
478
+ let alternate = db_allele(fields[4]);
479
+ let internal_start = if reference.is_empty() {
480
+ start
481
+ } else {
482
+ start
483
+ .checked_sub(1)
484
+ .context("database coordinates must be one-based")?
485
+ };
486
+ let internal_end = if reference.is_empty() { start } else { end };
487
+ Ok((
488
+ OwnedKey {
489
+ chrom: normalize_chrom(fields[0]),
490
+ start: internal_start,
491
+ end: internal_end,
492
+ reference,
493
+ alternate,
494
+ },
495
+ fields[5..]
496
+ .iter()
497
+ .map(|value| (*value).to_string())
498
+ .collect(),
499
+ ))
500
+ }
501
+
502
+ fn read_filter_headers(path: &Path) -> Result<Vec<String>> {
503
+ for line in open_text(path)?.lines() {
504
+ let line = line?;
505
+ if line.starts_with('#') {
506
+ return Ok(line
507
+ .trim_start_matches('#')
508
+ .split('\t')
509
+ .skip(5)
510
+ .map(str::to_string)
511
+ .collect());
512
+ }
513
+ if !line.trim().is_empty() {
514
+ break;
515
+ }
516
+ }
517
+ Ok(Vec::new())
518
+ }
519
+
520
+ fn infer_filter_headers(
521
+ mut headers: Vec<String>,
522
+ records: &HashMap<OwnedKey, Vec<Vec<String>>>,
523
+ ) -> Vec<String> {
524
+ if headers.is_empty() {
525
+ let width = records
526
+ .values()
527
+ .next()
528
+ .and_then(|values| values.first())
529
+ .map_or(1, Vec::len)
530
+ .max(1);
531
+ headers = (1..=width)
532
+ .map(|index| {
533
+ if width == 1 {
534
+ "value".to_string()
535
+ } else {
536
+ format!("value{index}")
537
+ }
538
+ })
539
+ .collect();
540
+ }
541
+ headers
542
+ }
543
+
544
+ fn db_allele(value: &str) -> String {
545
+ if value == "-" || value == "0" {
546
+ String::new()
547
+ } else {
548
+ value.to_ascii_uppercase()
549
+ }
550
+ }
551
+
552
+ fn transpose_join(rows: &[Vec<String>]) -> Vec<String> {
553
+ let width = rows.iter().map(Vec::len).max().unwrap_or(0);
554
+ (0..width)
555
+ .map(|column| {
556
+ rows.iter()
557
+ .filter_map(|row| row.get(column))
558
+ .cloned()
559
+ .collect::<Vec<_>>()
560
+ .join(";")
561
+ })
562
+ .collect()
563
+ }
564
+
565
+ fn detect_region_columns(fields: &[&str]) -> Result<(usize, usize, usize, usize)> {
566
+ if fields.len() >= 4
567
+ && fields[0].parse::<u64>().is_ok()
568
+ && fields[2].parse::<u64>().is_ok()
569
+ && fields[3].parse::<u64>().is_ok()
570
+ {
571
+ Ok((1, 2, 3, 4)) // UCSC bin, chrom, chromStart, chromEnd
572
+ } else if fields[1].parse::<u64>().is_ok() && fields[2].parse::<u64>().is_ok() {
573
+ Ok((0, 1, 2, 3))
574
+ } else {
575
+ bail!("unable to detect region database coordinate columns")
576
+ }
577
+ }
578
+
579
+ fn is_gff3(fields: &[&str]) -> bool {
580
+ fields.len() >= 9
581
+ && fields[3].parse::<u64>().is_ok()
582
+ && fields[4].parse::<u64>().is_ok()
583
+ && matches!(fields[6], "+" | "-" | "." | "?")
584
+ }
585
+
586
+ fn gff3_id(attributes: &str) -> String {
587
+ attributes
588
+ .split(';')
589
+ .find_map(|field| field.trim().strip_prefix("ID="))
590
+ .unwrap_or("NA")
591
+ .to_string()
592
+ }