Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)

Expand Down
5 changes: 4 additions & 1 deletion docs/src/content/docs/reference/cli-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
125 changes: 125 additions & 0 deletions src/index/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ impl GenomeIndex {
pub fn load(genome_dir: &Path, params: &Parameters) -> Result<Self, Error> {
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, &params.version_genome)?;

// Load Genome file
let genome = load_genome(genome_dir, params)?;
log::info!(
Expand Down Expand Up @@ -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>, 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::<u32>().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<Option<String>, 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<n_genome> <sa_size>` 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,
Expand Down Expand Up @@ -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());
}
}
164 changes: 159 additions & 5 deletions src/io/fastq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,75 @@ pub struct FastqReader {
name_separators: Vec<u8>,
}

/// A `--readFilesCommand`, plus the shell it is run through (`--sysShell`).
///
/// STAR runs the command as `sysShell -c "<command> <file>"`, 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<String>,
}

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<Command, Error> {
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)
///
Expand All @@ -96,7 +165,7 @@ impl FastqReader {
///
/// # Returns
/// A FastqReader that iterates over encoded reads
pub fn open(path: &Path, decompress_cmd: Option<&str>) -> Result<Self, Error> {
pub fn open(path: &Path, decompress_cmd: Option<&ReadCommand>) -> Result<Self, Error> {
let reader: Box<dyn BufRead + Send> = if let Some(cmd) = decompress_cmd {
// Use external decompression command
Self::open_with_command(path, cmd)?
Expand Down Expand Up @@ -153,9 +222,9 @@ impl FastqReader {
}

/// Open FASTQ file using external decompression command
fn open_with_command(path: &Path, cmd: &str) -> Result<Box<dyn BufRead + Send>, Error> {
let mut child = Command::new(cmd)
.arg(path)
fn open_with_command(path: &Path, cmd: &ReadCommand) -> Result<Box<dyn BufRead + Send>, Error> {
let mut child = cmd
.build(path)?
.stdout(Stdio::piped())
.spawn()
.map_err(|e| Error::io(e, path))?;
Expand Down Expand Up @@ -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<Self, Error> {
pub fn open(
path1: &Path,
path2: &Path,
decompress_cmd: Option<&ReadCommand>,
) -> Result<Self, Error> {
let reader1 = FastqReader::open(path1, decompress_cmd)?;
let reader2 = FastqReader::open(path2, decompress_cmd)?;

Expand Down Expand Up @@ -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);
}
}
}
Loading
Loading