Skip to main content

symcurve/
cli.rs

1//! Command line interface symcurve tool.
2//!
3//! The main arguments to the symcurve CLI are the input and output file paths.
4//! These should be provided as positional arguments. The other arguments are optional
5//! but have constraints and default values.
6//!
7//! ```text
8//! Symmetry of DNA curvature.
9//!
10//! Usage: symcurve [OPTIONS] <INPUT> <OUTPUT>
11//!
12//! Arguments:
13//!   <INPUT>   FASTA input file path
14//!   <OUTPUT>  output file path; the extension picks the format: .bw/.bigWig, .bedGraph/.bg, or .gff
15//!
16//! Options:
17//!   -v, --verbose                            verbose setting
18//!   -m, --matrices <MATRICES>                optional matrices YAML file
19//!       --curve-step <CURVE_STEP>            curve step [default: 15]
20//!       --curve-scale <CURVE_SCALE>          curve scale [default: 0.33335]
21//!       --curve-step-one <CURVE_STEP_ONE>    curve step one [default: 6]
22//!       --curve-step-two <CURVE_STEP_TWO>    curve step two [default: 4]
23//!       --symcurve-win <SYMCURVE_WIN>        symcurve window [default: 101]
24//!       --symcurve-step <SYMCURVE_STEP>      symcurve step [default: 1]
25//!       --min-linker-size <MIN_LINKER_SIZE>  minimum linker size [default: 30]
26//!       --max-memory <MAX_MEMORY>            score buffer budget [default: 8G]
27//!       --stage <STAGE>                      curvature, symmetry, calls, final-calls [default: curvature]
28//!       --roll <ROLL>                        simple or active [default: simple]
29//!   -h, --help                               Print help
30//!   -V, --version                            Print version
31//! ```
32
33use clap::{Parser, ValueEnum};
34use std::path::PathBuf;
35
36/// Which stage of the calculation to write out.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
38pub enum Stage {
39    /// Curvature values.
40    Curvature,
41    /// Symmetry of curvature around each dyad.
42    Symmetry,
43    /// Every nucleosome call, which may overlap one another.
44    Calls,
45    /// Non-overlapping nucleosome calls, chosen greedily by score.
46    FinalCalls,
47}
48
49impl Stage {
50    /// Whether this stage produces nucleosome calls rather than per-base scores.
51    pub fn is_calls(self) -> bool {
52        matches!(self, Stage::Calls | Stage::FinalCalls)
53    }
54}
55
56impl From<Stage> for crate::curve::scan::Stage {
57    fn from(stage: Stage) -> Self {
58        match stage {
59            Stage::Curvature => Self::Curvature,
60            // Calls are derived from symmetry, so the scoring stage is the same.
61            Stage::Symmetry | Stage::Calls | Stage::FinalCalls => Self::Symmetry,
62        }
63    }
64}
65
66/// Which roll matrix to score with.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
68pub enum Roll {
69    /// The simple matrix, used for the DNase state in the reference implementation.
70    Simple,
71    /// The activated matrix, used for the nucleosome state.
72    Active,
73}
74
75impl From<Roll> for crate::curve::matrix::RollType {
76    fn from(roll: Roll) -> Self {
77        match roll {
78            Roll::Simple => Self::Simple,
79            Roll::Active => Self::Active,
80        }
81    }
82}
83
84#[derive(Parser, Debug)]
85#[command(version = env!("CARGO_PKG_VERSION"), about = "Symmetry of DNA curvature.", long_about = None)]
86pub struct Cli {
87    /// FASTA input file path
88    pub input: PathBuf,
89
90    /// output file path; the extension picks the format: .bw/.bigWig, .bedGraph/.bg, or .gff
91    pub output: PathBuf,
92
93    /// verbose setting
94    #[arg(short, long)]
95    pub verbose: bool,
96
97    /// optional matrices YAML file
98    #[arg(short, long)]
99    pub matrices: Option<PathBuf>,
100
101    /// curve step
102    #[arg(long, default_value = "15", value_parser = clap::value_parser!(u16).range(1..))]
103    pub curve_step: u16,
104
105    /// curve scale
106    #[arg(long, default_value = "0.33335", value_parser = parse_float_in_range)]
107    pub curve_scale: f64,
108
109    /// curve step one
110    #[arg(long, default_value = "6", value_parser = clap::value_parser!(u16).range(1..))]
111    pub curve_step_one: u16,
112
113    /// curve step two
114    #[arg(long, default_value = "4", value_parser = clap::value_parser!(u16).range(1..))]
115    pub curve_step_two: u16,
116
117    /// symcurve window
118    #[arg(long, default_value = "101", value_parser = clap::value_parser!(u16).range(1..))]
119    pub symcurve_win: u16,
120
121    /// symcurve step
122    #[arg(long, default_value = "1", value_parser = clap::value_parser!(u16).range(1..))]
123    pub symcurve_step: u16,
124
125    /// minimum linker size
126    #[arg(long, default_value = "30", value_parser = clap::value_parser!(u16).range(1..))]
127    pub min_linker_size: u16,
128
129    /// upper bound on memory used to buffer scores, e.g. 8G, 512M, 64K
130    #[arg(long, default_value = "8G")]
131    pub max_memory: crate::memory::MemoryBudget,
132
133    /// which stage to write out
134    #[arg(long, value_enum, default_value_t = Stage::Curvature)]
135    pub stage: Stage,
136
137    /// which roll matrix to score with
138    #[arg(long, value_enum, default_value_t = Roll::Simple)]
139    pub roll: Roll,
140}
141
142fn parse_float_in_range(s: &str) -> Result<f64, String> {
143    let value = s
144        .parse::<f64>()
145        .map_err(|_| "Value must be a floating-point number")?;
146    if (0.0..=1.0).contains(&value) {
147        Ok(value)
148    } else {
149        Err("The value must be between 0 and 1".to_owned())
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use clap::error::*;
157
158    #[test]
159    fn test_cli_args() {
160        // Your test code will go here
161        let args = Cli::parse_from([
162            "symcurve",
163            "input.fasta",
164            "output.bw",
165            "--verbose",
166            "--matrices",
167            "matrices.yaml",
168            "--curve-step",
169            "20",
170        ]);
171        assert_eq!(args.input.to_str().unwrap(), "input.fasta");
172        assert_eq!(args.output.to_str().unwrap(), "output.bw");
173        assert!(args.verbose);
174        assert_eq!(args.matrices.unwrap().to_str().unwrap(), "matrices.yaml");
175        assert_eq!(args.curve_step, 20);
176    }
177
178    #[test]
179    fn test_missing_matrix_file() {
180        let args_result =
181            Cli::try_parse_from(["symcurve", "input.fasta", "output.bw", "--matrices"]);
182        // construct the Error object manually, this probably
183        // overkill and the error .to_string() is enough but it's
184        // just some practice
185        let cmd = clap::Command::new("symcurve");
186        let mut err = clap::Error::new(ErrorKind::InvalidValue).with_cmd(&cmd);
187        err.insert(
188            ContextKind::InvalidArg,
189            ContextValue::String("--matrices <MATRICES>".to_owned()),
190        );
191        err.insert(
192            ContextKind::InvalidValue,
193            ContextValue::String("".to_owned()),
194        );
195        assert!(args_result.is_err());
196        assert_eq!(args_result.unwrap_err().to_string(), err.to_string());
197    }
198
199    // test when the curve_step argument is not > 0
200    #[test]
201    fn test_zero_curve_step() {
202        let args_result =
203            Cli::try_parse_from(["symcurve", "input.fasta", "output.bw", "--curve-step", "0"]);
204        assert!(args_result.is_err());
205        assert!(
206            args_result
207                .unwrap_err()
208                .to_string()
209                .starts_with("error: invalid value '0' for '--curve-step")
210        );
211    }
212
213    // helper to test_curve_scale()
214    fn get_different_curve_scale_parsings(curve_scale_s: &str) -> Result<Cli, clap::error::Error> {
215        Cli::try_parse_from([
216            "symcurve",
217            "input.fasta",
218            "output.bw",
219            "--curve-scale",
220            curve_scale_s,
221        ])
222    }
223
224    #[test]
225    fn test_curve_scale_keeps_full_precision() {
226        // Parsed as f32 this became 0.33334999..., a relative error of 8e-9 that every
227        // curvature value carried and that symmetry squared into 1.7e-8. That was enough
228        // to move agreement with the reference implementation by three orders.
229        let args = Cli::parse_from(["symcurve", "in.fa", "out.bw"]);
230        assert_eq!(args.curve_scale, 0.33335_f64);
231
232        let args = Cli::parse_from(["symcurve", "in.fa", "out.bw", "--curve-scale", "0.1"]);
233        assert_eq!(args.curve_scale, 0.1_f64);
234        assert_ne!(args.curve_scale, f64::from(0.1_f32));
235    }
236
237    #[test]
238    fn test_max_memory_parses_and_defaults() {
239        let args = Cli::parse_from(["symcurve", "in.fa", "out.bw"]);
240        assert_eq!(args.max_memory.to_string(), "8G");
241
242        let args = Cli::parse_from(["symcurve", "in.fa", "out.bw", "--max-memory", "512M"]);
243        assert_eq!(args.max_memory.bytes(), 512 * 1024 * 1024);
244
245        let bad = Cli::try_parse_from(["symcurve", "in.fa", "out.bw", "--max-memory", "lots"]);
246        assert!(bad.is_err());
247        assert!(
248            bad.unwrap_err().to_string().contains("8G"),
249            "the error should show the expected form"
250        );
251    }
252
253    #[test]
254    fn test_curve_scale() {
255        // test different passed in curve scales
256        assert!(get_different_curve_scale_parsings("0").is_ok());
257        assert!(get_different_curve_scale_parsings("0.33").is_ok());
258        assert!(get_different_curve_scale_parsings("1").is_ok());
259        assert!(get_different_curve_scale_parsings("1.1").is_err());
260        assert!(get_different_curve_scale_parsings("-1").is_err());
261        assert!(get_different_curve_scale_parsings("abc").is_err());
262    }
263}