rsomics-vcf-reheader 0.1.0

Replace a VCF header or rename samples — Rust port of bcftools reheader
Documentation
use std::io::BufWriter;
use std::path::PathBuf;

use clap::Parser;
use rsomics_common::{CommonFlags, Result, RsomicsError, Tool, ToolMeta};
use rsomics_help::{Example, FlagSpec, HelpSpec, Origin, Section};

use rsomics_vcf_reheader::{passthrough, reheader_replace, reheader_samples};

pub const META: ToolMeta = ToolMeta {
    name: env!("CARGO_PKG_NAME"),
    version: env!("CARGO_PKG_VERSION"),
};

#[derive(Parser, Debug)]
#[command(
    name = "rsomics-vcf-reheader",
    version,
    about,
    long_about = None,
    disable_help_flag = true
)]
pub struct Cli {
    /// Input VCF file (plain text).
    #[arg(value_name = "INPUT")]
    pub input: Option<PathBuf>,

    /// Replace the entire header with the contents of FILE.
    #[arg(long = "header", value_name = "FILE")]
    pub header: Option<PathBuf>,

    /// Rename samples. FILE contains one new name per line (positional)
    /// or `old new` pairs (map-based).
    #[arg(short = 's', long = "samples", value_name = "FILE")]
    pub samples: Option<PathBuf>,

    /// Output file (default stdout).
    #[arg(short = 'o', long = "output", default_value = "-")]
    pub output: String,

    #[command(flatten)]
    pub common: CommonFlags,
}

impl Cli {
    pub fn execute(self) -> Result<()> {
        let mut input_box: Box<dyn std::io::Read> = match &self.input {
            Some(p) => Box::new(
                std::fs::File::open(p)
                    .map_err(|e| RsomicsError::InvalidInput(format!("{}: {e}", p.display())))?,
            ),
            None => Box::new(std::io::stdin()),
        };

        let mut out: Box<dyn std::io::Write> = if self.output == "-" {
            Box::new(BufWriter::new(std::io::stdout().lock()))
        } else {
            Box::new(BufWriter::new(
                std::fs::File::create(&self.output).map_err(RsomicsError::Io)?,
            ))
        };

        let n = match (&self.header, &self.samples) {
            (Some(hdr), _) => reheader_replace(input_box.as_mut(), hdr, out.as_mut())?,
            (None, Some(smp)) => reheader_samples(input_box.as_mut(), smp, out.as_mut())?,
            (None, None) => passthrough(input_box.as_mut(), out.as_mut())?,
        };

        if !self.common.quiet {
            eprintln!("{n} data records written");
        }

        Ok(())
    }
}

impl Tool for Cli {
    fn meta() -> ToolMeta {
        META
    }

    fn common(&self) -> &CommonFlags {
        &self.common
    }

    fn execute(self) -> Result<()> {
        self.execute()
    }
}

pub static HELP: HelpSpec = HelpSpec {
    name: META.name,
    version: META.version,
    tagline: "Replace a VCF header or rename samples — Rust port of bcftools reheader.",
    origin: Some(Origin {
        upstream: "bcftools reheader",
        upstream_license: "MIT",
        our_license: "MIT OR Apache-2.0",
        paper_doi: Some("10.1093/gigascience/giab008"),
    }),
    usage_lines: &["[OPTIONS] [INPUT]"],
    sections: &[Section {
        title: "OPTIONS",
        flags: &[
            FlagSpec {
                short: None,
                long: "INPUT",
                aliases: &[],
                value: Some("<path>"),
                type_hint: Some("Path"),
                required: false,
                default: Some("stdin"),
                description: "Input VCF file. Reads from stdin when omitted.",
                why_default: None,
            },
            FlagSpec {
                short: None,
                long: "header",
                aliases: &[],
                value: Some("<FILE>"),
                type_hint: Some("Path"),
                required: false,
                default: None,
                description: "Replace the entire header with the contents of FILE. \
                              Data records are passed through unchanged.",
                why_default: None,
            },
            FlagSpec {
                short: Some('s'),
                long: "samples",
                aliases: &[],
                value: Some("<FILE>"),
                type_hint: Some("Path"),
                required: false,
                default: None,
                description: "Rename samples on the #CHROM line. FILE contains one new \
                              sample name per line (positional) or `old new` pairs \
                              (map-based). Data records are passed through unchanged.",
                why_default: None,
            },
            FlagSpec {
                short: Some('o'),
                long: "output",
                aliases: &[],
                value: Some("<path>"),
                type_hint: Some("Path"),
                required: false,
                default: Some("-"),
                description: "Output VCF file (default: stdout).",
                why_default: None,
            },
        ],
    }],
    examples: &[
        Example {
            description: "Replace header with contents of new_header.txt",
            command: "rsomics-vcf-reheader --header new_header.txt in.vcf",
        },
        Example {
            description: "Rename samples (positional, one name per line)",
            command: "rsomics-vcf-reheader -s new_samples.txt in.vcf",
        },
        Example {
            description: "Rename samples (old→new pairs) and write to file",
            command: "rsomics-vcf-reheader -s renames.txt -o out.vcf in.vcf",
        },
    ],
    json_result_schema_doc: None,
};

#[cfg(test)]
mod tests {
    use super::*;
    use clap::CommandFactory;

    #[test]
    fn cli_debug_assert() {
        Cli::command().debug_assert();
    }
}