diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a57e84..857f467 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,15 @@ Sections commonly used: Features, Bug fixes, Other changes.
### Bug fixes
+- FASTA reads are accepted, in one or several lines, mixed with FASTQ, as in STAR. Their records have `*` for quality, and they go to `Unmapped.out.mate*` as FASTA.
+- A blank line at the end of a read file no longer stops the run. A blank line with reads after it is an error. See `DIVERGENCE.md` ยง4.8.
+- Mates named `SRR1.5.1` / `SRR1.5.2` are accepted as a pair. `SRR1.1` / `SRR1.2` are still refused, since there the number is the read's.
+- Both records of a pair carry mate 1's full name as QNAME, as STAR does. A `_1`, `.R1` or `.1` suffix was removed from it.
+- Sort spill files stay in the output folder when `--outFileNamePrefix` ends in `/`. They went to the folder above it.
+- `--outBAMcompression -1` compresses like level 1, as STAR does. It wrote uncompressed BAM.
+
+### Bug fixes
+
- Streamed FASTQ input (FIFOs, `<(...)`, `/dev/stdin`) fails instead of passing
for complete. A compressed stream cut in a way the FASTQ parser took for a
clean end of file (gzip trailer, member header, or decoded text ending on a
diff --git a/DIVERGENCE.md b/DIVERGENCE.md
index 6d65f0a..bb7e84c 100644
--- a/DIVERGENCE.md
+++ b/DIVERGENCE.md
@@ -341,6 +341,18 @@ rustar-aligner uses an in-tree splitmix64 (`src/rng.rs`) rather than the `rand`
**Source.** `FastqReader::open_with_command` in `src/io/fastq.rs`; `join_read_files_command` in `src/params/mod.rs`.
+### 4.8 A blank line before more reads is an error
+
+**What STAR does.** Its read loop stops at the first line starting with a space or a newline (observed: `@q1 ... \n\n@q2 ...` maps `q1`, ignores `q2`, exit 0).
+
+**What rustar-aligner does.** Blank lines at the end of a file are fine, as in STAR. Data after a blank line stops the run with an error naming the file.
+
+**Why.** Reads after the blank line would be lost with exit code 0.
+
+**Impact.** Only input with a blank line in the middle. Those runs now fail rather than align part of the file.
+
+**Source.** `src/io/fastq.rs` (`expect_only_blank_lines`), locked by `blank_lines_end_a_file_but_not_before_more_reads`.
+
## 5. Known residual single-read differences
These are **not** deliberate divergences. They are the differences `test/parity_suite.sh` finds, kept here so that each one is fixed or explained before v0.2.0 (README, *Comparison with STAR 2.7.11b*). Read names are from that suite's datasets.
diff --git a/src/io/bam.rs b/src/io/bam.rs
index be8b040..220a648 100644
--- a/src/io/bam.rs
+++ b/src/io/bam.rs
@@ -36,11 +36,13 @@ impl BufferedBamRecords {
/// Convert STAR's `--outBAMcompression` integer (-1..9) to a noodles level.
///
-/// STAR mapping: -1 or 0 = uncompressed, 1-9 = deflate levels, default 1.
+/// 0 = uncompressed, 1-9 = deflate levels. STAR's -1 compresses like level 1
+/// (measured: 59,925 bytes against 59,924 at level 1 and 635,969 at level 0).
fn bgzf_compression(level: i32) -> bgzf::io::writer::CompressionLevel {
use bgzf::io::writer::CompressionLevel;
match level {
- n if n <= 0 => CompressionLevel::NONE,
+ 0 => CompressionLevel::NONE,
+ n if n < 0 => CompressionLevel::try_from(1).unwrap_or_default(),
n if n >= 9 => CompressionLevel::BEST,
n => CompressionLevel::try_from(n as u8).unwrap_or_default(),
}
@@ -166,6 +168,13 @@ fn sort_temp_dir(params: &Parameters) -> std::path::PathBuf {
return std::path::PathBuf::from(¶ms.out_tmp_dir);
}
let prefix = params.output_path("");
+ // `Path::parent` of `out/sub/` is `out`, one level above the output.
+ if params
+ .out_file_name_prefix
+ .ends_with(['/', std::path::MAIN_SEPARATOR])
+ {
+ return prefix;
+ }
match prefix.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => std::path::PathBuf::from("."),
@@ -714,6 +723,22 @@ mod tests {
use noodles::sam::alignment::record::cigar;
use tempfile::NamedTempFile;
+ #[test]
+ fn minus_one_compresses_like_level_one_and_zero_does_not() {
+ assert_eq!(bgzf_compression(-1), bgzf_compression(1));
+ assert_ne!(bgzf_compression(0), bgzf_compression(1));
+ }
+
+ #[test]
+ fn spill_files_stay_inside_a_directory_prefix() {
+ let args =
+ |prefix: &'static str| ["x", "--readFilesIn", "r.fq", "--outFileNamePrefix", prefix];
+ let dir = |prefix| sort_temp_dir(&Parameters::parse_from(args(prefix)));
+ assert_eq!(dir("/data/run1/"), std::path::Path::new("/data/run1/"));
+ assert_eq!(dir("/data/run1/s_"), std::path::Path::new("/data/run1"));
+ assert_eq!(dir("s_"), std::path::Path::new("."));
+ }
+
fn create_test_genome() -> Genome {
Genome {
transform_blocks: None,
diff --git a/src/io/fastq.rs b/src/io/fastq.rs
index 6cae978..dfc37fd 100644
--- a/src/io/fastq.rs
+++ b/src/io/fastq.rs
@@ -25,7 +25,9 @@ impl UnmappedFastqWriter {
/// Write one FASTQ record. `seq` is in genome encoding (0=A,1=C,2=G,3=T,4=N).
/// `qual` is raw FASTQ quality bytes.
pub fn write_record(&mut self, name: &str, seq: &[u8], qual: &[u8]) -> Result<(), Error> {
- self.writer.write_all(b"@").map_err(Error::from)?;
+ // A FASTA input read (no quality) stays FASTA, as STAR writes it.
+ let marker: &[u8] = if qual.is_empty() { b">" } else { b"@" };
+ self.writer.write_all(marker).map_err(Error::from)?;
self.writer
.write_all(name.as_bytes())
.map_err(Error::from)?;
@@ -35,6 +37,9 @@ impl UnmappedFastqWriter {
.write_all(&[decode_base(b)])
.map_err(Error::from)?;
}
+ if qual.is_empty() {
+ return self.writer.write_all(b"\n").map_err(Error::from);
+ }
self.writer.write_all(b"\n+\n").map_err(Error::from)?;
self.writer.write_all(qual).map_err(Error::from)?;
self.writer.write_all(b"\n").map_err(Error::from)
@@ -63,7 +68,7 @@ pub struct EncodedRead {
/// A paired-end read from two FASTQ files
#[derive(Debug, Clone)]
pub struct PairedRead {
- /// Base read name (without /1 or /2 suffix)
+ /// Mate 1's read name, which both mates' records carry
pub name: String,
/// First mate in pair
pub mate1: EncodedRead,
@@ -253,6 +258,15 @@ impl FastqReader {
}
fn next_in_current_file(&mut self) -> Result