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/io.rs ADDED
@@ -0,0 +1,217 @@
1
+ use crate::model::Variant;
2
+ use anyhow::{Context, Result, bail};
3
+ use flate2::read::MultiGzDecoder;
4
+ use std::fs::File;
5
+ use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
6
+ use std::path::Path;
7
+
8
+ pub fn open_reader(path: &Path) -> Result<Box<dyn BufRead>> {
9
+ if path == Path::new("-") {
10
+ return Ok(Box::new(BufReader::new(io::stdin())));
11
+ }
12
+ let file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
13
+ if path.extension().is_some_and(|ext| ext == "gz") {
14
+ Ok(Box::new(BufReader::new(MultiGzDecoder::new(file))))
15
+ } else {
16
+ Ok(Box::new(BufReader::new(file)))
17
+ }
18
+ }
19
+
20
+ pub fn open_writer(path: &Path) -> Result<Box<dyn Write>> {
21
+ if path == Path::new("-") {
22
+ Ok(Box::new(BufWriter::new(io::stdout())))
23
+ } else {
24
+ Ok(Box::new(BufWriter::new(File::create(path).with_context(
25
+ || format!("cannot create {}", path.display()),
26
+ )?)))
27
+ }
28
+ }
29
+
30
+ pub fn read_avinput(path: &Path) -> Result<Vec<Variant>> {
31
+ let reader = open_reader(path)?;
32
+ let mut variants = Vec::new();
33
+ for (line_no, line) in reader.lines().enumerate() {
34
+ let line = line.with_context(|| format!("failed reading line {}", line_no + 1))?;
35
+ if line.trim().is_empty() || line.starts_with('#') {
36
+ continue;
37
+ }
38
+ let (fields, remainder) = split_avinput_fields(&line);
39
+ if fields.len() < 5 {
40
+ bail!(
41
+ "{}:{}: expected at least five AVinput columns",
42
+ path.display(),
43
+ line_no + 1
44
+ );
45
+ }
46
+ let av_start: u64 = fields[1]
47
+ .parse()
48
+ .with_context(|| format!("invalid start at line {}", line_no + 1))?;
49
+ let av_end: u64 = fields[2]
50
+ .parse()
51
+ .with_context(|| format!("invalid end at line {}", line_no + 1))?;
52
+ let reference = allele_from_avinput(fields[3]);
53
+ let alternate = allele_from_avinput(fields[4]);
54
+ let (start, end) = if reference.is_empty() {
55
+ (av_start, av_start)
56
+ } else {
57
+ if av_start == 0 {
58
+ bail!("AVinput coordinates are one-based at line {}", line_no + 1);
59
+ }
60
+ (av_start - 1, av_end)
61
+ };
62
+ let mut variant = Variant::new(fields[0], start, end, reference, alternate)?;
63
+ variant.output_chrom = fields[0].to_string();
64
+ variant.extra = remainder
65
+ .filter(|value| !value.is_empty())
66
+ .map(|value| value.split('\t').map(str::to_string).collect())
67
+ .unwrap_or_default();
68
+ variant.source_line = line_no + 1;
69
+ variants.push(variant);
70
+ }
71
+ Ok(variants)
72
+ }
73
+
74
+ pub fn read_vcf(path: &Path) -> Result<VcfDocument> {
75
+ let mut reader = open_reader(path)?;
76
+ let mut text = String::new();
77
+ reader.read_to_string(&mut text)?;
78
+ let mut headers = Vec::new();
79
+ let mut records = Vec::new();
80
+ let mut variants = Vec::new();
81
+ for (line_no, line) in text.lines().enumerate() {
82
+ if line.starts_with('#') {
83
+ headers.push(line.to_string());
84
+ continue;
85
+ }
86
+ if line.trim().is_empty() {
87
+ continue;
88
+ }
89
+ let fields: Vec<String> = line.split('\t').map(str::to_string).collect();
90
+ if fields.len() < 8 {
91
+ bail!(
92
+ "{}:{}: VCF requires at least eight columns",
93
+ path.display(),
94
+ line_no + 1
95
+ );
96
+ }
97
+ let pos: u64 = fields[1]
98
+ .parse()
99
+ .with_context(|| format!("invalid VCF POS at line {}", line_no + 1))?;
100
+ if pos == 0 {
101
+ bail!("VCF POS is one-based at line {}", line_no + 1);
102
+ }
103
+ let reference = fields[3].to_ascii_uppercase();
104
+ let alts: Vec<&str> = fields[4].split(',').collect();
105
+ let record_index = records.len();
106
+ for (allele_index, alt) in alts.iter().enumerate() {
107
+ if alt.starts_with('<')
108
+ || *alt == "*"
109
+ || *alt == "."
110
+ || alt.contains('[')
111
+ || alt.contains(']')
112
+ {
113
+ continue;
114
+ }
115
+ let (start, end, normalized_ref, normalized_alt) =
116
+ normalize_vcf_alleles(pos - 1, &reference, alt);
117
+ let mut variant = Variant::new(&fields[0], start, end, normalized_ref, normalized_alt)?;
118
+ variant.source_line = line_no + 1;
119
+ variant.source_record = Some(record_index);
120
+ variant.allele_index = allele_index;
121
+ variants.push(variant);
122
+ }
123
+ records.push(fields);
124
+ }
125
+ Ok(VcfDocument {
126
+ headers,
127
+ records,
128
+ variants,
129
+ })
130
+ }
131
+
132
+ /// Trim identical suffixes and prefixes while preserving at least one side of
133
+ /// the event. Left alignment against a reference genome is intentionally a
134
+ /// separate operation.
135
+ pub fn normalize_vcf_alleles(
136
+ mut start: u64,
137
+ reference: &str,
138
+ alternate: &str,
139
+ ) -> (u64, u64, String, String) {
140
+ let mut r = reference.as_bytes().to_vec();
141
+ let mut a = alternate.as_bytes().to_vec();
142
+ while r.len() > 1 && a.len() > 1 && r.last() == a.last() {
143
+ r.pop();
144
+ a.pop();
145
+ }
146
+ while r.len() > 1 && a.len() > 1 && r.first() == a.first() {
147
+ r.remove(0);
148
+ a.remove(0);
149
+ start += 1;
150
+ }
151
+ if r.len() == 1 && a.len() > 1 && r[0] == a[0] {
152
+ start += 1;
153
+ a.remove(0);
154
+ r.clear();
155
+ } else if a.len() == 1 && r.len() > 1 && a[0] == r[0] {
156
+ start += 1;
157
+ r.remove(0);
158
+ a.clear();
159
+ }
160
+ let end = start + r.len() as u64;
161
+ (
162
+ start,
163
+ end,
164
+ String::from_utf8(r).unwrap(),
165
+ String::from_utf8(a).unwrap(),
166
+ )
167
+ }
168
+
169
+ pub fn write_avinput(variants: &[Variant], path: &Path, include_info: bool) -> Result<()> {
170
+ let mut writer = open_writer(path)?;
171
+ for variant in variants {
172
+ let mut fields = variant.avinput_fields().to_vec();
173
+ if include_info {
174
+ fields.extend(variant.extra.clone());
175
+ }
176
+ writeln!(writer, "{}", fields.join("\t"))?;
177
+ }
178
+ Ok(())
179
+ }
180
+
181
+ #[derive(Debug, Clone)]
182
+ pub struct VcfDocument {
183
+ pub headers: Vec<String>,
184
+ pub records: Vec<Vec<String>>,
185
+ pub variants: Vec<Variant>,
186
+ }
187
+
188
+ fn allele_from_avinput(value: &str) -> String {
189
+ if value == "-" {
190
+ String::new()
191
+ } else {
192
+ value.to_ascii_uppercase()
193
+ }
194
+ }
195
+
196
+ fn split_avinput_fields(line: &str) -> (Vec<&str>, Option<&str>) {
197
+ let bytes = line.as_bytes();
198
+ let mut fields = Vec::with_capacity(5);
199
+ let mut cursor = 0usize;
200
+ while fields.len() < 5 {
201
+ while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
202
+ cursor += 1;
203
+ }
204
+ if cursor == bytes.len() {
205
+ break;
206
+ }
207
+ let start = cursor;
208
+ while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() {
209
+ cursor += 1;
210
+ }
211
+ fields.push(&line[start..cursor]);
212
+ }
213
+ while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
214
+ cursor += 1;
215
+ }
216
+ (fields, (cursor < line.len()).then(|| &line[cursor..]))
217
+ }
data/src/lib.rs ADDED
@@ -0,0 +1,12 @@
1
+ //! Core annotation engine for `RustAnnovar`.
2
+ //!
3
+ //! Coordinates are always represented internally as zero-based half-open
4
+ //! intervals. Parsers and writers are responsible for conversion at the edge.
5
+
6
+ pub mod database;
7
+ pub mod gene;
8
+ pub mod io;
9
+ pub mod model;
10
+ pub mod pipeline;
11
+
12
+ pub use model::{Annotation, AnnotationKind, Variant};
data/src/main.rs ADDED
@@ -0,0 +1,376 @@
1
+ use anyhow::{Context, Result, bail};
2
+ use clap::{Args, Parser, Subcommand, ValueEnum};
3
+ use rust_annovar::database::IndexManifest;
4
+ use rust_annovar::io::{read_avinput, read_vcf, write_avinput};
5
+ use rust_annovar::pipeline::{
6
+ Operation, annotate_table, resolve_protocols, write_annotated_vcf, write_table,
7
+ };
8
+ use sha2::{Digest, Sha256};
9
+ use std::fs;
10
+ use std::io::{Read, Write};
11
+ use std::path::{Path, PathBuf};
12
+
13
+ #[derive(Parser)]
14
+ #[command(
15
+ version,
16
+ about = "Fast genomic variant annotation with ANNOVAR-compatible databases"
17
+ )]
18
+ struct Cli {
19
+ #[command(subcommand)]
20
+ command: Command,
21
+ }
22
+
23
+ #[derive(Subcommand)]
24
+ enum Command {
25
+ /// Convert VCF to ANNOVAR's five-column input format.
26
+ Convert(ConvertArgs),
27
+ /// Run one annotation database.
28
+ Annotate(AnnotateArgs),
29
+ /// Combine multiple gene, region, and filter annotations.
30
+ Table(TableArgs),
31
+ /// Manage and validate local database metadata.
32
+ Db(DbArgs),
33
+ /// Extract reference sequence for AVinput intervals.
34
+ Sequence(SequenceArgs),
35
+ /// Report coding consequences (a gene annotation convenience command).
36
+ CodingChange(AnnotateArgs),
37
+ /// Select rows from a multi-annotation table using a simple predicate.
38
+ Reduce(ReduceArgs),
39
+ }
40
+
41
+ #[derive(Args)]
42
+ struct ConvertArgs {
43
+ input: PathBuf,
44
+ #[arg(short, long, default_value = "-")]
45
+ output: PathBuf,
46
+ #[arg(long)]
47
+ include_info: bool,
48
+ }
49
+
50
+ #[derive(Clone, ValueEnum)]
51
+ enum OpArg {
52
+ Gene,
53
+ Region,
54
+ Filter,
55
+ }
56
+ impl From<OpArg> for Operation {
57
+ fn from(value: OpArg) -> Self {
58
+ match value {
59
+ OpArg::Gene => Self::Gene,
60
+ OpArg::Region => Self::Region,
61
+ OpArg::Filter => Self::Filter,
62
+ }
63
+ }
64
+ }
65
+
66
+ #[derive(Args)]
67
+ struct AnnotateArgs {
68
+ input: PathBuf,
69
+ database: PathBuf,
70
+ #[arg(short, long)]
71
+ output: PathBuf,
72
+ #[arg(long, value_enum, default_value = "filter")]
73
+ operation: OpArg,
74
+ #[arg(long, default_value = "annotation")]
75
+ protocol: String,
76
+ #[arg(long)]
77
+ fasta: Option<PathBuf>,
78
+ #[arg(long)]
79
+ vcf_input: bool,
80
+ #[arg(long, default_value = ".")]
81
+ nastring: String,
82
+ }
83
+
84
+ #[derive(Args)]
85
+ struct TableArgs {
86
+ input: PathBuf,
87
+ db_dir: PathBuf,
88
+ #[arg(long)]
89
+ build: String,
90
+ #[arg(long, value_delimiter = ',')]
91
+ protocol: Vec<String>,
92
+ #[arg(long, value_delimiter = ',')]
93
+ operation: Vec<String>,
94
+ #[arg(short, long)]
95
+ output: PathBuf,
96
+ #[arg(long)]
97
+ vcf_input: bool,
98
+ #[arg(long)]
99
+ csv: bool,
100
+ #[arg(long)]
101
+ vcf_output: Option<PathBuf>,
102
+ #[arg(long, default_value = ".")]
103
+ nastring: String,
104
+ }
105
+
106
+ #[derive(Args)]
107
+ struct DbArgs {
108
+ #[command(subcommand)]
109
+ command: DbCommand,
110
+ }
111
+ #[derive(Subcommand)]
112
+ enum DbCommand {
113
+ Index {
114
+ database: PathBuf,
115
+ #[arg(long, default_value = "generic")]
116
+ kind: String,
117
+ #[arg(short, long)]
118
+ output: Option<PathBuf>,
119
+ },
120
+ Check {
121
+ index: PathBuf,
122
+ },
123
+ List {
124
+ db_dir: PathBuf,
125
+ #[arg(long)]
126
+ build: Option<String>,
127
+ },
128
+ /// Download a public database atomically, with optional SHA-256 verification.
129
+ Download {
130
+ url: String,
131
+ output: PathBuf,
132
+ #[arg(long)]
133
+ sha256: Option<String>,
134
+ },
135
+ }
136
+
137
+ #[derive(Args)]
138
+ struct SequenceArgs {
139
+ input: PathBuf,
140
+ fasta: PathBuf,
141
+ #[arg(short, long)]
142
+ output: PathBuf,
143
+ }
144
+
145
+ #[derive(Args)]
146
+ struct ReduceArgs {
147
+ input: PathBuf,
148
+ #[arg(short, long)]
149
+ output: PathBuf,
150
+ #[arg(long)]
151
+ column: String,
152
+ #[arg(long)]
153
+ equals: String,
154
+ }
155
+
156
+ fn main() {
157
+ if let Err(error) = run() {
158
+ eprintln!("error: {error:#}");
159
+ std::process::exit(2);
160
+ }
161
+ }
162
+
163
+ fn run() -> Result<()> {
164
+ match Cli::parse().command {
165
+ Command::Convert(args) => {
166
+ let document = read_vcf(&args.input)?;
167
+ write_avinput(&document.variants, &args.output, args.include_info)?;
168
+ }
169
+ Command::Annotate(args) => run_annotate(args, None)?,
170
+ Command::CodingChange(mut args) => {
171
+ args.operation = OpArg::Gene;
172
+ run_annotate(args, None)?;
173
+ }
174
+ Command::Table(args) => run_table(args)?,
175
+ Command::Db(args) => run_db(args.command)?,
176
+ Command::Sequence(args) => run_sequence(args)?,
177
+ Command::Reduce(args) => run_reduce(args)?,
178
+ }
179
+ Ok(())
180
+ }
181
+
182
+ fn run_annotate(args: AnnotateArgs, _reserved: Option<()>) -> Result<()> {
183
+ let operation = Operation::from(args.operation);
184
+ let document = args.vcf_input.then(|| read_vcf(&args.input)).transpose()?;
185
+ let variants = if let Some(doc) = &document {
186
+ doc.variants.clone()
187
+ } else {
188
+ read_avinput(&args.input)?
189
+ };
190
+ let protocol = rust_annovar::pipeline::Protocol {
191
+ name: args.protocol,
192
+ operation,
193
+ database: args.database,
194
+ fasta: args.fasta,
195
+ };
196
+ let result = annotate_table(&variants, &[protocol], &args.nastring)?;
197
+ write_table(&result, &args.output, false)
198
+ }
199
+
200
+ fn run_table(args: TableArgs) -> Result<()> {
201
+ let operations = args
202
+ .operation
203
+ .iter()
204
+ .map(|value| Operation::parse(value))
205
+ .collect::<Result<Vec<_>>>()?;
206
+ let protocols = resolve_protocols(&args.db_dir, &args.build, &args.protocol, &operations)?;
207
+ let document = args.vcf_input.then(|| read_vcf(&args.input)).transpose()?;
208
+ let variants = if let Some(doc) = &document {
209
+ doc.variants.clone()
210
+ } else {
211
+ read_avinput(&args.input)?
212
+ };
213
+ let result = annotate_table(&variants, &protocols, &args.nastring)?;
214
+ write_table(&result, &args.output, args.csv)?;
215
+ if let (Some(document), Some(vcf_output)) = (&document, args.vcf_output) {
216
+ write_annotated_vcf(document, &result, &protocols, &vcf_output, &args.nastring)?;
217
+ }
218
+ Ok(())
219
+ }
220
+
221
+ fn run_db(command: DbCommand) -> Result<()> {
222
+ match command {
223
+ DbCommand::Index {
224
+ database,
225
+ kind,
226
+ output,
227
+ } => {
228
+ let output = output.unwrap_or_else(|| database.with_extension("fai.json"));
229
+ IndexManifest::build(&database, &kind)?.write(&output)?;
230
+ println!("{}", output.display());
231
+ }
232
+ DbCommand::Check { index } => {
233
+ let manifest: IndexManifest = serde_json::from_reader(fs::File::open(&index)?)?;
234
+ if !manifest.is_current()? {
235
+ bail!("stale index: {}", index.display());
236
+ }
237
+ println!("current");
238
+ }
239
+ DbCommand::List { db_dir, build } => {
240
+ let prefix = build.map(|v| format!("{v}_"));
241
+ let mut entries = fs::read_dir(db_dir)?
242
+ .filter_map(Result::ok)
243
+ .map(|e| e.path())
244
+ .filter(|path| {
245
+ path.extension().is_some_and(|ext| ext == "txt")
246
+ && prefix.as_ref().is_none_or(|p| {
247
+ path.file_name()
248
+ .unwrap_or_default()
249
+ .to_string_lossy()
250
+ .starts_with(p)
251
+ })
252
+ })
253
+ .collect::<Vec<_>>();
254
+ entries.sort();
255
+ for path in entries {
256
+ println!("{}", path.display());
257
+ }
258
+ }
259
+ DbCommand::Download {
260
+ url,
261
+ output,
262
+ sha256,
263
+ } => download_database(&url, &output, sha256.as_deref())?,
264
+ }
265
+ Ok(())
266
+ }
267
+
268
+ fn download_database(url: &str, output: &Path, expected_sha256: Option<&str>) -> Result<()> {
269
+ let parent = output.parent().unwrap_or_else(|| Path::new("."));
270
+ fs::create_dir_all(parent)?;
271
+ let temp = output.with_extension("download.tmp");
272
+ let result = (|| -> Result<()> {
273
+ let response = ureq::get(url)
274
+ .call()
275
+ .with_context(|| format!("failed to download {url}"))?;
276
+ let mut reader = response.into_reader();
277
+ let mut writer = fs::File::create(&temp)?;
278
+ let mut hasher = Sha256::new();
279
+ let mut buffer = [0u8; 64 * 1024];
280
+ loop {
281
+ let size = reader.read(&mut buffer)?;
282
+ if size == 0 {
283
+ break;
284
+ }
285
+ writer.write_all(&buffer[..size])?;
286
+ hasher.update(&buffer[..size]);
287
+ }
288
+ writer.sync_all()?;
289
+ let actual = format!("{:x}", hasher.finalize());
290
+ if let Some(expected) = expected_sha256
291
+ && !actual.eq_ignore_ascii_case(expected)
292
+ {
293
+ bail!("SHA-256 mismatch: expected {expected}, got {actual}");
294
+ }
295
+ fs::rename(&temp, output)?;
296
+ println!("{actual} {}", output.display());
297
+ Ok(())
298
+ })();
299
+ if result.is_err() {
300
+ let _ = fs::remove_file(&temp);
301
+ }
302
+ result
303
+ }
304
+
305
+ fn run_sequence(args: SequenceArgs) -> Result<()> {
306
+ let genome = read_fasta_genome(&args.fasta)?;
307
+ let variants = read_avinput(&args.input)?;
308
+ let mut output = String::new();
309
+ for (index, variant) in variants.iter().enumerate() {
310
+ let sequence = genome
311
+ .get(&variant.chrom)
312
+ .context("chromosome missing from FASTA")?;
313
+ let end = usize::try_from(variant.end)?;
314
+ let start = usize::try_from(variant.start)?;
315
+ if end > sequence.len() {
316
+ bail!("variant {} exceeds chromosome length", index + 1);
317
+ }
318
+ output.push_str(&format!(
319
+ ">variant_{} {}:{}-{}\n{}\n",
320
+ index + 1,
321
+ variant.chrom,
322
+ variant.start + 1,
323
+ variant.end,
324
+ String::from_utf8_lossy(&sequence[start..end])
325
+ ));
326
+ }
327
+ fs::write(args.output, output)?;
328
+ Ok(())
329
+ }
330
+
331
+ fn read_fasta_genome(path: &Path) -> Result<std::collections::HashMap<String, Vec<u8>>> {
332
+ use std::io::BufRead;
333
+ let mut map = std::collections::HashMap::new();
334
+ let mut id = None::<String>;
335
+ let mut seq = Vec::new();
336
+ for line in rust_annovar::io::open_reader(path)?.lines() {
337
+ let line = line?;
338
+ if let Some(header) = line.strip_prefix('>') {
339
+ if let Some(previous) = id.replace(rust_annovar::model::normalize_chrom(
340
+ header
341
+ .split_whitespace()
342
+ .next()
343
+ .context("empty FASTA header")?,
344
+ )) {
345
+ map.insert(previous, std::mem::take(&mut seq));
346
+ }
347
+ } else {
348
+ seq.extend(line.trim().as_bytes().iter().map(u8::to_ascii_uppercase));
349
+ }
350
+ }
351
+ if let Some(id) = id {
352
+ map.insert(id, seq);
353
+ }
354
+ Ok(map)
355
+ }
356
+
357
+ fn run_reduce(args: ReduceArgs) -> Result<()> {
358
+ let content = fs::read_to_string(&args.input)?;
359
+ let mut lines = content.lines();
360
+ let header = lines.next().context("empty table")?;
361
+ let headers: Vec<&str> = header.split('\t').collect();
362
+ let column = headers
363
+ .iter()
364
+ .position(|name| *name == args.column)
365
+ .with_context(|| format!("unknown column {}", args.column))?;
366
+ let mut output = String::from(header);
367
+ output.push('\n');
368
+ for line in lines {
369
+ if line.split('\t').nth(column) == Some(args.equals.as_str()) {
370
+ output.push_str(line);
371
+ output.push('\n');
372
+ }
373
+ }
374
+ fs::write(args.output, output)?;
375
+ Ok(())
376
+ }