diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b4346b..effbe06c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ Sections commonly used: Features, Bug fixes, Other changes. ### Features +- **Three more STAR 2.7.11b parameters, all with behaviour rather than + acceptance**: `--parametersFiles` (STAR-format parameter files, command + line wins, unknown name or empty value is fatal), `--versionGenome` (an + index older than the requested version is refused instead of misread), and + `--sysShell`, alongside a `--readFilesCommand` fix: it now runs through a + shell, so multi-word commands such as `gunzip -c` work instead of failing + as a missing program. Machine-checked STAR parameter coverage rises to + 181/203. - **CLI and output parity: SAM/SJ/read-input knobs and the STAR limit surface** — 30 further STAR 2.7.11b parameters. (`--outSAMorder` came from #145.) diff --git a/docs/src/content/docs/reference/cli-parameters.md b/docs/src/content/docs/reference/cli-parameters.md index f749039b..d97b61bc 100644 --- a/docs/src/content/docs/reference/cli-parameters.md +++ b/docs/src/content/docs/reference/cli-parameters.md @@ -24,13 +24,16 @@ Run `rustar-aligner --help` for the full machine-generated listing. | `--genomeSAindexNbases` | `14` | Length of the SA pre-indexing string (log2). Lower for small genomes. | | `--genomeChrBinNbits` | `18` | Log2 of chromosome bin size. | | `--genomeSAsparseD` | `1` | SA sparsity (higher = less RAM, slower mapping). | +| `--versionGenome` | `2.7.4a` | Earliest genome index version accepted; an older index is refused rather than misread. | ## Read input | Parameter | Default | Description | |-----------|---------|-------------| | `--readFilesIn` | — | Input FASTQ file(s); second file is mate 2 for paired-end (required for `alignReads`). | -| `--readFilesCommand` | — | Decompression command, e.g. `zcat` for `.gz`. | +| `--readFilesCommand` | — | Decompression command, e.g. `zcat` or `gunzip -c` for `.gz`. Run through a shell, so multi-word commands work. | +| `--sysShell` | `-` | Shell that runs `--readFilesCommand`. `-` uses `/bin/sh` on Unix; on Windows the command is split into words and run directly. | +| `--parametersFiles` | `-` | STAR-format parameter file(s): `parameterName value...` per line, `#` and `//` comments. Command-line values win. `-` means none. | | `--readMapNumber` | `-1` | Number of reads to map (`-1` = all). | | `--clip5pNbases` | `0` | Bases to clip from the 5' end of each mate. | | `--clip3pNbases` | `0` | Bases to clip from the 3' end of each mate. | diff --git a/src/index/io.rs b/src/index/io.rs index ce6de640..4ffdce69 100644 --- a/src/index/io.rs +++ b/src/index/io.rs @@ -21,6 +21,11 @@ impl GenomeIndex { pub fn load(genome_dir: &Path, params: &Parameters) -> Result { log::info!("Loading genome from {}...", genome_dir.display()); + // Refuse an index older than --versionGenome before reading a byte of + // it: an older layout read with today's reader is silently wrong, not + // loudly wrong. + check_genome_version(genome_dir, ¶ms.version_genome)?; + // Load Genome file let genome = load_genome(genome_dir, params)?; log::info!( @@ -150,6 +155,74 @@ impl GenomeIndex { } } +/// Parse a STAR genome version string (`2.7.4a`) into a comparable tuple: +/// the numeric components, then the trailing letter suffix (`a` -> 1). +/// +/// STAR compares these versions as strings, which orders `2.7.10a` before +/// `2.7.4a`; comparing components avoids that trap. +fn parse_genome_version(v: &str) -> Option<(Vec, u32)> { + let v = v.trim(); + if v.is_empty() { + return None; + } + let digits_end = v.rfind(|c: char| c.is_ascii_digit()).map_or(0, |i| i + 1); + let (numeric, suffix) = v.split_at(digits_end); + let mut parts = Vec::new(); + for p in numeric.split('.') { + parts.push(p.parse::().ok()?); + } + let suffix_rank = suffix.bytes().next().map_or(0, |b| { + u32::from(b.to_ascii_lowercase().saturating_sub(b'a')) + 1 + }); + Some((parts, suffix_rank)) +} + +/// Read `versionGenome` from `genomeParameters.txt`. +fn read_genome_version(genome_dir: &Path) -> Result, Error> { + let path = genome_dir.join("genomeParameters.txt"); + let contents = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(Error::io(e, &path)), + }; + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("versionGenome") + && let Some(value) = rest.split_whitespace().next() + { + return Ok(Some(value.to_string())); + } + } + Ok(None) +} + +/// Fail when the index on disk is older than `required` (`--versionGenome`). +/// +/// An index with no recorded version, or a version neither side can parse, is +/// accepted with a warning: refusing it would break directories that load +/// correctly today, and the version line is advisory metadata, not a checksum. +pub(crate) fn check_genome_version(genome_dir: &Path, required: &str) -> Result<(), Error> { + let Some(found) = read_genome_version(genome_dir)? else { + log::warn!( + "{} has no versionGenome line; skipping the version check", + genome_dir.join("genomeParameters.txt").display() + ); + return Ok(()); + }; + let (Some(found_v), Some(required_v)) = + (parse_genome_version(&found), parse_genome_version(required)) + else { + log::warn!("could not compare genome version '{found}' against '{required}'"); + return Ok(()); + }; + if found_v < required_v { + return Err(Error::Index(format!( + "genome index in {} has versionGenome {found}, older than the required {required}. Regenerate the index with --runMode genomeGenerate, or lower --versionGenome if you know the layout is compatible", + genome_dir.display() + ))); + } + Ok(()) +} + /// Read `genomeFileSizes\t ` from genomeParameters.txt /// and return the first field (total genome byte count, including Gsj if /// sjdb was baked in). Returns `Ok(None)` if the file or line is absent, @@ -403,4 +476,56 @@ mod tests { assert_eq!(loaded_index.suffix_array.get(i), index.suffix_array.get(i)); } } + + // ── --versionGenome ────────────────────────────────────────────────── + + fn write_genome_params(dir: &std::path::Path, version_line: &str) { + std::fs::write(dir.join("genomeParameters.txt"), version_line).unwrap(); + } + + #[test] + fn version_genome_rejects_an_older_index() { + let dir = tempfile::tempdir().unwrap(); + write_genome_params(dir.path(), "versionGenome\t2.7.1a\n"); + let err = check_genome_version(dir.path(), "2.7.4a").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("2.7.1a"), + "error should quote the found version: {msg}" + ); + assert!( + msg.contains("2.7.4a"), + "error should quote the required version: {msg}" + ); + } + + #[test] + fn version_genome_accepts_equal_and_newer_indices() { + let dir = tempfile::tempdir().unwrap(); + write_genome_params(dir.path(), "versionGenome\t2.7.4a\n"); + assert!(check_genome_version(dir.path(), "2.7.4a").is_ok()); + + write_genome_params(dir.path(), "versionGenome\t2.7.10b\n"); + assert!(check_genome_version(dir.path(), "2.7.4a").is_ok()); + } + + #[test] + fn version_genome_orders_by_component_not_lexically() { + // A string comparison puts "2.7.10a" before "2.7.4a"; component + // comparison must not. + let older = parse_genome_version("2.7.4a").unwrap(); + let newer = parse_genome_version("2.7.10a").unwrap(); + assert!(newer > older, "2.7.10a must sort after 2.7.4a"); + assert!(parse_genome_version("2.7.4b").unwrap() > older); + } + + #[test] + fn version_genome_missing_line_is_accepted_with_a_warning() { + let dir = tempfile::tempdir().unwrap(); + write_genome_params(dir.path(), "genomeType\tFull\n"); + assert!(check_genome_version(dir.path(), "2.7.4a").is_ok()); + // No genomeParameters.txt at all is also accepted. + std::fs::remove_file(dir.path().join("genomeParameters.txt")).unwrap(); + assert!(check_genome_version(dir.path(), "2.7.4a").is_ok()); + } } diff --git a/src/io/fastq.rs b/src/io/fastq.rs index 0c795de0..bb0b040d 100644 --- a/src/io/fastq.rs +++ b/src/io/fastq.rs @@ -87,6 +87,75 @@ pub struct FastqReader { name_separators: Vec, } +/// A `--readFilesCommand`, plus the shell it is run through (`--sysShell`). +/// +/// STAR runs the command as `sysShell -c " "`, so a multi-word +/// command such as `gunzip -c` works. Spawning the whole string as one program +/// name does not: it fails with "no such file or directory" on the very +/// commands STAR's own documentation gives as examples. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadCommand { + /// The command line as written by the user, without the input file. + pub command: String, + /// Shell to run it through. `None` means run it directly, splitting the + /// command into words: the fallback where no POSIX shell is guaranteed. + pub shell: Option, +} + +impl ReadCommand { + /// Build from `--readFilesCommand` and `--sysShell`. + /// + /// `sys_shell` is STAR's `-` sentinel for "the platform default", which is + /// `/bin/sh` on Unix and no shell on Windows. + pub fn new(command: &str, sys_shell: &str) -> Self { + let shell = if sys_shell != "-" && !sys_shell.is_empty() { + Some(sys_shell.to_string()) + } else if cfg!(unix) { + Some("/bin/sh".to_string()) + } else { + None + }; + ReadCommand { + command: command.to_string(), + shell, + } + } + + /// The `Command` that streams `path` on stdout. + fn build(&self, path: &Path) -> Result { + if let Some(shell) = &self.shell { + let quoted = shlex::try_quote(&path.to_string_lossy()) + .map_err(|_| { + Error::Parameter(format!( + "input path contains a NUL byte and cannot be passed to \ + --readFilesCommand: {}", + path.display() + )) + })? + .into_owned(); + let mut c = Command::new(shell); + c.arg("-c").arg(format!("{} {quoted}", self.command)); + return Ok(c); + } + + // No shell: split the command line into words ourselves so that a + // multi-word command still runs. + let mut words = shlex::split(&self.command).ok_or_else(|| { + Error::Parameter(format!( + "could not parse --readFilesCommand '{}' into words", + self.command + )) + })?; + if words.is_empty() { + return Err(Error::Parameter("--readFilesCommand is empty".to_string())); + } + let program = words.remove(0); + let mut c = Command::new(program); + c.args(words).arg(path); + Ok(c) + } +} + impl FastqReader { /// Open a FASTQ file (plain or gzip compressed) /// @@ -96,7 +165,7 @@ impl FastqReader { /// /// # Returns /// A FastqReader that iterates over encoded reads - pub fn open(path: &Path, decompress_cmd: Option<&str>) -> Result { + pub fn open(path: &Path, decompress_cmd: Option<&ReadCommand>) -> Result { let reader: Box = if let Some(cmd) = decompress_cmd { // Use external decompression command Self::open_with_command(path, cmd)? @@ -153,9 +222,9 @@ impl FastqReader { } /// Open FASTQ file using external decompression command - fn open_with_command(path: &Path, cmd: &str) -> Result, Error> { - let mut child = Command::new(cmd) - .arg(path) + fn open_with_command(path: &Path, cmd: &ReadCommand) -> Result, Error> { + let mut child = cmd + .build(path)? .stdout(Stdio::piped()) .spawn() .map_err(|e| Error::io(e, path))?; @@ -250,7 +319,11 @@ impl PairedFastqReader { /// /// # Returns /// A PairedFastqReader that iterates over paired reads with name validation - pub fn open(path1: &Path, path2: &Path, decompress_cmd: Option<&str>) -> Result { + pub fn open( + path1: &Path, + path2: &Path, + decompress_cmd: Option<&ReadCommand>, + ) -> Result { let reader1 = FastqReader::open(path1, decompress_cmd)?; let reader2 = FastqReader::open(path2, decompress_cmd)?; @@ -778,4 +851,85 @@ mod tests { let batch3 = reader.read_paired_batch(3).unwrap(); assert_eq!(batch3.len(), 0); } + + // ── --readFilesCommand / --sysShell ────────────────────────────────── + + fn write_gzipped_fastq(dir: &std::path::Path) -> std::path::PathBuf { + use flate2::Compression; + use flate2::write::GzEncoder; + let path = dir.join("reads.fq.gz"); + let f = std::fs::File::create(&path).unwrap(); + let mut enc = GzEncoder::new(f, Compression::fast()); + enc.write_all(b"@r1\nACGTACGTAC\n+\nIIIIIIIIII\n").unwrap(); + enc.finish().unwrap(); + path + } + + #[cfg(unix)] + #[test] + fn read_files_command_accepts_a_multi_word_command() { + // `gunzip -c` is the example STAR's own documentation gives. Spawning + // the whole string as one program name fails; running it through a + // shell is what STAR does. + let dir = tempfile::tempdir().unwrap(); + let path = write_gzipped_fastq(dir.path()); + let cmd = ReadCommand::new("gunzip -c", "-"); + let mut reader = FastqReader::open(&path, Some(&cmd)).unwrap(); + let record = reader.next_encoded().unwrap().expect("one record"); + assert_eq!(record.name, "r1"); + assert_eq!(record.sequence.len(), 10); + } + + #[cfg(unix)] + #[test] + fn read_files_command_runs_through_the_shell_sys_shell_selects() { + let dir = tempfile::tempdir().unwrap(); + let path = write_gzipped_fastq(dir.path()); + let cmd = ReadCommand::new("gunzip -c", "/bin/sh"); + assert_eq!(cmd.shell.as_deref(), Some("/bin/sh")); + let mut reader = FastqReader::open(&path, Some(&cmd)).unwrap(); + assert!(reader.next_encoded().unwrap().is_some()); + } + + #[cfg(unix)] + #[test] + fn read_files_command_handles_a_path_with_spaces() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("a directory with spaces"); + std::fs::create_dir(&sub).unwrap(); + let path = write_gzipped_fastq(&sub); + let cmd = ReadCommand::new("gunzip -c", "-"); + let mut reader = FastqReader::open(&path, Some(&cmd)).unwrap(); + assert!( + reader.next_encoded().unwrap().is_some(), + "the file path must be quoted when handed to the shell" + ); + } + + #[test] + fn read_files_command_without_a_shell_splits_the_command_itself() { + // The Windows path: no POSIX shell is guaranteed, so the command line + // is split into program plus arguments rather than handed to a shell. + let cmd = ReadCommand { + command: "gunzip -c".to_string(), + shell: None, + }; + let built = cmd.build(std::path::Path::new("reads.fq.gz")).unwrap(); + assert_eq!(built.get_program(), "gunzip"); + let args: Vec<_> = built + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args, vec!["-c".to_string(), "reads.fq.gz".to_string()]); + } + + #[test] + fn sys_shell_default_is_platform_dependent() { + let cmd = ReadCommand::new("zcat", "-"); + if cfg!(unix) { + assert_eq!(cmd.shell.as_deref(), Some("/bin/sh")); + } else { + assert_eq!(cmd.shell, None); + } + } } diff --git a/src/lib.rs b/src/lib.rs index 5086fcbb..08677ae0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -525,7 +525,8 @@ fn run_smartseq( counts.add(ci, g); } }; - let cmd = params.read_files_command.as_deref(); + let read_command = params.read_command(); + let cmd = read_command.as_ref(); for (ci, cell) in cells.iter().enumerate() { match &cell.read2 { @@ -1414,8 +1415,7 @@ fn align_reads_single_end( let read_file = ¶ms.read_files_in[0]; info!("Reading single-end from {}", read_file.display()); - let reader = - FastqReader::open(read_file, params.read_files_command.as_deref())?.with_params(params); + let reader = FastqReader::open(read_file, params.read_command().as_ref())?.with_params(params); // Create chimeric output writer if enabled let chimeric_writer = if params.chim_segment_min > 0 && params.chim_out_junctions() { @@ -2753,7 +2753,7 @@ fn align_reads_paired_end( let reader = PairedFastqReader::open( ¶ms.read_files_in[0], ¶ms.read_files_in[1], - params.read_files_command.as_deref(), + params.read_command().as_ref(), )? .with_params(params); diff --git a/src/params/mod.rs b/src/params/mod.rs index 3b507310..5b5fba10 100644 --- a/src/params/mod.rs +++ b/src/params/mod.rs @@ -530,6 +530,24 @@ pub struct Parameters { #[arg(long = "readFilesIn", num_args = 1..=2)] pub read_files_in: Vec, + /// STAR-format parameter file(s): lines of `parameterName value...`, + /// `#` and `//` comments, one parameter per line. `-` means none. Values + /// given on the command line override values read from a file, and + /// `parametersFiles` itself may only appear on the command line. + #[arg(long = "parametersFiles", num_args = 1.., default_value = "-")] + pub parameters_files: Vec, + + /// Shell used to run `--readFilesCommand`; `-` selects the platform + /// default (`/bin/sh` on Unix). STAR's `sysShell`. + #[arg(long = "sysShell", default_value = "-")] + pub sys_shell: String, + + /// Earliest genome index version this run accepts. A genome directory + /// written by an older STAR / rustar-aligner is rejected rather than read + /// with the wrong layout. + #[arg(long = "versionGenome", default_value = "2.7.4a")] + pub version_genome: String, + /// Command to decompress input files (e.g. "zcat" for .gz) #[arg(long = "readFilesCommand")] pub read_files_command: Option, @@ -1499,6 +1517,166 @@ impl Parameters { } /// Parse and validate parameter combinations. + /// Long-flag names clap accepts, walked recursively so flattened argument + /// groups are included. Shared with the parameter-file loader so a file + /// can only set parameters this build actually has. + fn recognised_long_flags(command: &clap::Command) -> std::collections::BTreeSet { + fn walk(cmd: &clap::Command, out: &mut std::collections::BTreeSet) { + for arg in cmd.get_arguments() { + if let Some(long) = arg.get_long() { + out.insert(long.to_string()); + } + for alias in arg.get_all_aliases().unwrap_or_default() { + out.insert(alias.to_string()); + } + } + for sub in cmd.get_subcommands() { + walk(sub, out); + } + } + let mut out = std::collections::BTreeSet::new(); + walk(command, &mut out); + out + } + + /// Parse one STAR parameter file into `--name value...` arguments. + /// + /// STAR's `Parameters::scanOneLine`: an empty line is skipped, a first + /// token starting with `#` or `//` is a comment, an unrecognised name is + /// fatal, and a name with no value is fatal. `parametersFiles` inside a + /// file is refused, as in STAR, where it is command-line only. + fn parse_parameters_file( + path: &str, + known: &std::collections::BTreeSet, + command: &clap::Command, + ) -> Result, clap::Error> { + use clap::error::ErrorKind; + + let text = std::fs::read_to_string(path).map_err(|e| { + let mut cmd = command.clone(); + cmd.error( + ErrorKind::Io, + format!("could not read --parametersFiles '{path}': {e}"), + ) + })?; + + let mut out: Vec = Vec::new(); + for (lineno, line) in text.lines().enumerate() { + let mut words = line.split_whitespace(); + let Some(name) = words.next() else { + continue; // blank line + }; + if name.starts_with('#') || name.starts_with("//") { + continue; // comment + } + if name == "parametersFiles" { + let mut cmd = command.clone(); + return Err(cmd.error( + ErrorKind::InvalidValue, + format!( + "parametersFiles cannot be set inside a parameter file ({path}, line {}); it is a command-line-only parameter", + lineno + 1 + ), + )); + } + if !known.contains(name) { + let mut cmd = command.clone(); + return Err(cmd.error( + ErrorKind::UnknownArgument, + format!( + "unrecognised parameter name '{name}' in --parametersFiles {path} (line {})", + lineno + 1 + ), + )); + } + let values: Vec<&str> = words.collect(); + if values.is_empty() { + let mut cmd = command.clone(); + return Err(cmd.error( + ErrorKind::InvalidValue, + format!( + "empty value for parameter '{name}' in --parametersFiles {path} (line {})", + lineno + 1 + ), + )); + } + out.push(std::ffi::OsString::from(format!("--{name}"))); + for v in values { + out.push(std::ffi::OsString::from(v)); + } + } + Ok(out) + } + + /// Expand `--parametersFiles` into leading arguments. + /// + /// Returns `args` unchanged when no file is requested. Otherwise the file + /// arguments are placed before the user's own arguments, and any flag the + /// user also passed on the command line is dropped from the file side, so + /// the command line wins even for the multi-value parameters where clap + /// would otherwise append rather than replace. + fn expand_parameters_files( + args: &[std::ffi::OsString], + command: &clap::Command, + ) -> Result, clap::Error> { + let lossy: Vec = args + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + + // Collect the values of every --parametersFiles occurrence. + let mut files: Vec = Vec::new(); + let mut i = 0; + while i < lossy.len() { + if lossy[i] == "--parametersFiles" { + let mut j = i + 1; + while j < lossy.len() && !lossy[j].starts_with("--") { + files.push(lossy[j].clone()); + j += 1; + } + i = j; + } else { + i += 1; + } + } + files.retain(|f| f != "-"); + if files.is_empty() { + return Ok(args.to_vec()); + } + + let known = Self::recognised_long_flags(command); + let mut file_args: Vec = Vec::new(); + for f in &files { + file_args.extend(Self::parse_parameters_file(f, &known, command)?); + } + + // Flags the user set directly; the file must not also set them. + let user_flags: std::collections::BTreeSet<&str> = + lossy.iter().filter_map(|a| a.strip_prefix("--")).collect(); + let mut filtered: Vec = Vec::new(); + let mut skipping = false; + for a in file_args { + let text = a.to_string_lossy().into_owned(); + if let Some(flag) = text.strip_prefix("--") { + skipping = user_flags.contains(flag); + if skipping { + continue; + } + filtered.push(a); + } else if !skipping { + filtered.push(a); + } + } + + let mut out: Vec = Vec::with_capacity(filtered.len() + args.len()); + if let Some(program) = args.first() { + out.push(program.clone()); + } + out.extend(filtered); + out.extend(args.iter().skip(1).cloned()); + Ok(out) + } + pub fn try_parse() -> Result { Self::try_parse_from(std::env::args_os()) } @@ -1512,7 +1690,11 @@ impl Parameters { let args: Vec<_> = args.into_iter().map(Into::into).collect(); let mut command = ::command(); - let matches = command.clone().get_matches_from(args.iter()); + // `--parametersFiles` is expanded before clap sees the arguments: + // file values become leading arguments, so a value repeated on the + // command line wins (STAR reads files first, then the command line). + let expanded = Self::expand_parameters_files(&args, &command)?; + let matches = command.clone().get_matches_from(expanded.iter()); let mut params = ::from_arg_matches(&matches)?; params.command_line = { @@ -2069,6 +2251,17 @@ impl Parameters { Ok(params) } + /// The `--readFilesCommand`, bound to the shell `--sysShell` selects. + /// + /// `None` when no command was given, in which case compression is + /// detected from the file extension. + pub fn read_command(&self) -> Option { + self.read_files_command + .as_deref() + .filter(|c| !c.trim().is_empty() && *c != "-") + .map(|c| crate::io::fastq::ReadCommand::new(c, &self.sys_shell)) + } + /// Returns true if `--quantMode GeneCounts` was requested. pub fn quant_gene_counts(&self) -> bool { self.quant_mode.iter().any(|m| m == "GeneCounts") @@ -2185,6 +2378,112 @@ mod tests { Parameters::try_parse_from(&full) } + // ── --parametersFiles ──────────────────────────────────────────────── + + fn write_param_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + path.to_string_lossy().into_owned() + } + + #[test] + fn parameters_file_sets_values_and_skips_comments() { + let dir = tempfile::tempdir().unwrap(); + let f = write_param_file( + dir.path(), + "star.params", + "# a comment\n\ + // another comment\n\ + \n\ + outFilterMismatchNmax 3\n\ + runThreadN 4\n\ + outSAMattributes NH HI AS nM\n", + ); + let p = try_parse(&["--readFilesIn", "r.fq", "--parametersFiles", &f]).unwrap(); + assert_eq!(p.out_filter_mismatch_nmax, 3); + assert_eq!(p.run_thread_n, NonZeroUsize::new(4).unwrap()); + } + + #[test] + fn parameters_file_is_overridden_by_the_command_line() { + let dir = tempfile::tempdir().unwrap(); + let f = write_param_file(dir.path(), "star.params", "outFilterMismatchNmax 3\n"); + let p = try_parse(&[ + "--readFilesIn", + "r.fq", + "--parametersFiles", + &f, + "--outFilterMismatchNmax", + "7", + ]) + .unwrap(); + assert_eq!(p.out_filter_mismatch_nmax, 7); + } + + #[test] + fn parameters_file_multi_value_is_replaced_not_appended() { + // clap appends repeated occurrences of a multi-value argument, so the + // loader has to drop the file's copy rather than rely on ordering. + let dir = tempfile::tempdir().unwrap(); + let f = write_param_file(dir.path(), "star.params", "readFilesIn a.fq b.fq\n"); + let p = try_parse(&["--parametersFiles", &f, "--readFilesIn", "c.fq"]).unwrap(); + assert_eq!(p.read_files_in, vec![PathBuf::from("c.fq")]); + } + + #[test] + fn parameters_file_dash_means_none() { + let p = try_parse(&["--readFilesIn", "r.fq", "--parametersFiles", "-"]).unwrap(); + assert_eq!(p.parameters_files, vec!["-".to_string()]); + } + + #[test] + fn parameters_file_rejects_unknown_parameter_name() { + let dir = tempfile::tempdir().unwrap(); + let f = write_param_file(dir.path(), "star.params", "notAParameter 1\n"); + let err = try_parse(&["--readFilesIn", "r.fq", "--parametersFiles", &f]).unwrap_err(); + assert!( + err.to_string().contains("notAParameter"), + "error should name the offending parameter: {err}" + ); + } + + #[test] + fn parameters_file_rejects_empty_value() { + let dir = tempfile::tempdir().unwrap(); + let f = write_param_file(dir.path(), "star.params", "outFilterMismatchNmax\n"); + let err = try_parse(&["--readFilesIn", "r.fq", "--parametersFiles", &f]).unwrap_err(); + assert!( + err.to_string().contains("empty value"), + "error should say the value is empty: {err}" + ); + } + + #[test] + fn parameters_file_rejects_nested_parameters_files() { + let dir = tempfile::tempdir().unwrap(); + let f = write_param_file(dir.path(), "star.params", "parametersFiles other.params\n"); + let err = try_parse(&["--readFilesIn", "r.fq", "--parametersFiles", &f]).unwrap_err(); + assert!( + err.to_string().contains("command-line-only"), + "error should explain the restriction: {err}" + ); + } + + #[test] + fn parameters_file_missing_is_an_error_naming_the_path() { + let err = try_parse(&[ + "--readFilesIn", + "r.fq", + "--parametersFiles", + "/nonexistent/star.params", + ]) + .unwrap_err(); + assert!( + err.to_string().contains("/nonexistent/star.params"), + "error should name the unreadable file: {err}" + ); + } + #[test] fn defaults() { let p = try_parse(&["--readFilesIn", "reads.fq"]).unwrap(); diff --git a/src/solo/mod.rs b/src/solo/mod.rs index 22d63e02..4d8f659b 100644 --- a/src/solo/mod.rs +++ b/src/solo/mod.rs @@ -239,7 +239,7 @@ impl SoloReadReader { cdna_path: &Path, barcode_path: &Path, layout: SoloBarcodeLayout, - decompress_cmd: Option<&str>, + decompress_cmd: Option<&crate::io::fastq::ReadCommand>, ) -> Result { Ok(Self { cdna: FastqReader::open(cdna_path, decompress_cmd)?, @@ -304,7 +304,7 @@ pub fn open_reader(params: &Parameters) -> Result { )) })?; let layout = SoloBarcodeLayout::from_params(params); - SoloReadReader::open(cdna, barcode, layout, params.read_files_command.as_deref()) + SoloReadReader::open(cdna, barcode, layout, params.read_command().as_ref()) } /// One paired-end solo read for `--soloBarcodeMate 1` (5' 10x): both mates carry @@ -329,7 +329,7 @@ impl SoloPairedReader { mate1_path: &Path, mate2_path: &Path, layout: SoloBarcodeLayout, - decompress_cmd: Option<&str>, + decompress_cmd: Option<&crate::io::fastq::ReadCommand>, ) -> Result { Ok(Self { mate1: FastqReader::open(mate1_path, decompress_cmd)?, @@ -380,7 +380,7 @@ pub fn open_paired_reader(params: &Parameters) -> Result= FLOOR, + "STAR parameter coverage fell to {accepted}/{}; the floor is {FLOOR}", + star.len() + ); +} + #[test] fn inert_parameters_are_actually_accepted() { // A name in ACCEPTED_BUT_INERT that the CLI does not accept would be a