如果你想做一个 Rust 版的 tsv/csv 命令行工具,同时支持:
tsv/csv
我建议不要把 CLI 参数直接设计成一堆零散参数,而是把核心抽象成 Column Expression / Column Operation。
例如工具叫 biox-tsv:
biox-tsv
# 选择列 biox-tsv input.tsv \ --select SNP,CHR,BETA,P # 重命名 biox-tsv input.tsv \ --rename SNP=variant \ --rename BETA=beta # 选择 + 重命名 biox-tsv input.tsv \ --select SNP,BETA,P \ --rename SNP=variant \ --rename BETA=beta # 调整顺序 biox-tsv input.tsv \ --select P,BETA,SNP # 输出 biox-tsv input.tsv \ --select SNP,BETA,P \ --rename SNP=variant \ -o output.tsv
我比较推荐 --select + --rename 这套 API,因为语义非常直观。
--select
--rename
Vec<Vec<String>>
例如:
struct Column { index: usize, name: String, }
读取 header 后:
SNP CHR POS BETA SE P
变成:
vec![ Column { index: 0, name: "SNP" }, Column { index: 1, name: "CHR" }, Column { index: 2, name: "POS" }, Column { index: 3, name: "BETA" }, Column { index: 4, name: "SE" }, Column { index: 5, name: "P" }, ]
然后 --select 负责产生一个 projection:
struct ColumnSelection { source_index: usize, output_name: String, }
--select SNP,BETA,P
得到:
vec![ ColumnSelection { source_index: 0, output_name: "SNP", }, ColumnSelection { source_index: 3, output_name: "BETA", }, ColumnSelection { source_index: 5, output_name: "P", }, ]
如果再:
--rename SNP=variant
那么最终:
vec![ ColumnSelection { source_index: 0, output_name: "variant", }, ColumnSelection { source_index: 3, output_name: "BETA", }, ColumnSelection { source_index: 5, output_name: "P", }, ]
这样 rename 和 select 本身就解耦了。
ColumnOp
如果你后面准备把 biox-tsv 做成一个比较完整的 Rust 生信工具,我会进一步设计成:
enum ColumnOp { Select { column: ColumnRef, }, Rename { from: String, to: String, }, Drop { column: ColumnRef, }, }
但是实际上 Rename 不一定需要作为独立的执行操作。
Rename
可以让:
输入列 ↓ 解析 column expressions ↓ ColumnPlan ↓ 逐行 projection ↓ 输出
核心结构:
struct ColumnPlan { columns: Vec<OutputColumn>, } struct OutputColumn { source_index: usize, source_name: String, output_name: String, }
执行:
--select SNP,BETA,P --rename SNP=variant
最终:
ColumnPlan { columns: vec![ OutputColumn { source_index: 0, source_name: "SNP", output_name: "variant", }, OutputColumn { source_index: 3, source_name: "BETA", output_name: "BETA", }, OutputColumn { source_index: 5, source_name: "P", output_name: "P", }, ], }
这样处理数据的时候非常简单:
for record in reader.records() { let record = record?; for column in &plan.columns { output.write_field(&record[column.source_index])?; } }
不会因为 rename 而修改原始数据的列索引。
我比较推荐:
--select SNP,CHR
同时允许:
--select SNP --select CHR
甚至:
--select SNP,BETA,P --select SE
最终 CLI parser 合并成:
Vec<String>
例如使用 clap:
clap
#[derive(Parser)] struct Cli { input: String, #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append)] select: Vec<String>, #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append)] rename: Vec<String>, #[arg(short, long)] output: Option<String>, }
不过 rename 最好不要直接用 String,可以定义自己的 parser。
rename
String
old=new
CLI:
--rename SNP=variant --rename BETA=beta
解析成:
struct Rename { from: String, to: String, }
然后:
impl FromStr for Rename { type Err = anyhow::Error; fn from_str(s: &str) -> Result<Self> { let (from, to) = s .split_once('=') .ok_or_else(|| anyhow!("rename must be OLD=NEW"))?; Ok(Self { from: from.to_string(), to: to.to_string(), }) } }
#[arg(long, value_parser = parse_rename)] rename: Vec<Rename>,
这样用户输入错误时可以直接得到:
error: invalid value 'SNP' rename must be OLD=NEW
--drop
既然已经有 --select,其实:
--drop SE,CHR
也很自然。
biox-tsv input.tsv \ --drop CHR,POS
但这里有一个设计选择:
或者:
--drop CHR,POS
不能同时使用。
这是我更推荐的第一版设计。
因为:
已经明确表达了最终输出什么。
对于 GWAS 数据特别有用。
比如:
biox-tsv input.tsv \ --select SNP=BETA,P
--select SNP:variant,BETA:beta,P:pvalue
那么就可以一步完成:
input: SNP BETA P output: variant beta pvalue
内部直接解析成:
struct ColumnExpr { source: ColumnRef, alias: Option<String>, }
ColumnExpr { source: ColumnRef::Name("SNP"), alias: Some("variant".into()), }
这实际上比:
--select SNP,BETA,P \ --rename SNP=variant \ --rename BETA=beta
更加适合命令行数据处理。
biox-tsv input.tsv \ --select SNP,BETA,P \ --rename SNP=variant
biox-tsv input.tsv \ --select SNP:variant,BETA:beta,P:pvalue
其中:
SNP:variant │ │ │ └── output name └────── input column
以后还可以自然扩展:
到:
--select SNP:variant,log(BETA):log_beta
--select SNP,CHR,POS,concat(CHR,":",POS):variant
这时候你的工具就从简单的 TSV 操作工具逐渐变成类似 awk / cut + dataframe expression 的东西。
awk
cut
biox-*
结合你前面的 biox-core / biox-tsv / biox-vcf 设计,我会把 列操作放在 biox-tsv,不要放到 biox-core:
biox-core / biox-tsv / biox-vcf
biox-core
biox/ ├── crates/ │ ├── biox-core/ │ │ │ ├── biox-tsv/ │ │ ├── src/ │ │ │ ├── lib.rs │ │ │ ├── reader.rs │ │ │ ├── writer.rs │ │ │ ├── column.rs │ │ │ ├── plan.rs │ │ │ └── error.rs │ │ │ ├── biox-vcf/ │ └── biox-fasta/ │ └── src/ └── main.rs
核心关系:
CLI │ ├── --select ├── --rename └── --drop │ ▼ ColumnPlan │ ▼ TSV Reader │ ▼ projection │ ▼ TSV Writer
关键点是 ColumnPlan。
ColumnPlan
不要让 --select、--rename、--drop 分别在数据循环里处理,而是在开始读取数据后,先根据 header 构建一次 ColumnPlan。之后每一行只做整数 index 的访问,这样对几千万行 GWAS TSV 会非常快。
Github开源生信云平台 DEMO