1pub const VERBOSE_LOG_FILTER: &str = "info";
5
6pub const QUIET_LOG_FILTER: &str = "warn";
8
9use flate2::read::MultiGzDecoder;
10use rayon::prelude::*;
11use std::ffi::OsStr;
12use std::fs::File;
13use std::io::{BufRead, BufReader, BufWriter, Write};
14use std::path::Path;
15use tempfile::tempdir;
16
17#[cfg(test)]
18mod tests;
19
20pub enum Delimiter {
22 Str(String),
23 Chars(Vec<char>),
24}
25
26impl From<&str> for Delimiter {
27 fn from(s: &str) -> Self {
28 Delimiter::Str(s.to_string())
29 }
30}
31
32impl From<Vec<char>> for Delimiter {
33 fn from(chars: Vec<char>) -> Self {
34 Delimiter::Chars(chars)
35 }
36}
37
38impl From<&[char]> for Delimiter {
39 fn from(chars: &[char]) -> Self {
40 Delimiter::Chars(chars.to_vec())
41 }
42}
43
44impl<const N: usize> From<&[char; N]> for Delimiter {
45 fn from(chars: &[char; N]) -> Self {
46 Delimiter::Chars(chars.to_vec())
47 }
48}
49
50pub fn read_lines(input_file_path: &str) -> anyhow::Result<Vec<Box<str>>> {
56 let buf: Box<dyn BufRead> = open_buf_reader(input_file_path)?;
57 let mut lines = vec![];
58 for x in buf.lines() {
59 lines.push(x?.into_boxed_str());
60 }
61 Ok(lines)
62}
63
64pub fn write_lines(lines: &Vec<Box<str>>, output_file_path: &str) -> anyhow::Result<()> {
71 write_types(lines, output_file_path)
72}
73
74pub fn write_types<T>(lines: &Vec<T>, output_file_path: &str) -> anyhow::Result<()>
81where
82 T: std::fmt::Display,
83{
84 let mut buf = open_buf_writer(output_file_path)?;
85 for line in lines {
86 if let Err(e) = writeln!(buf, "{}", line) {
87 if e.kind() == std::io::ErrorKind::BrokenPipe {
88 return Ok(());
89 } else {
90 return Err(anyhow::anyhow!("unexpected error: {}", e));
91 }
92 }
93 }
94 buf.flush()?;
95 Ok(())
96}
97
98pub struct ReadLinesOut<T: Send> {
99 pub lines: Vec<Vec<T>>,
100 pub header: Vec<Box<str>>,
101}
102
103pub fn read_lines_of_words_generic<T>(
111 input_file: &str,
112 hdr_line: i64,
113 parse_header_fn: impl Fn(&str) -> Vec<Box<str>> + Sync,
114 parse_fn: impl Fn(&str) -> Vec<T> + Sync,
115) -> anyhow::Result<ReadLinesOut<T>>
116where
117 T: Send,
118{
119 let buf_reader: Box<dyn BufRead> = open_buf_reader(input_file)?;
120
121 fn is_not_comment_line(line: &str) -> bool {
122 if line.starts_with('#') || line.starts_with('%') {
123 return false;
124 }
125 true
126 }
127
128 let lines_raw: Vec<Box<str>> = buf_reader
129 .lines()
130 .map_while(Result::ok)
131 .map(|x| x.into_boxed_str())
132 .filter(|x| is_not_comment_line(x.as_ref()))
133 .collect();
134
135 let mut header = vec![];
136
137 let mut lines: Vec<(usize, Vec<T>)> = if hdr_line < 0 {
139 lines_raw
140 .iter()
141 .enumerate()
142 .par_bridge()
143 .map(|(i, s)| (i, parse_fn(s)))
144 .collect()
145 } else {
146 let n_skip = hdr_line as usize;
147 if lines_raw.len() < (n_skip + 1) {
148 return Err(anyhow::anyhow!("not enough data"));
149 }
150
151 header.extend(parse_header_fn(&lines_raw[n_skip]));
152
153 lines_raw[(n_skip + 1)..]
154 .iter()
155 .enumerate()
156 .par_bridge()
157 .map(|(i, s)| (i, parse_fn(s)))
158 .collect()
159 };
160
161 if lines.len() > 100_000 {
162 lines.par_sort_by_key(|&(i, _)| i);
163 } else {
164 lines.sort_by_key(|&(i, _)| i);
165 }
166
167 let lines = lines.into_iter().map(|(_, x)| x).collect();
168 Ok(ReadLinesOut { lines, header })
169}
170
171pub fn read_lines_of_types<T>(
179 input_file: &str,
180 delim: impl Into<Delimiter>,
181 hdr_line: i64,
182) -> anyhow::Result<ReadLinesOut<T>>
183where
184 T: Send + std::str::FromStr + std::fmt::Display,
185 <T as std::str::FromStr>::Err: std::fmt::Debug,
186{
187 let delim = delim.into(); let parse_fn = move |line: &str| -> Vec<T> {
190 match &delim {
191 Delimiter::Str(s) => line
192 .split(s.as_str())
193 .map(|x| x.parse::<T>().expect("failed to parse"))
194 .collect(),
195 Delimiter::Chars(chars) => line
196 .split(chars.as_slice())
197 .map(|x| x.parse::<T>().expect("failed to parse"))
198 .collect(),
199 }
200 };
201
202 let parse_header_fn = |line: &str| -> Vec<Box<str>> {
203 line.split_whitespace()
204 .map(|x| x.to_owned().into_boxed_str())
205 .collect()
206 };
207
208 read_lines_of_words_generic(input_file, hdr_line, parse_header_fn, parse_fn)
209}
210
211pub fn read_lines_of_words(
218 input_file: &str,
219 hdr_line: i64,
220) -> anyhow::Result<ReadLinesOut<Box<str>>> {
221 let parse_fn = |line: &str| -> Vec<Box<str>> {
222 line.split_whitespace()
223 .map(|x| x.to_owned().into_boxed_str())
224 .collect()
225 };
226
227 read_lines_of_words_generic(input_file, hdr_line, parse_fn, parse_fn)
228}
229
230pub fn unquote_field(x: &str) -> &str {
246 let t = x.trim();
247 for q in ['"', '\''] {
248 if t.len() >= 2 && t.starts_with(q) && t.ends_with(q) {
249 return &t[q.len_utf8()..t.len() - q.len_utf8()];
250 }
251 }
252 t
253}
254
255pub fn read_lines_of_words_delim(
256 input_file: &str,
257 delim: impl Into<Delimiter>,
258 hdr_line: i64,
259) -> anyhow::Result<ReadLinesOut<Box<str>>> {
260 let delim = delim.into(); let parse_fn = |line: &str| -> Vec<Box<str>> {
281 match &delim {
282 Delimiter::Str(s) => line
283 .split(s.as_str())
284 .map(|x| unquote_field(x).to_owned().into_boxed_str())
285 .collect(),
286 Delimiter::Chars(chars) => line
287 .split(chars.as_slice())
288 .map(|x| unquote_field(x).to_owned().into_boxed_str())
289 .collect(),
290 }
291 };
292
293 read_lines_of_words_generic(input_file, hdr_line, parse_fn, parse_fn)
294}
295
296const NAME_LIST_HEADERS: [&str; 12] = [
303 "gene",
304 "genes",
305 "gene_name",
306 "gene_names",
307 "gene_id",
308 "gene_symbol",
309 "feature",
310 "features",
311 "feature_name",
312 "symbol",
313 "name",
314 "id",
315];
316
317fn name_list_column(header: &[Box<str>]) -> Option<usize> {
319 header.iter().position(|h| {
320 let h = h.trim().trim_matches('"').to_ascii_lowercase();
321 NAME_LIST_HEADERS.contains(&h.as_str())
322 })
323}
324
325pub fn read_name_list(file_path: &str) -> anyhow::Result<Vec<Box<str>>> {
339 let is_parquet = Path::new(file_path)
340 .extension()
341 .and_then(OsStr::to_str)
342 .is_some_and(|e| e.eq_ignore_ascii_case("parquet"));
343
344 let names: Vec<Box<str>> = if is_parquet {
345 let header = crate::matrix::parquet::peek_parquet_field_names(file_path)?;
346 let col = name_list_column(&header).unwrap_or(0);
347 crate::matrix::parquet::read_parquet_string_column(file_path, col)?
348 } else {
349 let raw = read_lines(file_path)?;
350 let data_lines: Vec<&str> = raw
351 .iter()
352 .map(|line| line.trim())
353 .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('%'))
354 .collect();
355
356 let delim: &[char] = match data_lines.first() {
360 Some(first) if first.contains('\t') => &['\t'],
361 Some(first) if first.contains(',') => &[','],
362 _ => &[' ', '\t'],
363 };
364
365 let rows: Vec<Vec<&str>> = data_lines
368 .iter()
369 .map(|line| {
370 line.split(delim)
371 .map(|w| w.trim().trim_matches('"'))
372 .filter(|w| !w.is_empty())
373 .collect::<Vec<&str>>()
374 })
375 .filter(|row| !row.is_empty())
376 .collect();
377
378 let header: Vec<Box<str>> = rows
382 .first()
383 .map(|row| row.iter().map(|w| (*w).into()).collect())
384 .unwrap_or_default();
385 let (col, skip) = match name_list_column(&header) {
386 Some(col) => (col, 1),
387 None => (0, 0),
388 };
389
390 rows.iter()
391 .skip(skip)
392 .filter_map(|row| row.get(col).map(|w| (*w).into()))
393 .collect()
394 };
395
396 let mut seen: std::collections::HashSet<Box<str>> = std::collections::HashSet::new();
397 let names: Vec<Box<str>> = names
398 .into_iter()
399 .filter(|n| !n.is_empty())
400 .filter(|n| seen.insert(n.clone()))
401 .collect();
402
403 if names.is_empty() {
404 return Err(anyhow::anyhow!("no names found in {file_path}"));
405 }
406 Ok(names)
407}
408
409pub fn open_buf_reader(input_file: &str) -> anyhow::Result<Box<dyn BufRead>> {
413 let ext = Path::new(input_file).extension().and_then(|x| x.to_str());
416 match ext {
417 Some("gz") | Some("bgz") | Some("bgzf") => {
418 let input_file = File::open(input_file)?;
419 let decoder = MultiGzDecoder::new(input_file);
424 Ok(Box::new(BufReader::with_capacity(1 << 20, decoder)))
427 }
428 _ => {
429 let input_file = File::open(input_file)?;
431 Ok(Box::new(BufReader::new(input_file)))
432 }
433 }
434}
435
436pub fn first_line_fields(path: &str, delimiters: &[char]) -> anyhow::Result<Vec<Box<str>>> {
446 let mut first = String::new();
447 open_buf_reader(path)?.read_line(&mut first)?;
448 Ok(first
449 .trim_end_matches(['\n', '\r'])
450 .split(delimiters)
451 .map(|f| unquote_field(f).to_string().into_boxed_str())
452 .filter(|f| !f.is_empty())
453 .collect())
454}
455
456pub fn detect_header_row_numeric(file_path: &str, delimiters: &[char]) -> Option<usize> {
467 let mut first = String::new();
468 open_buf_reader(file_path)
469 .ok()?
470 .read_line(&mut first)
471 .ok()?;
472 let fields: Vec<&str> = first
473 .trim_end_matches(['\n', '\r'])
474 .split(delimiters)
475 .map(unquote_field)
476 .collect();
477 let any_non_numeric_after_col0 = fields
478 .iter()
479 .skip(1)
480 .any(|t| !t.is_empty() && !is_numeric_or_missing(t));
481 let preview = fields
485 .iter()
486 .take(6)
487 .copied()
488 .collect::<Vec<_>>()
489 .join(", ");
490 if any_non_numeric_after_col0 {
491 log::info!("{file_path}: first line treated as a header (non-numeric): [{preview}]");
492 Some(0)
493 } else {
494 log::info!(
495 "{file_path}: first line treated as data (all numeric after column 0): [{preview}]"
496 );
497 None
498 }
499}
500
501fn is_numeric_or_missing(t: &str) -> bool {
504 t.parse::<f64>().is_ok() || matches!(t, "NA" | "N/A" | "na" | "n/a")
505}
506
507pub fn open_buf_writer(output_file: &str) -> anyhow::Result<Box<dyn std::io::Write>> {
511 if output_file.eq_ignore_ascii_case("stdout") {
513 return Ok(Box::new(std::io::BufWriter::new(std::io::stdout())));
514 }
515
516 if output_file.eq_ignore_ascii_case("stderr") {
517 return Ok(Box::new(std::io::BufWriter::new(std::io::stderr())));
518 }
519
520 let output_file = Path::new(output_file);
522 let ext = output_file.extension().and_then(|x| x.to_str());
523
524 match ext {
525 Some("gz") => {
526 let output_file = File::create(output_file)?;
527 let encoder =
528 flate2::write::GzEncoder::new(output_file, flate2::Compression::default());
529 Ok(Box::new(BufWriter::new(encoder)))
530 }
531 _ => {
532 let output_file = File::create(output_file)?;
533 Ok(Box::new(BufWriter::new(output_file)))
534 }
535 }
536}
537
538pub fn mkdir(file: &str) -> anyhow::Result<()> {
543 let path = Path::new(file);
544 std::fs::create_dir_all(path)?;
545 Ok(())
546}
547
548pub fn mkdir_parent(path: &str) -> anyhow::Result<()> {
559 if let Some(parent) = Path::new(path).parent() {
560 if !parent.as_os_str().is_empty() {
561 std::fs::create_dir_all(parent)?;
562 }
563 }
564 Ok(())
565}
566
567pub trait PathOsToStr {
568 #[allow(clippy::wrong_self_convention)]
569 fn into_boxed_str(&self) -> Box<str>;
570}
571
572impl PathOsToStr for Path {
573 fn into_boxed_str(&self) -> Box<str> {
574 self.to_str()
575 .expect("failed to convert to string")
576 .to_string()
577 .into_boxed_str()
578 }
579}
580
581impl PathOsToStr for OsStr {
582 fn into_boxed_str(&self) -> Box<str> {
583 self.to_str()
584 .expect("failed to convert to string")
585 .to_string()
586 .into_boxed_str()
587 }
588}
589
590pub fn recursive_copy(src_path: &str, dst_path: &str) -> anyhow::Result<()> {
592 let src = Path::new(src_path);
593 let dst = Path::new(dst_path);
594
595 if src.is_dir() {
596 mkdir(dst_path)?;
597 for entry in std::fs::read_dir(src)? {
598 let entry = entry?;
599 if let (Some(src_path), Some(dst_path)) =
600 (entry.path().to_str(), dst.join(entry.file_name()).to_str())
601 {
602 let file_type = entry.file_type()?;
603 if file_type.is_dir() {
604 recursive_copy(src_path, dst_path)?;
605 } else if file_type.is_file() {
606 std::fs::copy(src_path, dst_path)?;
607 }
608 }
609 }
610 } else if src.is_file() {
611 if let Some(dir) = dirname(dst_path).as_deref() {
612 mkdir(dir)?;
613 }
614 std::fs::copy(src, dst)?;
615 } else if src.is_symlink() {
616 if let Ok(abs_src) = std::fs::read_link(src) {
617 if let Some(abs_src_path) = abs_src.to_str() {
618 recursive_copy(abs_src_path, dst_path)?;
619 }
620 }
621 }
622
623 Ok(())
624}
625
626pub fn unzip_dir(zip_path: &str, extract_path: Option<&str>) -> anyhow::Result<Box<str>> {
630 let zip_file = std::fs::File::open(zip_path)?;
631 let mut archive = zip::ZipArchive::new(zip_file)?;
632
633 let extract_path = extract_path
634 .map(std::path::PathBuf::from)
635 .unwrap_or(std::env::current_dir()?);
636
637 for i in 0..archive.len() {
638 let mut file = archive.by_index(i)?;
639 let out_path = extract_path.join(file.name());
640 if file.is_dir() {
642 std::fs::create_dir_all(&out_path)?;
643 } else {
644 if let Some(parent) = out_path.parent() {
645 std::fs::create_dir_all(parent)?;
646 }
647 let mut outfile = std::fs::File::create(&out_path)?;
648 std::io::copy(&mut file, &mut outfile)?;
649 }
650 }
651
652 Ok(extract_path.into_boxed_str())
653}
654
655pub fn zip_dir(source_dir: &str, zip_path: &str) -> anyhow::Result<()> {
659 zip_dir_as(source_dir, zip_path, None)
660}
661
662pub fn zip_dir_as(
666 source_dir: &str,
667 zip_path: &str,
668 entry_root: Option<&str>,
669) -> anyhow::Result<()> {
670 use std::io::Write;
671 use zip::write::SimpleFileOptions;
672 use zip::ZipWriter;
673
674 let file = std::fs::File::create(zip_path)?;
675 let mut zip = ZipWriter::new(file);
676 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
677 let source = Path::new(source_dir);
678
679 fn collect_entries(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> std::io::Result<()> {
680 for entry in std::fs::read_dir(dir)? {
681 let entry = entry?;
682 let path = entry.path();
683 out.push(path.clone());
684 if entry.file_type()?.is_dir() {
687 collect_entries(&path, out)?;
688 }
689 }
690 Ok(())
691 }
692
693 let mut entries = vec![];
694 collect_entries(source, &mut entries)?;
695 entries.sort();
696
697 let root_name =
698 entry_root.unwrap_or_else(|| source.file_name().and_then(|s| s.to_str()).unwrap_or(""));
699
700 for path in &entries {
701 let rel_under_source = path.strip_prefix(source).unwrap_or(path);
702 let rel = if root_name.is_empty() {
703 rel_under_source.to_path_buf()
704 } else {
705 Path::new(root_name).join(rel_under_source)
706 };
707 if path.symlink_metadata()?.is_dir() {
708 zip.add_directory(format!("{}/", rel.display()), options)?;
709 } else {
710 zip.start_file(rel.display().to_string(), options)?;
711 let data = std::fs::read(path)?;
712 zip.write_all(&data)?;
713 }
714 }
715
716 zip.finish()?;
717 Ok(())
718}
719
720pub fn dirname(file_path: &str) -> Option<Box<str>> {
722 Path::new(file_path).parent().map(|x| x.into_boxed_str())
723}
724
725pub fn dir_base_ext(file_path: &str) -> anyhow::Result<(Box<str>, Box<str>, Box<str>)> {
730 let path = Path::new(file_path);
731
732 let dir = path
733 .parent()
734 .map_or(".".to_string().into_boxed_str(), |x| x.into_boxed_str());
735
736 let ext = path
737 .extension()
738 .map_or("".to_string().into_boxed_str(), |x| x.into_boxed_str());
739
740 let base = path
741 .file_stem()
742 .and_then(|x| x.to_str())
743 .map(|x| strip_data_ext(x).to_string().into_boxed_str())
744 .ok_or(anyhow::anyhow!("failed to find base here: {}", file_path))?;
745
746 Ok((dir, base, ext))
747}
748
749pub fn basename(file: &str) -> anyhow::Result<Box<str>> {
754 let path = Path::new(file);
755 if let Some(base) = path.file_stem().and_then(|s| s.to_str()) {
756 Ok(strip_data_ext(base).to_string().into_boxed_str())
757 } else {
758 Err(anyhow::anyhow!("no file stem"))
759 }
760}
761
762fn strip_data_ext(stem: &str) -> &str {
766 for sfx in [".zarr", ".h5ad", ".h5"] {
767 if let Some(s) = stem.strip_suffix(sfx) {
768 return s;
769 }
770 }
771 stem
772}
773
774pub fn file_ext(file: &str) -> anyhow::Result<Box<str>> {
779 let path = Path::new(file);
780 if let Some(ext) = path.extension() {
781 Ok(ext.into_boxed_str())
782 } else {
783 Err(anyhow::anyhow!("failed to extract extension"))
784 }
785}
786
787pub fn create_temp_dir_file(suffix: &str) -> anyhow::Result<std::path::PathBuf> {
792 let temp_dir = tempdir()?.path().to_path_buf();
793 std::fs::create_dir_all(&temp_dir)?;
794 let temp_file = tempfile::Builder::new()
795 .suffix(suffix)
796 .tempfile_in(temp_dir)?
797 .path()
798 .to_owned();
799
800 Ok(temp_file)
801}
802
803pub fn remove_file(file: &str) -> anyhow::Result<()> {
808 let path = Path::new(file);
809 if path.exists() {
810 if path.is_file() {
811 std::fs::remove_file(path)?;
812 } else {
813 std::fs::remove_dir_all(path)?;
814 }
815 }
816 Ok(())
817}
818
819pub fn remove_all_files(files: &Vec<Box<str>>) -> anyhow::Result<()> {
824 for file in files {
825 remove_file(file)?;
826 }
827 Ok(())
828}
829
830pub const DATA_FILE_EXTENSIONS: &[&str] = &[
833 "gz", "bgz", "bz2", "zst", "tsv", "csv", "txt", "tab", "gaf", "gmt", "obo", "bed", "vcf",
834 "parquet", "pq",
835];
836
837pub fn file_stem(path: &str) -> String {
841 let mut stem = std::path::Path::new(path)
842 .file_name()
843 .map(|s| s.to_string_lossy().into_owned())
844 .unwrap_or_else(|| path.to_string());
845 while let Some((base, ext)) = stem.rsplit_once('.') {
846 if base.is_empty() || !DATA_FILE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
847 break;
848 }
849 stem.truncate(base.len());
850 }
851 stem
852}