polaranges 0.3.2

Rust-first genomic range operations on top of Polars DataFrames
Documentation
use std::path::PathBuf;

use clap::{Args, Parser, Subcommand};

use crate::commands::spec::{
    BinaryInputs, IntervalColumns, IntervalCommandSpec, NearestCommandSpec, OverlapCommandSpec,
    StrandConstraint, DEFAULT_CHROM_COL, DEFAULT_DISTANCE_COL, DEFAULT_END_COL,
    DEFAULT_NEAREST_SUFFIX, DEFAULT_START_COL, DEFAULT_STRAND_COL,
};
use crate::commands::{CommandError, Result};

/// The regular mode is the primary modern CLI. It owns the user-facing syntax,
/// then translates that syntax into shared command specs for the executor.
#[derive(Parser, Clone, Debug, PartialEq, Eq)]
#[command(name = "regular", about = "Primary modern command set for polaranges.")]
pub struct RegularCli {
    #[command(subcommand)]
    pub command: RegularCommand,
}

#[derive(Subcommand, Clone, Debug, PartialEq, Eq)]
pub enum RegularCommand {
    Overlap(OverlapArgs),
    Nearest(NearestArgs),
}

#[derive(Args, Clone, Debug, PartialEq, Eq)]
pub struct OverlapArgs {
    #[command(flatten)]
    pub common: BinaryIntervalArgs,

    #[command(flatten)]
    pub execution: ExecutionArgs,
}

#[derive(Args, Clone, Debug, PartialEq, Eq)]
pub struct NearestArgs {
    #[command(flatten)]
    pub common: BinaryIntervalArgs,

    #[command(flatten)]
    pub execution: ExecutionArgs,

    /// Leave nearest results in kernel order instead of restoring input order.
    #[arg(long)]
    pub no_sort_output: bool,
}

#[derive(Args, Clone, Debug, PartialEq, Eq)]
pub struct BinaryIntervalArgs {
    #[arg(long = "a", value_name = "PATH")]
    pub left_path: PathBuf,

    #[arg(long = "b", value_name = "PATH")]
    pub right_path: PathBuf,

    #[command(flatten)]
    pub columns: ColumnArgs,

    #[command(flatten)]
    pub strand: StrandArgs,
}

#[derive(Args, Clone, Debug, PartialEq, Eq)]
pub struct ColumnArgs {
    #[arg(long, default_value = DEFAULT_CHROM_COL)]
    pub chrom_col: String,

    #[arg(long, default_value = DEFAULT_START_COL)]
    pub start_col: String,

    #[arg(long, default_value = DEFAULT_END_COL)]
    pub end_col: String,

    #[arg(long)]
    pub strand_col: Option<String>,
}

#[derive(Args, Clone, Debug, PartialEq, Eq, Default)]
pub struct StrandArgs {
    #[arg(long, conflicts_with = "opposite_strand")]
    pub same_strand: bool,

    #[arg(long, conflicts_with = "same_strand")]
    pub opposite_strand: bool,
}

#[derive(Args, Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExecutionArgs {
    /// Print a result summary and benchmark timings to stderr.
    #[arg(long)]
    pub benchmark: bool,

    /// Number of operation repetitions to time after reading inputs.
    #[arg(long, default_value_t = 1, value_name = "N")]
    pub reps: usize,

    /// Suppress the full tabular result on stdout.
    #[arg(long)]
    pub no_output: bool,
}

impl Default for ExecutionArgs {
    fn default() -> Self {
        Self {
            benchmark: false,
            reps: 1,
            no_output: false,
        }
    }
}

pub fn translate_regular_cli(cli: RegularCli) -> Result<IntervalCommandSpec> {
    translate_regular_command(cli.command)
}

pub fn translate_regular_command(command: RegularCommand) -> Result<IntervalCommandSpec> {
    match command {
        RegularCommand::Overlap(args) => Ok(IntervalCommandSpec::Overlap(OverlapCommandSpec {
            inputs: translate_inputs(&args.common),
            columns: translate_columns(&args.common.columns),
            strand_constraint: translate_strand_constraint(&args.common.strand)?,
        })),
        RegularCommand::Nearest(args) => Ok(IntervalCommandSpec::Nearest(NearestCommandSpec {
            inputs: translate_inputs(&args.common),
            columns: translate_columns(&args.common.columns),
            strand_constraint: translate_strand_constraint(&args.common.strand)?,
            suffix: DEFAULT_NEAREST_SUFFIX.to_owned(),
            distance_column: Some(DEFAULT_DISTANCE_COL.to_owned()),
            preserve_input_order: !args.no_sort_output,
        })),
    }
}

fn translate_inputs(args: &BinaryIntervalArgs) -> BinaryInputs {
    BinaryInputs {
        left_path: args.left_path.clone(),
        right_path: args.right_path.clone(),
    }
}

fn translate_columns(args: &ColumnArgs) -> IntervalColumns {
    IntervalColumns {
        chrom_col: args.chrom_col.as_str().into(),
        start_col: args.start_col.as_str().into(),
        end_col: args.end_col.as_str().into(),
        strand_col: Some(
            args.strand_col
                .as_deref()
                .unwrap_or(DEFAULT_STRAND_COL)
                .into(),
        ),
    }
}

fn translate_strand_constraint(args: &StrandArgs) -> Result<StrandConstraint> {
    match (args.same_strand, args.opposite_strand) {
        (true, true) => Err(CommandError::InvalidStrandConstraint),
        (true, false) => Ok(StrandConstraint::Same),
        (false, true) => Ok(StrandConstraint::Opposite),
        (false, false) => Ok(StrandConstraint::Ignore),
    }
}

#[cfg(test)]
mod tests {
    use clap::Parser;

    use super::*;

    #[test]
    fn regular_cli_parses_overlap_command() {
        let cli = RegularCli::try_parse_from([
            "regular",
            "overlap",
            "--a",
            "left.bed",
            "--b",
            "right.bed",
            "--same-strand",
        ])
        .unwrap();

        match cli.command {
            RegularCommand::Overlap(args) => {
                assert_eq!(args.common.left_path, PathBuf::from("left.bed"));
                assert_eq!(args.common.right_path, PathBuf::from("right.bed"));
                assert!(args.common.strand.same_strand);
                assert_eq!(args.common.columns.chrom_col, DEFAULT_CHROM_COL);
            }
            other => panic!("expected overlap command, got {other:?}"),
        }
    }

    #[test]
    fn regular_cli_parses_nearest_command() {
        let cli = RegularCli::try_parse_from([
            "regular",
            "nearest",
            "--a",
            "left.tsv",
            "--b",
            "right.tsv",
            "--chrom-col",
            "Chrom",
            "--start-col",
            "Lower",
            "--end-col",
            "Upper",
        ])
        .unwrap();

        match cli.command {
            RegularCommand::Nearest(args) => {
                assert_eq!(args.common.left_path, PathBuf::from("left.tsv"));
                assert_eq!(args.common.right_path, PathBuf::from("right.tsv"));
                assert_eq!(args.common.columns.chrom_col, "Chrom");
                assert_eq!(args.common.columns.start_col, "Lower");
                assert_eq!(args.common.columns.end_col, "Upper");
            }
            other => panic!("expected nearest command, got {other:?}"),
        }
    }

    #[test]
    fn regular_cli_parses_nearest_no_sort_output() {
        let cli = RegularCli::try_parse_from([
            "regular",
            "nearest",
            "--a",
            "left.tsv",
            "--b",
            "right.tsv",
            "--no-sort-output",
        ])
        .unwrap();

        match cli.command {
            RegularCommand::Nearest(args) => assert!(args.no_sort_output),
            other => panic!("expected nearest command, got {other:?}"),
        }
    }

    #[test]
    fn nearest_no_sort_output_disables_input_order_preservation() {
        let cli = RegularCli::try_parse_from([
            "regular",
            "nearest",
            "--a",
            "left.tsv",
            "--b",
            "right.tsv",
            "--no-sort-output",
        ])
        .unwrap();

        let spec = translate_regular_cli(cli).unwrap();
        match spec {
            IntervalCommandSpec::Nearest(spec) => assert!(!spec.preserve_input_order),
            other => panic!("expected nearest spec, got {other:?}"),
        }
    }

    #[test]
    fn regular_cli_parses_benchmark_execution_flags() {
        let cli = RegularCli::try_parse_from([
            "regular",
            "nearest",
            "--a",
            "left.tsv",
            "--b",
            "right.tsv",
            "--benchmark",
            "--reps",
            "3",
            "--no-output",
        ])
        .unwrap();

        match cli.command {
            RegularCommand::Nearest(args) => {
                assert!(args.execution.benchmark);
                assert_eq!(args.execution.reps, 3);
                assert!(args.execution.no_output);
            }
            other => panic!("expected nearest command, got {other:?}"),
        }
    }

    #[test]
    fn parsed_regular_cli_translates_into_shared_specs() {
        let cli = RegularCli::try_parse_from([
            "regular",
            "overlap",
            "--a",
            "left.bed",
            "--b",
            "right.bed",
            "--same-strand",
        ])
        .unwrap();

        let spec = translate_regular_cli(cli).unwrap();
        assert_eq!(
            spec,
            IntervalCommandSpec::Overlap(OverlapCommandSpec {
                inputs: BinaryInputs {
                    left_path: PathBuf::from("left.bed"),
                    right_path: PathBuf::from("right.bed"),
                },
                columns: IntervalColumns {
                    chrom_col: DEFAULT_CHROM_COL.into(),
                    start_col: DEFAULT_START_COL.into(),
                    end_col: DEFAULT_END_COL.into(),
                    strand_col: Some(DEFAULT_STRAND_COL.into()),
                },
                strand_constraint: StrandConstraint::Same,
            })
        );
    }
}