Skip to content

Commit 040042f

Browse files
pinin4fjordsclaudeflying-sheep
authored
fix(params): treat --outFileNamePrefix as a literal string prefix (#46)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Philipp A. <flying-sheep@web.de>
1 parent 3861a6b commit 040042f

4 files changed

Lines changed: 176 additions & 35 deletions

File tree

src/chimeric/output.rs

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ impl ChimericJunctionWriter {
2222
///
2323
/// Creates file: {prefix}Chimeric.out.junction
2424
pub fn new(prefix: &str) -> Result<Self, Error> {
25-
let mut path = PathBuf::from(prefix);
26-
path.push("Chimeric.out.junction");
25+
let path = PathBuf::from(format!("{prefix}Chimeric.out.junction"));
2726

2827
let file = File::create(&path).map_err(|e| Error::io(e, &path))?;
2928

@@ -229,23 +228,39 @@ mod tests {
229228
#[test]
230229
fn test_chimeric_junction_writer_creation() {
231230
let dir = tempdir().unwrap();
232-
let prefix = dir.path().to_str().unwrap();
231+
let prefix = format!("{}/", dir.path().display());
233232

234-
let writer = ChimericJunctionWriter::new(prefix);
233+
let writer = ChimericJunctionWriter::new(&prefix);
235234
assert!(writer.is_ok());
236235

237-
let mut path = PathBuf::from(prefix);
238-
path.push("Chimeric.out.junction");
236+
let path = PathBuf::from(format!("{prefix}Chimeric.out.junction"));
239237
assert!(path.exists());
240238
}
241239

240+
#[test]
241+
fn test_chimeric_junction_writer_bare_dot_prefix() {
242+
let dir = tempdir().unwrap();
243+
let prefix = format!("{}/SAMPLE.", dir.path().display());
244+
245+
let writer = ChimericJunctionWriter::new(&prefix);
246+
assert!(writer.is_ok());
247+
248+
let path = PathBuf::from(format!("{prefix}Chimeric.out.junction"));
249+
assert!(path.exists(), "expected {} to exist", path.display());
250+
assert!(
251+
path.file_name().unwrap().to_str().unwrap() == "SAMPLE.Chimeric.out.junction",
252+
"expected literal concatenation, got {}",
253+
path.display()
254+
);
255+
}
256+
242257
#[test]
243258
fn test_write_inter_chromosomal() {
244259
use cigar::op::{Kind, Op};
245260
let dir = tempdir().unwrap();
246-
let prefix = dir.path().to_str().unwrap();
261+
let prefix = format!("{}/", dir.path().display());
247262

248-
let mut writer = ChimericJunctionWriter::new(prefix).unwrap();
263+
let mut writer = ChimericJunctionWriter::new(&prefix).unwrap();
249264

250265
// Create mock chimeric alignment (chr9 -> chr22, BCR-ABL fusion)
251266
let donor = ChimericSegment {
@@ -290,8 +305,7 @@ mod tests {
290305
writer.flush().unwrap();
291306

292307
// Read file and verify
293-
let mut path = PathBuf::from(prefix);
294-
path.push("Chimeric.out.junction");
308+
let path = PathBuf::from(format!("{prefix}Chimeric.out.junction"));
295309

296310
let mut content = String::new();
297311
File::open(&path)
@@ -323,9 +337,9 @@ mod tests {
323337
fn test_write_strand_break() {
324338
use cigar::op::{Kind, Op};
325339
let dir = tempdir().unwrap();
326-
let prefix = dir.path().to_str().unwrap();
340+
let prefix = format!("{}/", dir.path().display());
327341

328-
let mut writer = ChimericJunctionWriter::new(prefix).unwrap();
342+
let mut writer = ChimericJunctionWriter::new(&prefix).unwrap();
329343

330344
// Create mock chimeric alignment (same chr, opposite strands)
331345
let donor = ChimericSegment {
@@ -370,8 +384,7 @@ mod tests {
370384
writer.flush().unwrap();
371385

372386
// Read file and verify
373-
let mut path = PathBuf::from(prefix);
374-
path.push("Chimeric.out.junction");
387+
let path = PathBuf::from(format!("{prefix}Chimeric.out.junction"));
375388

376389
let mut content = String::new();
377390
File::open(&path)

src/lib.rs

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ fn align_reads(params: &Parameters) -> anyhow::Result<()> {
273273
let time_finish = chrono::Local::now();
274274

275275
// Write Log.final.out
276-
let log_path = params.out_file_name_prefix.join("Log.final.out");
276+
let log_path = params.output_path("Log.final.out");
277277
if let Some(parent) = log_path.parent() {
278278
std::fs::create_dir_all(parent)?;
279279
}
@@ -282,7 +282,7 @@ fn align_reads(params: &Parameters) -> anyhow::Result<()> {
282282

283283
// Write ReadsPerGene.out.tab if quantMode GeneCounts was requested.
284284
if let Some(ref ctx) = quant_ctx {
285-
let quant_path = params.out_file_name_prefix.join("ReadsPerGene.out.tab");
285+
let quant_path = params.output_path("ReadsPerGene.out.tab");
286286
ctx.counts.write_output(&quant_path, &ctx.gene_ann)?;
287287
info!("Wrote {}", quant_path.display());
288288
}
@@ -313,9 +313,7 @@ fn run_single_pass(
313313

314314
// Open transcriptome BAM writer if requested.
315315
let mut tr_writer: Option<BamWriter> = if let Some(tidx) = tr.as_ref() {
316-
let path = params
317-
.out_file_name_prefix
318-
.join("Aligned.toTranscriptome.out.bam");
316+
let path = params.output_path("Aligned.toTranscriptome.out.bam");
319317
info!("Writing transcriptome BAM to {}", path.display());
320318
if let Some(parent) = path.parent() {
321319
std::fs::create_dir_all(parent)?;
@@ -332,15 +330,15 @@ fn run_single_pass(
332330
let is_paired = params.read_files_in.len() == 2;
333331
let mut unmapped_w1: Option<UnmappedFastqWriter> =
334332
if params.out_reads_unmapped == OutReadsUnmapped::Fastx {
335-
let path = params.out_file_name_prefix.join("Unmapped.out.mate1");
333+
let path = params.output_path("Unmapped.out.mate1");
336334
info!("Writing unmapped reads to {}", path.display());
337335
Some(UnmappedFastqWriter::create(&path)?)
338336
} else {
339337
None
340338
};
341339
let mut unmapped_w2: Option<UnmappedFastqWriter> =
342340
if params.out_reads_unmapped == OutReadsUnmapped::Fastx && is_paired {
343-
let path = params.out_file_name_prefix.join("Unmapped.out.mate2");
341+
let path = params.output_path("Unmapped.out.mate2");
344342
info!("Writing unmapped mate2 reads to {}", path.display());
345343
Some(UnmappedFastqWriter::create(&path)?)
346344
} else {
@@ -379,7 +377,7 @@ fn run_single_pass(
379377
}
380378
OutStd::None => match out_type.format {
381379
OutSamFormat::Sam => {
382-
let output_path = params.out_file_name_prefix.join("Aligned.out.sam");
380+
let output_path = params.output_path("Aligned.out.sam");
383381
info!("Writing SAM to {}", output_path.display());
384382
if let Some(parent) = output_path.parent() {
385383
std::fs::create_dir_all(parent)?;
@@ -389,11 +387,9 @@ fn run_single_pass(
389387
OutSamFormat::Bam => {
390388
let sorted = out_type.sort_order == Some(OutSamSortOrder::SortedByCoordinate);
391389
let output_path = if sorted {
392-
params
393-
.out_file_name_prefix
394-
.join("Aligned.sortedByCoord.out.bam")
390+
params.output_path("Aligned.sortedByCoord.out.bam")
395391
} else {
396-
params.out_file_name_prefix.join("Aligned.out.bam")
392+
params.output_path("Aligned.out.bam")
397393
};
398394
info!("Writing BAM to {}", output_path.display());
399395
if let Some(parent) = output_path.parent() {
@@ -451,7 +447,7 @@ fn run_single_pass(
451447
}
452448

453449
// 5. Write SJ.out.tab file
454-
let sj_output_path = params.out_file_name_prefix.join("SJ.out.tab");
450+
let sj_output_path = params.output_path("SJ.out.tab");
455451
if !sj_stats.is_empty() {
456452
info!(
457453
"Writing splice junction statistics to {}",
@@ -480,7 +476,7 @@ fn run_two_pass(
480476
let (sj_stats_pass1, novel_junctions) = run_pass1(index, params)?;
481477

482478
// Write SJ.pass1.out.tab
483-
let pass1_path = params.out_file_name_prefix.join("SJ.pass1.out.tab");
479+
let pass1_path = params.output_path("SJ.pass1.out.tab");
484480

485481
// Create output directory if it doesn't exist
486482
if let Some(parent) = pass1_path.parent() {
@@ -878,12 +874,11 @@ fn align_reads_single_end<W: AlignmentWriter + ?Sized>(
878874
// Create chimeric output writer if enabled
879875
let mut chimeric_writer = if params.chim_segment_min > 0 && params.chim_out_junctions() {
880876
use crate::chimeric::ChimericJunctionWriter;
881-
let prefix = params.out_file_name_prefix.to_str().unwrap_or(".");
882877
info!(
883878
"Chimeric detection enabled (chimSegmentMin={})",
884879
params.chim_segment_min
885880
);
886-
Some(ChimericJunctionWriter::new(prefix)?)
881+
Some(ChimericJunctionWriter::new(&params.out_file_name_prefix)?)
887882
} else {
888883
None
889884
};
@@ -1309,12 +1304,11 @@ fn align_reads_paired_end<W: AlignmentWriter + ?Sized>(
13091304
// Create chimeric output writer if enabled
13101305
let mut chimeric_writer = if params.chim_segment_min > 0 && params.chim_out_junctions() {
13111306
use crate::chimeric::ChimericJunctionWriter;
1312-
let prefix = params.out_file_name_prefix.to_str().unwrap_or(".");
13131307
info!(
13141308
"Chimeric detection enabled (chimSegmentMin={})",
13151309
params.chim_segment_min
13161310
);
1317-
Some(ChimericJunctionWriter::new(prefix)?)
1311+
Some(ChimericJunctionWriter::new(&params.out_file_name_prefix)?)
13181312
} else {
13191313
None
13201314
};

src/params.rs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ pub struct Parameters {
344344
// ── Output ──────────────────────────────────────────────────────────
345345
/// Output file name prefix (including path)
346346
#[arg(long = "outFileNamePrefix", default_value = "./")]
347-
pub out_file_name_prefix: PathBuf,
347+
pub out_file_name_prefix: String,
348348

349349
/// Output type: SAM, BAM Unsorted, BAM SortedByCoordinate, None.
350350
/// Provide as space-separated tokens, e.g. "BAM SortedByCoordinate".
@@ -704,6 +704,11 @@ pub struct Parameters {
704704
}
705705

706706
impl Parameters {
707+
/// Build an output path by concatenating `suffix` onto `out_file_name_prefix`.
708+
pub fn output_path(&self, suffix: &str) -> PathBuf {
709+
PathBuf::from(format!("{}{suffix}", self.out_file_name_prefix))
710+
}
711+
707712
/// Parse the raw `--outSAMtype` tokens into a structured `OutSamType`.
708713
pub fn out_sam_type(&self) -> Result<OutSamType, String> {
709714
match self
@@ -1008,7 +1013,7 @@ mod tests {
10081013
assert_eq!(p.read_map_number, -1);
10091014
assert_eq!(p.clip5p_nbases, 0);
10101015
assert_eq!(p.clip3p_nbases, 0);
1011-
assert_eq!(p.out_file_name_prefix, PathBuf::from("./"));
1016+
assert_eq!(p.out_file_name_prefix, "./");
10121017
assert_eq!(p.out_sam_type_raw, vec!["SAM".to_string()]);
10131018
assert_eq!(p.out_sam_strand_field, "None");
10141019
assert_eq!(p.out_sam_attributes, vec!["Standard".to_string()]);
@@ -1145,7 +1150,7 @@ mod tests {
11451150
sam_type.sort_order,
11461151
Some(OutSamSortOrder::SortedByCoordinate)
11471152
);
1148-
assert_eq!(p.out_file_name_prefix, PathBuf::from("/out/sample1_"));
1153+
assert_eq!(p.out_file_name_prefix, "/out/sample1_");
11491154
assert_eq!(p.out_filter_multimap_nmax, 20);
11501155
assert_eq!(p.align_intron_max, 1_000_000);
11511156
assert_eq!(p.sjdb_gtf_file, Some(PathBuf::from("gencode.gtf")));
@@ -1487,4 +1492,50 @@ mod tests {
14871492
]);
14881493
assert_eq!(p.align_sj_stitch_mismatch_nmax, vec![1, -1, 2, 3]);
14891494
}
1495+
1496+
#[test]
1497+
fn output_path_bare_dot_prefix() {
1498+
let p = parse(&["--readFilesIn", "r.fq", "--outFileNamePrefix", "SAMPLE."]);
1499+
assert_eq!(p.out_file_name_prefix, "SAMPLE.");
1500+
assert_eq!(
1501+
p.output_path("Aligned.out.bam"),
1502+
PathBuf::from("SAMPLE.Aligned.out.bam")
1503+
);
1504+
assert_eq!(
1505+
p.output_path("Log.final.out"),
1506+
PathBuf::from("SAMPLE.Log.final.out")
1507+
);
1508+
}
1509+
1510+
#[test]
1511+
fn output_path_trailing_slash_prefix() {
1512+
let p = parse(&["--readFilesIn", "r.fq", "--outFileNamePrefix", "out/"]);
1513+
assert_eq!(
1514+
p.output_path("Aligned.out.bam"),
1515+
PathBuf::from("out/Aligned.out.bam")
1516+
);
1517+
}
1518+
1519+
#[test]
1520+
fn output_path_default_prefix() {
1521+
let p = parse(&["--readFilesIn", "r.fq"]);
1522+
assert_eq!(
1523+
p.output_path("Aligned.out.bam"),
1524+
PathBuf::from("./Aligned.out.bam")
1525+
);
1526+
}
1527+
1528+
#[test]
1529+
fn output_path_path_with_underscore() {
1530+
let p = parse(&[
1531+
"--readFilesIn",
1532+
"r.fq",
1533+
"--outFileNamePrefix",
1534+
"/out/sample1_",
1535+
]);
1536+
assert_eq!(
1537+
p.output_path("Aligned.out.bam"),
1538+
PathBuf::from("/out/sample1_Aligned.out.bam")
1539+
);
1540+
}
14901541
}

tests/alignment_features.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,3 +791,86 @@ fn test_two_pass_mode() {
791791
"expected at least 1 alignment record, got {record_count}"
792792
);
793793
}
794+
795+
// ---------------------------------------------------------------------------
796+
// Test 9 — bare-dot prefix is treated as a literal string prefix (issue #26)
797+
//
798+
// STAR treats `--outFileNamePrefix SAMPLE.` as a literal prefix concatenated
799+
// onto each output filename (SAMPLE.Aligned.out.bam at the top level), not as
800+
// a directory name. This test asserts rustar-aligner matches that behaviour.
801+
// ---------------------------------------------------------------------------
802+
803+
#[test]
804+
fn test_bare_dot_prefix_is_literal_string() {
805+
let tmpdir = TempDir::new().unwrap();
806+
let genome = build_genome();
807+
let fasta = write_fasta(&tmpdir, &genome);
808+
809+
let genome_dir = tmpdir.path().join("genome");
810+
build_index(&fasta, &genome_dir, "7", None);
811+
812+
let fastq_path = tmpdir.path().join("reads.fq");
813+
{
814+
let mut f = fs::File::create(&fastq_path).unwrap();
815+
for i in 0..50usize {
816+
let start = 100 + i * 100;
817+
let seq = &genome[start..start + 50];
818+
writeln!(f, "@read{}", i + 1).unwrap();
819+
f.write_all(seq).unwrap();
820+
writeln!(f).unwrap();
821+
writeln!(f, "+").unwrap();
822+
writeln!(f, "{}", "I".repeat(50)).unwrap();
823+
}
824+
}
825+
826+
let run_dir = tmpdir.path().join("bare_dot_run");
827+
fs::create_dir_all(&run_dir).unwrap();
828+
// SAMPLE. is a bare-dot prefix; STAR writes SAMPLE.Aligned.out.bam at the top level.
829+
let prefix = format!("{}/SAMPLE.", run_dir.display());
830+
831+
cargo_bin_cmd!("rustar-aligner")
832+
.args([
833+
"--runMode",
834+
"alignReads",
835+
"--genomeDir",
836+
genome_dir.to_str().unwrap(),
837+
"--readFilesIn",
838+
fastq_path.to_str().unwrap(),
839+
"--outSAMtype",
840+
"BAM",
841+
"Unsorted",
842+
"--outFileNamePrefix",
843+
&prefix,
844+
])
845+
.assert()
846+
.success();
847+
848+
let bam_path = run_dir.join("SAMPLE.Aligned.out.bam");
849+
let log_path = run_dir.join("SAMPLE.Log.final.out");
850+
let bam_as_dir = run_dir.join("SAMPLE.").join("Aligned.out.bam");
851+
852+
assert!(
853+
bam_path.exists(),
854+
"expected literal prefix file at {}, but it was not created",
855+
bam_path.display()
856+
);
857+
assert!(
858+
log_path.exists(),
859+
"expected literal prefix file at {}, but it was not created",
860+
log_path.display()
861+
);
862+
assert!(
863+
!bam_as_dir.exists(),
864+
"bare-dot prefix was treated as a directory: {} should not exist",
865+
bam_as_dir.display()
866+
);
867+
868+
let mut reader = bam::io::Reader::new(fs::File::open(&bam_path).unwrap());
869+
let _header = reader.read_header().expect("BAM header readable");
870+
let mut count = 0usize;
871+
for rec in reader.records() {
872+
rec.expect("valid BAM record");
873+
count += 1;
874+
}
875+
assert!(count >= 1, "expected at least 1 BAM record, got {count}");
876+
}

0 commit comments

Comments
 (0)