rust 实现命令行 对 tsv/csv 列名的rename ,选择列 如何设计

最后发布时间:2026-08-25 22:27:16 浏览量:

如果你想做一个 Rust 版的 tsv/csv 命令行工具,同时支持:

  • rename 列名
  • 选择/保留列
  • 后续还可能扩展 删除列、调整列顺序、类型转换、过滤行

我建议不要把 CLI 参数直接设计成一堆零散参数,而是把核心抽象成 Column Expression / Column Operation

1. CLI 可以先设计成这样

例如工具叫 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,因为语义非常直观。


2. 内部不要直接操作 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 本身就解耦了


3. 更推荐:内部统一成 ColumnOp

如果你后面准备把 biox-tsv 做成一个比较完整的 Rust 生信工具,我会进一步设计成:

enum ColumnOp {
    Select {
        column: ColumnRef,
    },

    Rename {
        from: String,
        to: String,
    },

    Drop {
        column: ColumnRef,
    },
}

但是实际上 Rename 不一定需要作为独立的执行操作。

可以让:

输入列
   ↓
解析 column expressions
   ↓
ColumnPlan
   ↓
逐行 projection
   ↓
输出

核心结构:

struct ColumnPlan {
    columns: Vec<OutputColumn>,
}

struct OutputColumn {
    source_index: usize,
    source_name: String,
    output_name: String,
}

例如:

SNP CHR POS BETA SE P

执行:

--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 而修改原始数据的列索引。


4. --select 最好支持重复参数

我比较推荐:

--select SNP,CHR

同时允许:

--select SNP --select CHR

甚至:

--select SNP,BETA,P --select SE

最终 CLI parser 合并成:

Vec<String>

例如使用 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。


5. Rename 最好支持 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(),
        })
    }
}

CLI:

#[arg(long, value_parser = parse_rename)]
rename: Vec<Rename>,

这样用户输入错误时可以直接得到:

error: invalid value 'SNP'
rename must be OLD=NEW

6. 我建议支持 --drop

既然已经有 --select,其实:

--drop SE,CHR

也很自然。

例如:

biox-tsv input.tsv \
    --drop CHR,POS

但这里有一个设计选择:

方案 A:select 和 drop 互斥

--select SNP,BETA,P

或者:

--drop CHR,POS

不能同时使用。

这是我更推荐的第一版设计

因为:

--select SNP,BETA,P

已经明确表达了最终输出什么。


7. 可以进一步支持 alias

对于 GWAS 数据特别有用。

比如:

biox-tsv input.tsv \
    --select SNP=BETA,P

或者:

--select SNP:variant,BETA:beta,P:pvalue

那么就可以一步完成:

input:

SNP BETA P

output:

variant beta pvalue

CLI:

--select SNP:variant,BETA:beta,P: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

更加适合命令行数据处理。


8. 最终我会设计成两种模式

简单模式

biox-tsv input.tsv \
    --select SNP,BETA,P \
    --rename SNP=variant

expression 模式

biox-tsv input.tsv \
    --select SNP:variant,BETA:beta,P:pvalue

其中:

SNP:variant
│   │
│   └── output name
└────── input column

以后还可以自然扩展:

--select SNP:variant,BETA:beta,P:pvalue

到:

--select SNP:variant,log(BETA):log_beta

甚至:

--select SNP,CHR,POS,concat(CHR,":",POS):variant

这时候你的工具就从简单的 TSV 操作工具逐渐变成类似 awk / cut + dataframe expression 的东西。


9. 对你的 biox-* 项目,我建议的架构

结合你前面的 biox-core / biox-tsv / biox-vcf 设计,我会把 列操作放在 biox-tsv,不要放到 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

不要让 --select--rename--drop 分别在数据循环里处理,而是在开始读取数据后,先根据 header 构建一次 ColumnPlan。之后每一行只做整数 index 的访问,这样对几千万行 GWAS TSV 会非常快。