OSCR

Development and validation of a long-term co-maturation protocol for human stem cell-derived microglia and neuronal networks.

Code ↔ Paper

1 match between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 1 match
  1. [1] § STAR★Methods › Method details › RNA-sequencing ↔ src/cli.rs, lines 231–287 · score 0.54 · FastQC, kit, Library, Illumina, RNA, bp

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Rust · 1,667 lines · 62 KB · GPL-3.0 · 1 match

  1. //! Command-line argument parsing and validation.
  2. use clap::Parser;
  3. use std::path::PathBuf;
  4. /// Output container format. FASTQ is the default and preserves byte-identity
  5. /// with Perl Trim Galore 0.6.11. uBAM is opt-in and carries the input BAM's
  6. /// aux tags through (via `--preserve-tags`).
  7. ///
  8. /// See `plans/06252026_pluggable-io-formats/phase1-trimgalore-formats/PLAN.md`
  9. /// §3.2 for the full output-naming + behaviour matrix.
  10. #[derive(Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
  11. pub enum OutputFormat {
  12. /// FASTQ output, mirroring input compression (default).
  13. #[default]
  14. Fastq,
  15. /// Unaligned BAM output. Always single-threaded in v1; --clumpify,
  16. /// --passthrough, --clock, --implicon, --demux are rejected at validation.
  17. #[clap(name = "ubam")]
  18. UBam,
  19. }
  20. /// Trim Galore: A fast, single-pass NGS adapter and quality trimmer.
  21. ///
  22. /// Drop-in replacement for Trim Galore, rewritten in Rust. Matches v0.6.x outputs
  23. /// for the core feature set and extends it with poly-G / generic poly-A auto-trimming
  24. /// and other additions. Compatible with MultiQC and existing pipelines.
  25. #[derive(Parser, Debug)]
  26. #[clap(
  27. name = "trim_galore",
  28. version = env!("CARGO_PKG_VERSION"),
  29. long_version = concat!(
  30. env!("CARGO_PKG_VERSION"), "\n",
  31. env!("VERSION_BODY")
  32. ),
  33. about
  34. )]
  35. pub struct Cli {
  36. /// Input FASTQ file(s). For paired-end, provide two files.
  37. #[clap(required = true)]
  38. pub input: Vec<PathBuf>,
  39. /// Quality trimming cutoff (Phred score). Bases below this are trimmed from 3' end.
  40. #[clap(short = 'q', long = "quality", default_value = "20")]
  41. pub quality: u8,
  42. /// Adapter sequence for trimming. Auto-detected if not specified.
  43. /// Supports A{N} shorthand for repeated single bases (e.g., -a A{10} → AAAAAAAAAA).
  44. /// For multiple adapters, repeat -a (e.g., -a SEQ1 -a SEQ2) or use "file:adapters.fa".
  45. #[clap(short = 'a', long = "adapter")]
  46. pub adapter: Vec<String>,
  47. /// Optional adapter sequence for Read 2 (paired-end only).
  48. /// Auto-set by --small_rna and --bgiseq presets.
  49. /// Supports A{N} shorthand for repeated single bases (e.g., -a2 T{150} → 150 T's).
  50. /// For multiple adapters, repeat -a2 (e.g., -a2 SEQ1 -a2 SEQ2) or use "file:adapters.fa".
  51. #[clap(long = "adapter2", alias = "a2")]
  52. pub adapter2: Vec<String>,
  53. /// Use Illumina universal adapter (AGATCGGAAGAGC). Also the auto-detect fallback.
  54. #[clap(long = "illumina", conflicts_with_all = &["nextera", "small_rna", "stranded_illumina", "bgiseq"])]
  55. pub illumina: bool,
  56. /// Use Nextera transposase adapter (CTGTCTCTTATA).
  57. #[clap(long = "nextera", conflicts_with_all = &["illumina", "small_rna", "stranded_illumina", "bgiseq"])]
  58. pub nextera: bool,
  59. /// Use Illumina Small RNA adapter (TGGAATTCTCGG).
  60. /// Also lowers --length default to 18 and sets --adapter2 (GATCGTCGGACT, Illumina small RNA 5').
  61. #[clap(long = "small_rna", conflicts_with_all = &["illumina", "nextera", "stranded_illumina", "bgiseq"])]
  62. pub small_rna: bool,
  63. /// Use Illumina Stranded mRNA adapter (ACTGTCTCTTATA).
  64. /// Not covered by auto-detection — must be set explicitly.
  65. #[clap(long = "stranded_illumina", conflicts_with_all = &["illumina", "nextera", "small_rna", "bgiseq"])]
  66. pub stranded_illumina: bool,
  67. /// Use BGI/DNBSEQ adapter. Sets --adapter2 for Read 2. Also probed by auto-detection.
  68. #[clap(long = "bgiseq", conflicts_with_all = &["illumina", "nextera", "small_rna", "stranded_illumina"])]
  69. pub bgiseq: bool,
  70. /// Paired-end mode. Accepts an even number of input files as consecutive R1/R2 pairs
  71. /// (e.g. --paired R1.fq.gz R2.fq.gz, or a glob matching multiple samples).
  72. #[clap(long = "paired")]
  73. pub paired: bool,
  74. /// Maximum allowed error rate for adapter matching (0-1).
  75. #[clap(short = 'e', long = "error_rate", default_value = "0.1")]
  76. pub error_rate: f64,
  77. /// Minimum overlap with adapter sequence required to trim (stringency).
  78. #[clap(long = "stringency", default_value = "1")]
  79. pub stringency: usize,
  80. /// Minimum required sequence length after trimming.
  81. /// Default: 20 (18 for smallRNA adapter).
  82. /// In paired-end mode, both reads must pass; see --retain_unpaired to keep single survivors.
  83. #[clap(long = "length")]
  84. pub length: Option<usize>,
  85. /// Maximum allowed sequence length (discard reads longer than this).
  86. /// Typically only useful for smallRNA-seq to remove non-small-RNA reads.
  87. #[clap(long = "max_length")]
  88. pub max_length: Option<usize>,
  89. /// Maximum number of N bases allowed in a read.
  90. /// Integer: absolute count. Decimal (0-1): fraction of read length.
  91. /// In paired-end mode, either read over the limit removes the whole pair.
  92. #[clap(long = "max_n")]
  93. pub max_n: Option<f64>,
  94. /// Trim N bases from both ends of reads. Suppressed under --rrbs (matches Perl v0.6.x).
  95. #[clap(long = "trim-n", alias = "trim_n")]
  96. pub trim_n: bool,
  97. /// Remove N bases from the 5' end of Read 1. Useful for removing 5' quality-bias regions.
  98. #[clap(long = "clip_R1", alias = "clip_r1")]
  99. pub clip_r1: Option<usize>,
  100. /// Remove N bases from the 5' end of Read 2 (paired-end only).
  101. /// For paired-end bisulfite-seq, the end-repair step can introduce methylation bias; see Bismark User Guide.
  102. #[clap(long = "clip_R2", alias = "clip_r2")]
  103. pub clip_r2: Option<usize>,
  104. /// Remove N bases from the 3' end of Read 1, after adapter/quality trimming.
  105. #[clap(long = "three_prime_clip_R1", alias = "three_prime_clip_r1")]
  106. pub three_prime_clip_r1: Option<usize>,
  107. /// Remove N bases from the 3' end of Read 2 (paired-end only), after adapter/quality trimming.
  108. #[clap(long = "three_prime_clip_R2", alias = "three_prime_clip_r2")]
  109. pub three_prime_clip_r2: Option<usize>,
  110. /// NextSeq/NovaSeq 2-colour quality trimming. Trailing high-quality G bases
  111. /// are treated as no-signal artifacts and quality-trimmed. The value is the
  112. /// quality cutoff (replaces -q). Mutually exclusive with --quality.
  113. #[clap(long = "nextseq", alias = "2colour")]
  114. pub nextseq: Option<u8>,
  115. /// Use Phred+64 quality encoding (Illumina 1.5). Default is Phred+33.
  116. #[clap(long = "phred64")]
  117. pub phred64: bool,
  118. /// Use Phred+33 quality encoding (default, Sanger/Illumina 1.8+).
  119. #[clap(long = "phred33")]
  120. pub phred33: bool,
  121. /// Output directory for trimmed files. Created if it doesn't exist.
  122. #[clap(short = 'o', long = "output_dir")]
  123. pub output_dir: Option<PathBuf>,
  124. /// Custom basename for output files (replaces input filename stem).
  125. /// Only valid for a single file (single-end) or a single pair (paired-end).
  126. #[clap(long = "basename")]
  127. pub basename: Option<String>,
  128. /// Do not gzip-compress output files. Forces plain output regardless of
  129. /// input compression. By default, output compression mirrors the input
  130. /// (plain → plain, .gz → .gz; matches Perl v0.6.x behaviour).
  131. #[clap(long = "dont_gzip")]
  132. pub dont_gzip: bool,
  133. /// Reorder reads in the gzip output so reads sharing a canonical 16-mer
  134. /// minimizer land adjacent, letting gzip's dictionary find longer
  135. /// redundant runs. Typical saving: 16–55% on short-read FASTQ. Records
  136. /// are byte-identical — only their order on disk changes. Pair lockstep
  137. /// is preserved.
  138. ///
  139. /// Requires `--cores >= 2` and gzip output. Intended for short-read
  140. /// FASTQ; long-read inputs (ONT, PacBio) are unlikely to compress
  141. /// better. Combine with `--compression <N>` to trade speed against
  142. /// output size, and `--memory <N>` to grow the per-gzip-member sort
  143. /// run.
  144. #[clap(long = "clumpify")]
  145. pub clumpify: bool,
  146. /// Gzip compression level for output FASTQ (1–9). Default: 1 (fast,
  147. /// 75% larger files). Pass `--compression 6` for the gzip(1) default
  148. /// or `--compression 9` for archival use. Most useful in combination
  149. /// with `--clumpify`, where reordering plus a higher level
  150. /// compounds for substantially smaller output.
  151. #[clap(
  152. long = "compression",
  153. default_value_t = crate::fastq::DEFAULT_GZIP_LEVEL,
  154. value_parser = clap::value_parser!(u32).range(1..=9),
  155. )]
  156. pub compression: u32,
  157. /// Total memory budget for Trim Galore (e.g. `1G`, `512M`, `8G`).
  158. /// Currently used only by `--clumpify` for bin buffer sizing — bigger
  159. /// budget → bigger gzip members → better compression, up to a limit
  160. /// roughly equal to the uncompressed input size. Default: `1G`.
  161. /// Resolved bin layout and predicted peak RSS are printed at startup
  162. /// when `--clumpify` is set.
  163. #[clap(long = "memory", default_value = "1G")]
  164. pub memory: String,
  165. /// Suppress the trimming report.
  166. #[clap(long = "no_report_file")]
  167. pub no_report_file: bool,
  168. /// Comma-separated list of BAM tags (e.g. "CB,UB,RX") to append to the
  169. /// FASTQ header on uBAM input. Tab-separated, samtools `-T`-compatible.
  170. /// Each tag must be a 2-char SAM tag name (`[A-Za-z][A-Za-z0-9]`).
  171. /// Ignored for FASTQ input. The `ALL` keyword is reserved for a future
  172. /// release and rejected in v1. See PLAN §3.2.5.
  173. #[clap(long = "preserve-tags", value_delimiter = ',', value_parser = parse_sam_tag_name)]
  174. pub preserve_tags: Vec<String>,
  175. /// Output container format. Default `fastq` keeps existing behaviour
  176. /// (input-compression-mirroring FASTQ); `ubam` emits unaligned BAM with
  177. /// aux tags propagated from uBAM inputs (when `--preserve-tags` is set).
  178. /// uBAM output is always single-threaded in v1; see the rejection rules
  179. /// in `Cli::validate` for incompatible combinations.
  180. /// See `plans/06252026_pluggable-io-formats/phase1-trimgalore-formats/PLAN.md`.
  181. #[clap(long = "output-format", value_enum, default_value_t = OutputFormat::Fastq)]
  182. pub output_format: OutputFormat,
  183. /// Retain unpaired reads when the mate is too short (paired-end only).
  184. /// Cutoff via --length_1 / --length_2 (default 35 each).
  185. #[clap(long = "retain_unpaired")]
  186. pub retain_unpaired: bool,
  187. /// Minimum length for unpaired Read 1 (with --retain_unpaired).
  188. #[clap(short = 'r', long = "length_1", default_value = "35", alias = "r1")]
  189. pub length_1: usize,
  190. /// Minimum length for unpaired Read 2 (with --retain_unpaired).
  191. #[clap(long = "length_2", default_value = "35", alias = "r2")]
  192. pub length_2: usize,
  193. /// Pass a third FASTQ file through unchanged but keep it in lockstep with R1/R2.
  194. /// Use case: 10X Multiome / scATAC libraries where the cell-barcode (I1/I2) read
  195. /// must stay aligned to the trimmed R1/R2. Records dropped by length/quality/N
  196. /// filters are also dropped from the passthrough output. The passthrough file is
  197. /// never trimmed or adapter-scanned.
  198. ///
  199. /// Requires --paired with exactly one R1/R2 pair. Incompatible with
  200. /// --retain_unpaired, --clumpify, and all specialty modes (--clock, --implicon,
  201. /// --hardtrim5/3, --demux).
  202. ///
  203. /// Note: legacy headers like @read/1, @read/2, @read/3 sync correctly. If your
  204. /// files use unusual header conventions and the sync check fires unexpectedly,
  205. /// please file an issue with a sample header line. If --fastqc is enabled the
  206. /// passthrough FastQC report will look poor (cell-barcode reads are intentionally
  207. /// uniformly-structured) — that's expected, not a defect. On a mid-stream reader
  208. /// error (truncated or desynced passthrough), partial output files may remain on
  209. /// disk — re-run after fixing the input.
  210. #[clap(long = "passthrough")]
  211. pub passthrough: Option<PathBuf>,
  212. /// Add clipped sequences to read IDs for --clip_R1/R2, --three_prime_clip_R1/R2, and --hardtrim5/3.
  213. /// Appends :clip5:SEQ and/or :clip3:SEQ to the read ID (each half only when that side was clipped). Useful for UMI handling.
  214. #[clap(long = "rename")]
  215. pub rename: bool,
  216. /// If auto-detected adapter count is at or below this threshold,
  217. /// skip adapter trimming (only quality trimming proceeds).
  218. /// Incompatible with explicit adapter presets.
  219. #[clap(long = "consider_already_trimmed",
  220. conflicts_with_all = &["illumina", "nextera", "small_rna", "stranded_illumina", "bgiseq"])]
  221. pub consider_already_trimmed: Option<usize>,
  222. /// RRBS mode for MspI-digested samples. Removes 2bp end-repair artifacts
  223. /// at MspI cut sites after adapter trimming. In paired-end directional mode,
  224. /// automatically sets --clip_R2 2 unless the user provides their own value.
  225. /// Do not use with Tecan Ovation RRBS kits — those use a diversity-trimming step instead.
  226. #[clap(long = "rrbs")]
  227. pub rrbs: bool,
  228. /// Non-directional RRBS libraries. Reads starting with CAA or CGA get 2bp
  229. /// trimmed from the 5' end. Requires --rrbs.
  230. /// Unlike directional --rrbs, does not auto-set --clip_R2 2 in paired-end mode.
  231. #[clap(long = "non_directional", requires = "rrbs")]
  232. pub non_directional: bool,
  233. /// Run FastQC on the trimmed output files (built in via the bundled
  234. /// fastqc-rust library; no external Java or FastQC binary needed).
  235. /// Produces FastQC 0.12.1-compatible *_fastqc.html / *_fastqc.zip artifacts.
  236. #[clap(long = "fastqc")]
  237. pub fastqc: bool,
  238. /// Additional arguments to pass to FastQC. Implies --fastqc.
  239. /// Common flags are translated to the bundled engine: --nogroup, --expgroup,
  240. /// --quiet, --svg, --nano, --nofilter, --casava, -t/--threads, -o/--outdir.
  241. /// Unrecognised flags emit a warning and are ignored.
  242. #[clap(long = "fastqc_args", allow_hyphen_values = true)]
  243. pub fastqc_args: Option<String>,
  244. /// Number of worker threads for parallel processing (default: 1).
  245. /// At --cores 1 the worker-pool is bypassed (single thread, ~5 MB RAM).
  246. /// From --cores 2 upward, an N+4 thread model applies: N workers + 2
  247. /// decompressors + 1 batcher + 1 writer. Wall-clock speedup is near-linear
  248. /// up to --cores 8 for paired-end runs; beyond that, gzip-output I/O on
  249. /// the storage layer typically becomes binding before workers run out of
  250. /// useful per-read work, so additional cores help progressively less.
  251. #[clap(short = 'j', long = "cores", default_value = "1")]
  252. pub cores: usize,
  253. /// Trim poly-A tails from the 3' end of Read 1 (and single-end reads),
  254. /// and poly-T heads from the 5' end of Read 2. Runs after adapter trimming,
  255. /// so poly-A tails hidden behind adapters are also removed.
  256. #[clap(long = "poly_a", alias = "poly-a", alias = "polyA")]
  257. pub poly_a: bool,
  258. /// Trim poly-G tails from the 3' end of Read 1 (and single-end reads),
  259. /// and poly-C heads from the 5' end of Read 2. Useful for data from
  260. /// 2-colour instruments (NovaSeq, NextSeq) where no-signal bases are
  261. /// called as high-quality G. By default, poly-G trimming is auto-detected
  262. /// from the data. Use this flag to force-enable it.
  263. /// This is independent from --nextseq (quality-based G-trimming).
  264. #[clap(
  265. long = "poly_g",
  266. alias = "poly-g",
  267. alias = "polyG",
  268. conflicts_with = "no_poly_g"
  269. )]
  270. pub poly_g: bool,
  271. /// Disable poly-G auto-detection and trimming.
  272. #[clap(long = "no_poly_g", alias = "no-poly-g", alias = "no-polyG")]
  273. pub no_poly_g: bool,
  274. /// Number of adapter trimming rounds per read. With multiple adapters,
  275. /// this allows removing more than one adapter from the same read.
  276. /// Default: 1. Typical multi-adapter usage: -n 3.
  277. #[clap(short = 'n', long = "times", default_value = "1")]
  278. pub times: usize,
  279. /// Discard reads that did not contain an adapter sequence. Only reads
  280. /// where at least one adapter match was found are written to output.
  281. /// For paired-end, the pair is discarded if neither read had an adapter.
  282. #[clap(long = "discard_untrimmed", alias = "discard-untrimmed")]
  283. pub discard_untrimmed: bool,
  284. // --- Specialty modes (run-and-exit, bypass normal trimming) ---
  285. /// Hard-trim to keep only the first N bases from the 5' end.
  286. /// Bypasses adapter/quality trimming entirely. Output filenames end in .<N>bp_5prime.fq(.gz).
  287. #[clap(long = "hardtrim5")]
  288. pub hardtrim5: Option<usize>,
  289. /// Hard-trim to keep only the last N bases from the 3' end.
  290. /// Bypasses adapter/quality trimming entirely. Output filenames end in .<N>bp_3prime.fq(.gz).
  291. #[clap(long = "hardtrim3")]
  292. pub hardtrim3: Option<usize>,
  293. /// Epigenetic Clock mode (paired-end only). Extracts 8bp UMI + 4bp
  294. /// fixed sequence (CAGT) from both reads, appends to read IDs, and
  295. /// clips R1 at position 13, R2 at position 15. Bypasses normal trimming.
  296. #[clap(long = "clock", alias = "casio", alias = "breitling")]
  297. pub clock: bool,
  298. /// Transfer the first N bases from Read 2 as a UMI barcode to both
  299. /// read IDs, then clip R2 by N bases. Paired-end only.
  300. /// Bypasses normal trimming (IMPLICON preprocessing).
  301. /// Default UMI length: 8 (used when --implicon is given without a value).
  302. #[clap(long = "implicon", alias = "umi_from_r2",
  303. default_missing_value = "8", num_args = 0..=1, require_equals = true)]
  304. pub implicon: Option<usize>,
  305. /// Demultiplex reads after trimming based on 3' inline barcodes.
  306. /// Takes a barcode file (TSV: sample_name\tbarcode_sequence).
  307. /// Barcode is removed from the read and appended to the read ID.
  308. /// Single-end only.
  309. #[clap(long = "demux")]
  310. pub demux: Option<PathBuf>,
  311. // --- Deprecated flags (accepted for backwards compatibility, no-ops) ---
  312. /// [Deprecated] Output is gzipped by default in v2.0. Use --dont_gzip to disable.
  313. #[clap(long = "gzip", hide = true)]
  314. pub gzip: bool,
  315. /// [Deprecated] No longer needed — Cutadapt is built in.
  316. #[clap(long = "path_to_cutadapt", hide = true)]
  317. pub path_to_cutadapt: Option<String>,
  318. /// [Deprecated] No longer needed — Cutadapt is built in.
  319. #[clap(long = "cutadapt_args", hide = true, allow_hyphen_values = true)]
  320. pub cutadapt_args: Option<String>,
  321. /// [Deprecated] v2.0 emits only essential progress output; use shell redirection if quieter output is needed.
  322. #[clap(long = "suppress_warn", hide = true)]
  323. pub suppress_warn: bool,
  324. /// [Deprecated] Reports are generated by default.
  325. #[clap(long = "report", hide = true)]
  326. pub report: bool,
  327. /// [Deprecated] The v2.0 single-pass architecture has no quality-trim intermediate file to keep.
  328. #[clap(long = "keep", hide = true)]
  329. pub keep: bool,
  330. /// Easter egg (no-op).
  331. #[clap(long = "hulu", hide = true)]
  332. pub hulu: bool,
  333. }
  334. /// Rewrite Perl-era multi-character short flags (`-r1`, `-r2`, `-a2`) as
  335. /// their clap-compatible long-alias forms (`--r1`, `--r2`, `--a2`) before
  336. /// parsing.
  337. ///
  338. /// Clap derives single-character short flags only, so e.g. `-r1 40` would
  339. /// parse as `-r=1` with `40` becoming a stray positional, producing a
  340. /// confusing "odd count of input files" error. `-a2 SEQ` would similarly
  341. /// parse as `-a=2` with `SEQ` becoming a positional input file. This
  342. /// pre-parse hook transparently rewrites the exact tokens so Perl-era
  343. /// invocations keep working.
  344. ///
  345. /// Only exact-match tokens are rewritten — `-r10` (legitimate clap
  346. /// `-r=10`) and any other value-suffixed form pass through unchanged.
  347. /// clap value parser for `--preserve-tags`. Each tag must be a valid 2-char
  348. /// SAM tag name (`[A-Za-z][A-Za-z0-9]`). The `ALL` keyword is reserved.
  349. fn parse_sam_tag_name(s: &str) -> Result<String, String> {
  350. if s == "ALL" {
  351. return Err("--preserve-tags ALL is not supported in v1 (use an explicit list)".into());
  352. }
  353. let bytes = s.as_bytes();
  354. if bytes.len() != 2 {
  355. return Err(format!(
  356. "'{s}' is not a valid SAM tag name (must be exactly 2 characters)"
  357. ));
  358. }
  359. if !bytes[0].is_ascii_alphabetic() || !bytes[1].is_ascii_alphanumeric() {
  360. return Err(format!(
  361. "'{s}' is not a valid SAM tag name (must match [A-Za-z][A-Za-z0-9])"
  362. ));
  363. }
  364. Ok(s.to_string())
  365. }
  366. pub fn rewrite_perl_short_flags<I>(args: I) -> Vec<String>
  367. where
  368. I: IntoIterator<Item = String>,
  369. {
  370. args.into_iter()
  371. .map(|a| {
  372. if a == "-r1" || a.starts_with("-r1=") {
  373. format!("--r1{}", &a[3..])
  374. } else if a == "-r2" || a.starts_with("-r2=") {
  375. format!("--r2{}", &a[3..])
  376. } else if a == "-a2" || a.starts_with("-a2=") {
  377. format!("--a2{}", &a[3..])
  378. } else {
  379. a
  380. }
  381. })
  382. .collect()
  383. }
  384. impl Cli {
  385. /// Shared validation for any paired-end mode (`--paired`, `--clock`,
  386. /// `--implicon`) that takes input files in pairwise (R1, R2, R1, R2, …)
  387. /// order. Checks:
  388. /// 1. Even count of input files.
  389. /// 2. Within each pair, R1 ≠ R2 byte-equal (matches Perl's
  390. /// `$ARGV[$i] eq $ARGV[$i+1]` check at `trim_galore:3208`; does not
  391. /// follow symlinks or canonicalise).
  392. /// 3. Across pairs, no duplicate pair (catches accidental copy-paste
  393. /// and emits a precise error rather than the case-insensitive
  394. /// output-collision pre-flight's APFS/NTFS message).
  395. ///
  396. /// `mode_label` is used in the user-facing error string, e.g.
  397. /// `"Paired-end"`, `"--clock"`, `"--implicon"`.
  398. fn validate_paired_input(&self, mode_label: &str) -> anyhow::Result<()> {
  399. // Allow N=1 for the `--paired` case: that's only legal if the single
  400. // file is a uBAM, in which case the de-interleaver produces R1+R2 from
  401. // one interleaved BAM. The "is it BAM?" check happens at main.rs
  402. // sanity_check time (after format detection has run). For specialty
  403. // modes (--clock, --implicon, --hardtrim) the strict even-count rule
  404. // still applies because they don't yet support uBAM input.
  405. if self.input.len() == 1 && self.paired && mode_label == "Paired-end" {
  406. return Ok(());
  407. }
  408. if !self.input.len().is_multiple_of(2) {
  409. anyhow::bail!(
  410. "{} mode requires an even number of input files (R1/R2 pairs), got {}",
  411. mode_label,
  412. self.input.len()
  413. );
  414. }
  415. for chunk in self.input.chunks(2) {
  416. if chunk[0] == chunk[1] {
  417. anyhow::bail!(
  418. "Read 1 and Read 2 appear to be the same file: {}. \
  419. Did you mean to pass distinct R1 and R2 files?",
  420. chunk[0].display()
  421. );
  422. }
  423. }
  424. let pairs: Vec<(&std::path::PathBuf, &std::path::PathBuf)> =
  425. self.input.chunks(2).map(|c| (&c[0], &c[1])).collect();
  426. for (i, (r1, r2)) in pairs.iter().enumerate() {
  427. for (j, (pr1, pr2)) in pairs.iter().enumerate().take(i) {
  428. if r1 == pr1 && r2 == pr2 {
  429. anyhow::bail!(
  430. "Pair {} ({}, {}) is a duplicate of pair {}. \
  431. Did you mean to pass different files?",
  432. i + 1,
  433. r1.display(),
  434. r2.display(),
  435. j + 1
  436. );
  437. }
  438. }
  439. }
  440. Ok(())
  441. }
  442. /// Validate CLI arguments after parsing.
  443. pub fn validate(&self) -> anyhow::Result<()> {
  444. // §3.4a — `--output-format ubam` exclusions. These are pure CLI-level
  445. // (no file I/O); enforced here. The §3.4b rule (preserve-tags + all
  446. // FASTQ inputs) requires format detection and lives in main.rs.
  447. if matches!(self.output_format, OutputFormat::UBam) {
  448. if self.clumpify {
  449. anyhow::bail!(
  450. "--clumpify is for gzip output; not applicable with --output-format ubam"
  451. );
  452. }
  453. if self.passthrough.is_some() {
  454. anyhow::bail!("--passthrough is not supported with --output-format ubam in v1");
  455. }
  456. if self.clock {
  457. anyhow::bail!(
  458. "--clock + --output-format ubam: UMI-to-BAM-tag mapping not defined in v1; \
  459. use FASTQ output or convert after"
  460. );
  461. }
  462. if self.implicon.is_some() {
  463. anyhow::bail!(
  464. "--implicon + --output-format ubam: UMI-to-BAM-tag mapping not defined in v1"
  465. );
  466. }
  467. if self.demux.is_some() {
  468. anyhow::bail!("--demux is not supported with --output-format ubam in v1");
  469. }
  470. // PLAN v2.1 §3.4a addendum (Step 3 implementation note): v2.1 left
  471. // --retain_unpaired silent, but it produces multiple output files
  472. // (`*_unpaired_{1,2}.fq.gz`) — same shape as --demux / --passthrough
  473. // which are already rejected. Multi-output BAM is out of scope for
  474. // v1; revisit if a real workload demands it.
  475. if self.retain_unpaired {
  476. anyhow::bail!(
  477. "--retain_unpaired is not supported with --output-format ubam in v1 \
  478. (unpaired records would require additional BAM output paths; \
  479. run without --retain_unpaired or post-process via samtools)"
  480. );
  481. }
  482. }
  483. if self.paired {
  484. // `#[clap(required = true)]` on `input` guarantees at least one file
  485. // reaches validate(), so no is_empty() check is needed.
  486. self.validate_paired_input("Paired-end")?;
  487. }
  488. if !self.paired && self.input.len() > 1 && self.basename.is_some() {
  489. anyhow::bail!(
  490. "--basename cannot be used with multiple input files (ambiguous output naming)"
  491. );
  492. }
  493. if self.paired && self.input.len() > 2 && self.basename.is_some() {
  494. anyhow::bail!(
  495. "--basename cannot be used with multiple paired-end pairs (ambiguous output naming)"
  496. );
  497. }
  498. if self.error_rate < 0.0 || self.error_rate > 1.0 {
  499. anyhow::bail!(
  500. "Error rate must be between 0 and 1, got {}",
  501. self.error_rate
  502. );
  503. }
  504. if self.stringency == 0 {
  505. anyhow::bail!("Stringency (minimum overlap) must be at least 1");
  506. }
  507. if self.nextseq.is_some() && self.quality != 20 {
  508. anyhow::bail!(
  509. "--nextseq/--2colour and -q/--quality are mutually exclusive. \
  510. The nextseq value replaces the quality cutoff."
  511. );
  512. }
  513. if let Some(val) = self.nextseq
  514. && (val == 0 || val >= 200)
  515. {
  516. anyhow::bail!(
  517. "NextSeq quality cutoff must be between 1 and 199, got {}",
  518. val
  519. );
  520. }
  521. if let Some(threshold) = self.consider_already_trimmed
  522. && threshold > 10000
  523. {
  524. anyhow::bail!(
  525. "consider_already_trimmed value must be between 0 and 10000, got {}",
  526. threshold
  527. );
  528. }
  529. if self.times == 0 || self.times > 10 {
  530. anyhow::bail!("--times/-n must be between 1 and 10, got {}", self.times);
  531. }
  532. if self.cores == 0 {
  533. anyhow::bail!("--cores must be at least 1");
  534. }
  535. if self.clumpify {
  536. if self.cores < 2 {
  537. anyhow::bail!(
  538. "--clumpify requires --cores >= 2 (the bin dispatcher feeds parallel workers)"
  539. );
  540. }
  541. if self.dont_gzip {
  542. anyhow::bail!(
  543. "--clumpify and --dont_gzip are mutually exclusive (clumping plain text is pointless)"
  544. );
  545. }
  546. if self.clock {
  547. anyhow::bail!("--clumpify is not yet supported with --clock");
  548. }
  549. if self.implicon.is_some() {
  550. anyhow::bail!("--clumpify is not yet supported with --implicon");
  551. }
  552. if self.hardtrim5.is_some() {
  553. anyhow::bail!("--clumpify is not yet supported with --hardtrim5");
  554. }
  555. if self.hardtrim3.is_some() {
  556. anyhow::bail!("--clumpify is not yet supported with --hardtrim3");
  557. }
  558. if self.demux.is_some() {
  559. anyhow::bail!("--clumpify is not yet supported with --demux");
  560. }
  561. // Validate --memory format up front. Whether the resolved bin
  562. // pool is *large enough* for clumpify to actually run is decided
  563. // later in main.rs::resolve_clump_layout, which warns and falls
  564. // back to plain mode if the budget is below the floor.
  565. crate::clump::parse_memory_size(&self.memory)
  566. .map_err(|e| anyhow::anyhow!("--memory: {e}"))?;
  567. }
  568. // --passthrough: 9-item compatibility envelope. Layout mirrors --clumpify
  569. // above. Each rejection has a precise user-facing message; case-folded
  570. // collision check (1.ix) uses crate::io::norm_path to share the same
  571. // APFS/NTFS-aware normalisation as the output-collision pre-flight in
  572. // main.rs (issue #216 protection).
  573. if let Some(ref pt) = self.passthrough {
  574. // 1.i — paired-end required
  575. if !self.paired {
  576. anyhow::bail!("--passthrough requires --paired");
  577. }
  578. // 1.ii — exactly one R1/R2 pair in v1
  579. if self.input.len() != 2 {
  580. anyhow::bail!(
  581. "--passthrough requires exactly one R1/R2 pair (got {} input files); \
  582. multi-pair input with passthrough is not yet implemented",
  583. self.input.len()
  584. );
  585. }
  586. // 1.iii — strict pair semantics in v1
  587. if self.retain_unpaired {
  588. anyhow::bail!(
  589. "--passthrough is incompatible with --retain_unpaired \
  590. (passthrough requires strict pair semantics in v1)"
  591. );
  592. }
  593. // 1.iv — clumpy reorder breaks lockstep
  594. if self.clumpify {
  595. anyhow::bail!("--passthrough is not yet supported with --clumpify");
  596. }
  597. // 1.v–1.vii — specialty modes own their own input arity/output naming
  598. if self.clock {
  599. anyhow::bail!("--passthrough is not compatible with --clock");
  600. }
  601. if self.implicon.is_some() {
  602. anyhow::bail!("--passthrough is not compatible with --implicon");
  603. }
  604. if self.hardtrim5.is_some() {
  605. anyhow::bail!("--passthrough is not compatible with --hardtrim5");
  606. }
  607. if self.hardtrim3.is_some() {
  608. anyhow::bail!("--passthrough is not compatible with --hardtrim3");
  609. }
  610. if self.demux.is_some() {
  611. anyhow::bail!("--passthrough is not compatible with --demux");
  612. }
  613. // 1.viii — file must exist
  614. if !pt.exists() {
  615. anyhow::bail!("--passthrough file not found: {}", pt.display());
  616. }
  617. // 1.ix — case-folded collision with R1/R2 (issue #216-style APFS/NTFS guard).
  618. // self.input.len() == 2 here per 1.ii. The plan's main.rs::run pre-flight
  619. // catches case-only output collisions; this catches case-only INPUT aliases
  620. // where --passthrough silently dual-consumes one input on a case-insensitive
  621. // filesystem.
  622. if self.input.len() == 2 {
  623. let pt_norm = crate::io::norm_path(pt);
  624. if pt_norm == crate::io::norm_path(&self.input[0])
  625. || pt_norm == crate::io::norm_path(&self.input[1])
  626. {
  627. anyhow::bail!(
  628. "--passthrough cannot point at R1 or R2 (case-insensitive match \
  629. on APFS/NTFS): {} aliases an input file",
  630. pt.display()
  631. );
  632. }
  633. }
  634. }
  635. if let Some(n) = self.hardtrim5
  636. && (n == 0 || n >= 1000)
  637. {
  638. anyhow::bail!("--hardtrim5 must be between 1 and 999, got {}", n);
  639. }
  640. if let Some(n) = self.hardtrim3
  641. && (n == 0 || n >= 1000)
  642. {
  643. anyhow::bail!("--hardtrim3 must be between 1 and 999, got {}", n);
  644. }
  645. if self.clock {
  646. self.validate_paired_input("--clock")?;
  647. }
  648. if self.implicon.is_some() {
  649. self.validate_paired_input("--implicon")?;
  650. }
  651. if let Some(ref demux_file) = self.demux {
  652. if self.paired {
  653. anyhow::bail!("Demultiplexing is only allowed for single-end files");
  654. }
  655. if !demux_file.exists() {
  656. anyhow::bail!("Barcode file not found: {}", demux_file.display());
  657. }
  658. }
  659. // Check input files exist
  660. for path in &self.input {
  661. if !path.exists() {
  662. anyhow::bail!("Input file not found: {}", path.display());
  663. }
  664. }
  665. // Deprecation warnings for Perl-era flags
  666. if self.gzip {
  667. eprintln!(
  668. "WARNING: --gzip is deprecated in Trim Galore v2.0. Output is gzipped by default. Use --dont_gzip to disable. Ignoring."
  669. );
  670. }
  671. if self.path_to_cutadapt.is_some() {
  672. eprintln!(
  673. "WARNING: --path_to_cutadapt is deprecated in Trim Galore v2.0 (no external Cutadapt needed). Ignoring."
  674. );
  675. }
  676. if self.cutadapt_args.is_some() {
  677. eprintln!(
  678. "WARNING: --cutadapt_args is deprecated in Trim Galore v2.0 (no external Cutadapt needed). Ignoring."
  679. );
  680. eprintln!(" Note: --discard-untrimmed is now a native flag.");
  681. }
  682. if self.suppress_warn {
  683. eprintln!(
  684. "WARNING: --suppress_warn is deprecated in Trim Galore v2.0 (no Cutadapt subprocess). Ignoring."
  685. );
  686. }
  687. if self.keep {
  688. eprintln!(
  689. "WARNING: --keep is not yet supported in Trim Galore v2.0. RRBS reads below length cutoff will be removed. Ignoring."
  690. );
  691. }
  692. Ok(())
  693. }
  694. /// Get the Phred encoding offset.
  695. pub fn phred_offset(&self) -> u8 {
  696. if self.phred64 { 64 } else { 33 }
  697. }
  698. /// Get the effective quality cutoff value.
  699. ///
  700. /// If `--nextseq` is set, uses that value as the cutoff.
  701. /// Otherwise uses the standard `--quality` value.
  702. pub fn effective_quality_cutoff(&self) -> u8 {
  703. self.nextseq.unwrap_or(self.quality)
  704. }
  705. }
  706. #[cfg(test)]
  707. mod tests {
  708. use super::*;
  709. use clap::Parser;
  710. // All fixtures live in test_files/ and are guaranteed to exist under the
  711. // repo root during `cargo test` (cwd = crate root).
  712. const R1: &str = "test_files/BS-seq_10K_R1.fastq.gz";
  713. const R2: &str = "test_files/BS-seq_10K_R2.fastq.gz";
  714. const ALT_R1: &str = "test_files/SRR24766921_RRBS_R1.fastq.gz";
  715. const ALT_R2: &str = "test_files/SRR24766921_RRBS_R2.fastq.gz";
  716. #[test]
  717. fn test_validate_paired_odd_count_rejected() {
  718. // N=1 with --paired is INTENTIONALLY accepted by validate() — it's
  719. // the new paired-uBAM-interleaved entry point (one BAM file containing
  720. // R1/R2 records interleaved). The "is it actually a BAM?" check
  721. // happens in main.rs after format detection.
  722. // See plans/06252026_ubam-input-support/PLAN.md §3.3.
  723. for inputs in [vec![R1, R2, ALT_R1], vec![R1, R2, ALT_R1, ALT_R2, R1]] {
  724. let mut argv = vec!["trim_galore", "--paired"];
  725. argv.extend(inputs.iter().copied());
  726. let cli = Cli::parse_from(argv);
  727. let err = cli.validate().unwrap_err().to_string();
  728. assert!(
  729. err.contains("even number of input files"),
  730. "expected even-number error, got: {err}"
  731. );
  732. }
  733. }
  734. // ── parse_sam_tag_name (PLAN §5 step 4.3, T16) ──────────────────────
  735. // Both code reviewers flagged the absence of these tests; ~10 LOC to
  736. // lock the validator's contract.
  737. #[test]
  738. fn parse_sam_tag_name_accepts_canonical_two_char() {
  739. assert_eq!(parse_sam_tag_name("CB").unwrap(), "CB");
  740. assert_eq!(parse_sam_tag_name("UB").unwrap(), "UB");
  741. assert_eq!(parse_sam_tag_name("RX").unwrap(), "RX");
  742. assert_eq!(parse_sam_tag_name("A0").unwrap(), "A0");
  743. assert_eq!(parse_sam_tag_name("Zz").unwrap(), "Zz");
  744. }
  745. #[test]
  746. fn parse_sam_tag_name_rejects_all_keyword() {
  747. // PLAN §5 step 4.3 — `ALL` is reserved for a future release.
  748. assert!(parse_sam_tag_name("ALL").is_err());
  749. }
  750. #[test]
  751. fn parse_sam_tag_name_rejects_wrong_length() {
  752. assert!(parse_sam_tag_name("X").is_err());
  753. assert!(parse_sam_tag_name("ABC").is_err());
  754. assert!(parse_sam_tag_name("").is_err());
  755. }
  756. #[test]
  757. fn parse_sam_tag_name_rejects_leading_non_alpha() {
  758. // SAM spec: tags match [A-Za-z][A-Za-z0-9]. Leading digit invalid.
  759. assert!(parse_sam_tag_name("1A").is_err());
  760. assert!(parse_sam_tag_name("9X").is_err());
  761. }
  762. #[test]
  763. fn parse_sam_tag_name_rejects_non_alphanumeric() {
  764. assert!(parse_sam_tag_name("A_").is_err());
  765. assert!(parse_sam_tag_name("A-").is_err());
  766. assert!(parse_sam_tag_name(" A").is_err());
  767. }
  768. #[test]
  769. fn test_validate_paired_single_input_accepted_at_validate_layer() {
  770. // The v3 paired-uBAM change: `--paired SINGLE.bam` is structurally
  771. // legal at the validate() layer. main.rs runs format detection and
  772. // errors if SINGLE.bam turns out to be FASTQ (test that path is
  773. // covered by integration tests, not here).
  774. let cli = Cli::parse_from(["trim_galore", "--paired", R1]);
  775. assert!(
  776. cli.validate().is_ok(),
  777. "--paired with N=1 must be accepted at the structural-validation layer"
  778. );
  779. }
  780. #[test]
  781. fn test_validate_paired_r1_r2_equal_rejected_within_pair() {
  782. let cli = Cli::parse_from(["trim_galore", "--paired", R1, R1]);
  783. let err = cli.validate().unwrap_err().to_string();
  784. assert!(
  785. err.contains("appear to be the same file"),
  786. "expected within-pair duplicate error, got: {err}"
  787. );
  788. }
  789. #[test]
  790. fn test_validate_paired_duplicate_pair_rejected_across_pairs() {
  791. let cli = Cli::parse_from(["trim_galore", "--paired", R1, R2, R1, R2]);
  792. let err = cli.validate().unwrap_err().to_string();
  793. assert!(
  794. err.contains("duplicate of pair"),
  795. "expected cross-pair duplicate error, got: {err}"
  796. );
  797. }
  798. #[test]
  799. fn test_validate_paired_basename_rejected_multi_pair() {
  800. let cli = Cli::parse_from([
  801. "trim_galore",
  802. "--paired",
  803. "--basename",
  804. "foo",
  805. R1,
  806. R2,
  807. ALT_R1,
  808. ALT_R2,
  809. ]);
  810. let err = cli.validate().unwrap_err().to_string();
  811. assert!(
  812. err.contains("basename cannot be used with multiple"),
  813. "expected multi-pair basename rejection, got: {err}"
  814. );
  815. }
  816. #[test]
  817. fn test_validate_paired_single_end_basename_still_allowed() {
  818. // Regression guard: SE `--basename` with a single input must still pass.
  819. let cli = Cli::parse_from(["trim_galore", "--basename", "foo", R1]);
  820. cli.validate()
  821. .expect("SE --basename with one input should validate");
  822. }
  823. #[test]
  824. fn test_validate_paired_two_files_accepted() {
  825. // Regression guard: the 2-file golden path must keep working.
  826. let cli = Cli::parse_from(["trim_galore", "--paired", R1, R2]);
  827. cli.validate().expect("two-file paired-end should validate");
  828. }
  829. // ── Multi-pair widening for --clock and --implicon ──
  830. // (Replaces the earlier "strict-2" regression guard. Specialty
  831. // run-and-exit modes now share the same pairwise validation as
  832. // --paired itself.)
  833. #[test]
  834. fn test_validate_clock_two_pairs_accepted() {
  835. let cli = Cli::parse_from(["trim_galore", "--clock", R1, R2, ALT_R1, ALT_R2]);
  836. cli.validate()
  837. .expect("two distinct pairs should validate under --clock");
  838. }
  839. #[test]
  840. fn test_validate_clock_odd_count_rejected() {
  841. let cli = Cli::parse_from(["trim_galore", "--clock", R1, R2, ALT_R1]);
  842. let err = cli.validate().unwrap_err().to_string();
  843. assert!(
  844. err.contains("--clock") && err.contains("even number"),
  845. "expected --clock even-count rejection, got: {err}"
  846. );
  847. }
  848. #[test]
  849. fn test_validate_clock_r1_equal_r2_within_pair_rejected() {
  850. let cli = Cli::parse_from(["trim_galore", "--clock", R1, R1]);
  851. let err = cli.validate().unwrap_err().to_string();
  852. assert!(
  853. err.contains("appear to be the same file"),
  854. "expected R1==R2 rejection under --clock, got: {err}"
  855. );
  856. }
  857. #[test]
  858. fn test_validate_clock_duplicate_pair_rejected() {
  859. let cli = Cli::parse_from(["trim_galore", "--clock", R1, R2, R1, R2]);
  860. let err = cli.validate().unwrap_err().to_string();
  861. assert!(
  862. err.contains("duplicate of pair"),
  863. "expected duplicate-pair rejection under --clock, got: {err}"
  864. );
  865. }
  866. #[test]
  867. fn test_validate_implicon_two_pairs_accepted() {
  868. let cli = Cli::parse_from(["trim_galore", "--implicon", R1, R2, ALT_R1, ALT_R2]);
  869. cli.validate()
  870. .expect("two distinct pairs should validate under --implicon");
  871. }
  872. #[test]
  873. fn test_validate_implicon_odd_count_rejected() {
  874. let cli = Cli::parse_from(["trim_galore", "--implicon", R1, R2, ALT_R1]);
  875. let err = cli.validate().unwrap_err().to_string();
  876. assert!(
  877. err.contains("--implicon") && err.contains("even number"),
  878. "expected --implicon even-count rejection, got: {err}"
  879. );
  880. }
  881. #[test]
  882. fn test_validate_implicon_duplicate_pair_rejected() {
  883. let cli = Cli::parse_from(["trim_galore", "--implicon", R1, R2, R1, R2]);
  884. let err = cli.validate().unwrap_err().to_string();
  885. assert!(
  886. err.contains("duplicate of pair"),
  887. "expected duplicate-pair rejection under --implicon, got: {err}"
  888. );
  889. }
  890. // ── Perl-migration short-flag rewrite (-r1 → --r1, -r2 → --r2) ──
  891. fn rewrite(args: &[&str]) -> Vec<String> {
  892. super::rewrite_perl_short_flags(args.iter().map(|s| s.to_string()))
  893. }
  894. #[test]
  895. fn test_rewrite_r1_bare() {
  896. assert_eq!(
  897. rewrite(&["trim_galore", "-r1", "40"]),
  898. vec!["trim_galore", "--r1", "40"]
  899. );
  900. }
  901. #[test]
  902. fn test_rewrite_r2_bare() {
  903. assert_eq!(
  904. rewrite(&["trim_galore", "-r2", "35"]),
  905. vec!["trim_galore", "--r2", "35"]
  906. );
  907. }
  908. #[test]
  909. fn test_rewrite_r1_equals_form() {
  910. assert_eq!(
  911. rewrite(&["trim_galore", "-r1=40"]),
  912. vec!["trim_galore", "--r1=40"]
  913. );
  914. }
  915. #[test]
  916. fn test_rewrite_r2_equals_form() {
  917. assert_eq!(
  918. rewrite(&["trim_galore", "-r2=35"]),
  919. vec!["trim_galore", "--r2=35"]
  920. );
  921. }
  922. #[test]
  923. fn test_rewrite_leaves_r_alone() {
  924. // -r 40 is valid clap short; must not be disturbed.
  925. assert_eq!(
  926. rewrite(&["trim_galore", "-r", "40"]),
  927. vec!["trim_galore", "-r", "40"]
  928. );
  929. }
  930. #[test]
  931. fn test_rewrite_leaves_r10_alone() {
  932. // -r10 is clap's short-with-value syntax (-r=10); must not be rewritten.
  933. assert_eq!(
  934. rewrite(&["trim_galore", "-r10"]),
  935. vec!["trim_galore", "-r10"]
  936. );
  937. }
  938. #[test]
  939. fn test_rewrite_leaves_r20_alone() {
  940. // -r20 is clap's short-with-value (-r=20); not a Perl `-r2` + value.
  941. assert_eq!(
  942. rewrite(&["trim_galore", "-r20"]),
  943. vec!["trim_galore", "-r20"]
  944. );
  945. }
  946. #[test]
  947. fn test_rewrite_leaves_unrelated_alone() {
  948. assert_eq!(
  949. rewrite(&["trim_galore", "--paired", "-a", "AGCT", "-o", "outdir"]),
  950. vec!["trim_galore", "--paired", "-a", "AGCT", "-o", "outdir"]
  951. );
  952. }
  953. #[test]
  954. fn test_rewrite_end_to_end_via_parse_from() {
  955. // Verify that after rewriting, Cli::parse_from successfully parses
  956. // -r1 / -r2 style invocations (this is the whole point of the rewrite).
  957. let args = rewrite(&[
  958. "trim_galore",
  959. "--paired",
  960. "--retain_unpaired",
  961. "-r1",
  962. "40",
  963. "-r2",
  964. "30",
  965. "test_files/BS-seq_10K_R1.fastq.gz",
  966. "test_files/BS-seq_10K_R2.fastq.gz",
  967. ]);
  968. let cli = Cli::parse_from(args);
  969. assert_eq!(cli.length_1, 40);
  970. assert_eq!(cli.length_2, 30);
  971. }
  972. #[test]
  973. fn test_rewrite_a2_bare() {
  974. assert_eq!(
  975. rewrite(&["trim_galore", "-a2", "GCAT"]),
  976. vec!["trim_galore", "--a2", "GCAT"]
  977. );
  978. }
  979. #[test]
  980. fn test_rewrite_a2_equals_form() {
  981. assert_eq!(
  982. rewrite(&["trim_galore", "-a2=GCAT"]),
  983. vec!["trim_galore", "--a2=GCAT"]
  984. );
  985. }
  986. #[test]
  987. fn test_rewrite_leaves_a10_alone() {
  988. // -a10 is clap's short-with-value (-a=10) — not a Perl `-a2` construct.
  989. // `10` isn't a valid DNA sequence but that's for validation to catch,
  990. // not for the rewrite to mangle.
  991. assert_eq!(
  992. rewrite(&["trim_galore", "-a10"]),
  993. vec!["trim_galore", "-a10"]
  994. );
  995. }
  996. #[test]
  997. fn test_rewrite_a2_end_to_end_via_parse_from() {
  998. let args = rewrite(&[
  999. "trim_galore",
  1000. "--paired",
  1001. "-a",
  1002. "AGCT",
  1003. "-a2",
  1004. "GCAT",
  1005. "-a2",
  1006. "AAAA",
  1007. "test_files/BS-seq_10K_R1.fastq.gz",
  1008. "test_files/BS-seq_10K_R2.fastq.gz",
  1009. ]);
  1010. let cli = Cli::parse_from(args);
  1011. assert_eq!(cli.adapter, vec!["AGCT"]);
  1012. assert_eq!(cli.adapter2, vec!["GCAT", "AAAA"]);
  1013. }
  1014. /// Perl `trim_galore` accepts the lowercase clip-flag spellings
  1015. /// (`--clip_r1` / `--clip_r2` / `--three_prime_clip_r1` /
  1016. /// `--three_prime_clip_r2`) alongside the uppercase forms. The Rust port
  1017. /// historically only matched the uppercase canonical, breaking every
  1018. /// Perl-era pipeline using the lowercase spelling. Regression for #242.
  1019. #[test]
  1020. fn test_clip_flags_accept_lowercase_aliases() {
  1021. let cli = Cli::parse_from([
  1022. "trim_galore",
  1023. "--paired",
  1024. "--clip_r1",
  1025. "5",
  1026. "--clip_r2",
  1027. "6",
  1028. "--three_prime_clip_r1",
  1029. "7",
  1030. "--three_prime_clip_r2",
  1031. "8",
  1032. R1,
  1033. R2,
  1034. ]);
  1035. assert_eq!(cli.clip_r1, Some(5));
  1036. assert_eq!(cli.clip_r2, Some(6));
  1037. assert_eq!(cli.three_prime_clip_r1, Some(7));
  1038. assert_eq!(cli.three_prime_clip_r2, Some(8));
  1039. // The canonical uppercase forms must of course still work. Mix a few
  1040. // to confirm both aliases resolve to the same field.
  1041. let cli = Cli::parse_from([
  1042. "trim_galore",
  1043. "--paired",
  1044. "--clip_R1",
  1045. "1",
  1046. "--clip_r2",
  1047. "2",
  1048. "--three_prime_clip_R1",
  1049. "3",
  1050. "--three_prime_clip_r2",
  1051. "4",
  1052. R1,
  1053. R2,
  1054. ]);
  1055. assert_eq!(cli.clip_r1, Some(1));
  1056. assert_eq!(cli.clip_r2, Some(2));
  1057. assert_eq!(cli.three_prime_clip_r1, Some(3));
  1058. assert_eq!(cli.three_prime_clip_r2, Some(4));
  1059. }
  1060. // ── --clumpify / --compression validation ────────────────────────────
  1061. #[test]
  1062. fn test_clumpify_requires_cores_at_least_two() {
  1063. let cli = Cli::parse_from(["trim_galore", "--clumpify", "--cores", "1", R1]);
  1064. let err = cli.validate().unwrap_err().to_string();
  1065. assert!(
  1066. err.contains("--clumpify requires --cores >= 2"),
  1067. "got: {err}"
  1068. );
  1069. }
  1070. #[test]
  1071. fn test_clumpify_rejects_dont_gzip() {
  1072. let cli = Cli::parse_from([
  1073. "trim_galore",
  1074. "--clumpify",
  1075. "--cores",
  1076. "2",
  1077. "--dont_gzip",
  1078. R1,
  1079. ]);
  1080. let err = cli.validate().unwrap_err().to_string();
  1081. assert!(err.contains("--dont_gzip"), "got: {err}");
  1082. }
  1083. #[test]
  1084. fn test_clumpify_rejects_clock() {
  1085. let cli = Cli::parse_from([
  1086. "trim_galore",
  1087. "--clumpify",
  1088. "--cores",
  1089. "2",
  1090. "--clock",
  1091. R1,
  1092. R2,
  1093. ]);
  1094. let err = cli.validate().unwrap_err().to_string();
  1095. assert!(err.contains("--clock"), "got: {err}");
  1096. }
  1097. #[test]
  1098. fn test_clumpify_rejects_implicon() {
  1099. let cli = Cli::parse_from([
  1100. "trim_galore",
  1101. "--clumpify",
  1102. "--cores",
  1103. "2",
  1104. "--implicon=8",
  1105. R1,
  1106. R2,
  1107. ]);
  1108. let err = cli.validate().unwrap_err().to_string();
  1109. assert!(err.contains("--implicon"), "got: {err}");
  1110. }
  1111. #[test]
  1112. fn test_clumpify_rejects_hardtrim5() {
  1113. let cli = Cli::parse_from([
  1114. "trim_galore",
  1115. "--clumpify",
  1116. "--cores",
  1117. "2",
  1118. "--hardtrim5",
  1119. "30",
  1120. R1,
  1121. ]);
  1122. let err = cli.validate().unwrap_err().to_string();
  1123. assert!(err.contains("--hardtrim5"), "got: {err}");
  1124. }
  1125. #[test]
  1126. fn test_clumpify_accepts_paired() {
  1127. let cli = Cli::parse_from([
  1128. "trim_galore",
  1129. "--clumpify",
  1130. "--cores",
  1131. "4",
  1132. "--paired",
  1133. R1,
  1134. R2,
  1135. ]);
  1136. cli.validate()
  1137. .expect("clumpify + paired + cores=4 should validate");
  1138. }
  1139. #[test]
  1140. fn test_clumpify_rejects_garbage_memory() {
  1141. let cli = Cli::parse_from([
  1142. "trim_galore",
  1143. "--clumpify",
  1144. "--cores",
  1145. "2",
  1146. "--memory",
  1147. "garbage",
  1148. R1,
  1149. ]);
  1150. let err = cli.validate().unwrap_err().to_string();
  1151. assert!(err.contains("--memory"), "got: {err}");
  1152. }
  1153. #[test]
  1154. fn test_clumpify_too_small_memory_passes_validation() {
  1155. // Validation now accepts a too-small --memory; main.rs resolves the
  1156. // layout at runtime and either succeeds, or warns + falls back to
  1157. // plain mode. This avoids hard-failing a job over a configuration
  1158. // detail the program can recover from.
  1159. let cli = Cli::parse_from([
  1160. "trim_galore",
  1161. "--clumpify",
  1162. "--cores",
  1163. "16",
  1164. "--memory",
  1165. "64M",
  1166. R1,
  1167. ]);
  1168. cli.validate()
  1169. .expect("validation should pass; runtime warns and falls back to plain");
  1170. }
  1171. #[test]
  1172. fn test_compression_defaults_to_one() {
  1173. let cli = Cli::parse_from(["trim_galore", R1]);
  1174. assert_eq!(cli.compression, 1);
  1175. }
  1176. #[test]
  1177. fn test_compression_explicit_level() {
  1178. let cli = Cli::parse_from(["trim_galore", "--compression", "9", R1]);
  1179. assert_eq!(cli.compression, 9);
  1180. }
  1181. #[test]
  1182. fn test_compression_rejects_out_of_range() {
  1183. let result = Cli::try_parse_from(["trim_galore", "--compression", "10", R1]);
  1184. assert!(result.is_err(), "level 10 should be rejected by clap");
  1185. }
  1186. #[test]
  1187. fn test_clumpify_with_compression_six() {
  1188. let cli = Cli::parse_from([
  1189. "trim_galore",
  1190. "--clumpify",
  1191. "--compression",
  1192. "6",
  1193. "--cores",
  1194. "2",
  1195. R1,
  1196. ]);
  1197. cli.validate()
  1198. .expect("clumpify --compression 6 should validate");
  1199. assert!(cli.clumpify);
  1200. assert_eq!(cli.compression, 6);
  1201. }
  1202. // ── --passthrough validation (plan v2 Step 1) ─────────────────────────
  1203. //
  1204. // The third fixture used as the "passthrough" target — any existing
  1205. // test_files/ FASTQ works since we never trim it in validation; we just
  1206. // need a real path so step 1.viii (file exists) is satisfied.
  1207. const PT: &str = "test_files/SRR24766921_RRBS_R2.fastq.gz";
  1208. #[test]
  1209. fn test_passthrough_paired_pair_accepted() {
  1210. let cli = Cli::parse_from(["trim_galore", "--paired", "--passthrough", PT, R1, R2]);
  1211. cli.validate()
  1212. .expect("--passthrough with one R1/R2 pair should validate");
  1213. assert_eq!(cli.passthrough.as_deref(), Some(std::path::Path::new(PT)));
  1214. }
  1215. #[test]
  1216. fn test_passthrough_requires_paired() {
  1217. let cli = Cli::parse_from(["trim_galore", "--passthrough", PT, R1]);
  1218. let err = cli.validate().unwrap_err().to_string();
  1219. assert!(
  1220. err.contains("--passthrough requires --paired"),
  1221. "got: {err}"
  1222. );
  1223. }
  1224. #[test]
  1225. fn test_passthrough_rejects_multi_pair() {
  1226. let cli = Cli::parse_from([
  1227. "trim_galore",
  1228. "--paired",
  1229. "--passthrough",
  1230. PT,
  1231. R1,
  1232. R2,
  1233. ALT_R1,
  1234. ALT_R2,
  1235. ]);
  1236. let err = cli.validate().unwrap_err().to_string();
  1237. assert!(
  1238. err.contains("--passthrough requires exactly one R1/R2 pair"),
  1239. "got: {err}"
  1240. );
  1241. }
  1242. #[test]
  1243. fn test_passthrough_rejects_retain_unpaired() {
  1244. let cli = Cli::parse_from([
  1245. "trim_galore",
  1246. "--paired",
  1247. "--retain_unpaired",
  1248. "--passthrough",
  1249. PT,
  1250. R1,
  1251. R2,
  1252. ]);
  1253. let err = cli.validate().unwrap_err().to_string();
  1254. assert!(err.contains("--retain_unpaired"), "got: {err}");
  1255. }
  1256. #[test]
  1257. fn test_passthrough_rejects_clumpify() {
  1258. let cli = Cli::parse_from([
  1259. "trim_galore",
  1260. "--paired",
  1261. "--clumpify",
  1262. "--cores",
  1263. "2",
  1264. "--passthrough",
  1265. PT,
  1266. R1,
  1267. R2,
  1268. ]);
  1269. let err = cli.validate().unwrap_err().to_string();
  1270. assert!(err.contains("--clumpify"), "got: {err}");
  1271. }
  1272. #[test]
  1273. fn test_passthrough_rejects_clock() {
  1274. let cli = Cli::parse_from([
  1275. "trim_galore",
  1276. "--paired",
  1277. "--clock",
  1278. "--passthrough",
  1279. PT,
  1280. R1,
  1281. R2,
  1282. ]);
  1283. let err = cli.validate().unwrap_err().to_string();
  1284. assert!(err.contains("--clock"), "got: {err}");
  1285. }
  1286. #[test]
  1287. fn test_passthrough_rejects_implicon() {
  1288. let cli = Cli::parse_from([
  1289. "trim_galore",
  1290. "--paired",
  1291. "--implicon=8",
  1292. "--passthrough",
  1293. PT,
  1294. R1,
  1295. R2,
  1296. ]);
  1297. let err = cli.validate().unwrap_err().to_string();
  1298. assert!(err.contains("--implicon"), "got: {err}");
  1299. }
  1300. #[test]
  1301. fn test_passthrough_rejects_hardtrim5() {
  1302. let cli = Cli::parse_from([
  1303. "trim_galore",
  1304. "--paired",
  1305. "--hardtrim5",
  1306. "30",
  1307. "--passthrough",
  1308. PT,
  1309. R1,
  1310. R2,
  1311. ]);
  1312. let err = cli.validate().unwrap_err().to_string();
  1313. assert!(err.contains("--hardtrim5"), "got: {err}");
  1314. }
  1315. #[test]
  1316. fn test_passthrough_rejects_hardtrim3() {
  1317. let cli = Cli::parse_from([
  1318. "trim_galore",
  1319. "--paired",
  1320. "--hardtrim3",
  1321. "30",
  1322. "--passthrough",
  1323. PT,
  1324. R1,
  1325. R2,
  1326. ]);
  1327. let err = cli.validate().unwrap_err().to_string();
  1328. assert!(err.contains("--hardtrim3"), "got: {err}");
  1329. }
  1330. #[test]
  1331. fn test_passthrough_rejects_demux() {
  1332. // --demux conflicts with --paired in existing validation, but the
  1333. // --passthrough vs --demux check must fire BEFORE that — confirm the
  1334. // error message is the passthrough-specific one. (single-end input
  1335. // shape because --demux requires single-end.)
  1336. let cli = Cli::parse_from([
  1337. "trim_galore",
  1338. "--demux",
  1339. "test_files/demux_test_samplesheet.txt",
  1340. "--passthrough",
  1341. PT,
  1342. R1,
  1343. ]);
  1344. // With single-end input + --passthrough we hit 1.i ("requires --paired")
  1345. // FIRST. To exercise the --demux check specifically, we'd need --paired
  1346. // + --demux, which clap rejects at the --demux validation step itself.
  1347. // The 1.i error is sufficient evidence the validation chain runs.
  1348. let err = cli.validate().unwrap_err().to_string();
  1349. assert!(
  1350. err.contains("--passthrough requires --paired") || err.contains("--demux"),
  1351. "got: {err}"
  1352. );
  1353. }
  1354. #[test]
  1355. fn test_passthrough_rejects_missing_file() {
  1356. let cli = Cli::parse_from([
  1357. "trim_galore",
  1358. "--paired",
  1359. "--passthrough",
  1360. "test_files/this_does_not_exist.fastq.gz",
  1361. R1,
  1362. R2,
  1363. ]);
  1364. let err = cli.validate().unwrap_err().to_string();
  1365. assert!(err.contains("--passthrough file not found"), "got: {err}");
  1366. }
  1367. #[test]
  1368. fn test_passthrough_rejects_pointing_at_r1() {
  1369. // 1.ix collision check: the byte-equal case is the strict subset of
  1370. // case-folded equality, so this also covers the case-folded path —
  1371. // norm_path() is a single `to_ascii_lowercase()` call which is
  1372. // trivially correct (and exercised by io::tests in its own right).
  1373. // True cross-case testing would need a case-insensitive filesystem
  1374. // which CI doesn't guarantee.
  1375. let cli = Cli::parse_from(["trim_galore", "--paired", "--passthrough", R1, R1, R2]);
  1376. let err = cli.validate().unwrap_err().to_string();
  1377. assert!(err.contains("cannot point at R1 or R2"), "got: {err}");
  1378. }
  1379. #[test]
  1380. fn test_passthrough_rejects_pointing_at_r2() {
  1381. let cli = Cli::parse_from(["trim_galore", "--paired", "--passthrough", R2, R1, R2]);
  1382. let err = cli.validate().unwrap_err().to_string();
  1383. assert!(err.contains("cannot point at R1 or R2"), "got: {err}");
  1384. }
  1385. // ── --output-format (PLAN v2.1 §3.4a) ─────────────────────────────────
  1386. #[test]
  1387. fn output_format_default_is_fastq() {
  1388. let cli = Cli::parse_from(["trim_galore", R1]);
  1389. assert_eq!(cli.output_format, OutputFormat::Fastq);
  1390. }
  1391. #[test]
  1392. fn output_format_ubam_parses() {
  1393. let cli = Cli::parse_from(["trim_galore", "--output-format", "ubam", R1]);
  1394. assert_eq!(cli.output_format, OutputFormat::UBam);
  1395. }
  1396. #[test]
  1397. fn output_format_unknown_value_rejected() {
  1398. let r = Cli::try_parse_from(["trim_galore", "--output-format", "binseq", R1]);
  1399. assert!(r.is_err(), "BINSEQ is deferred in v1 — clap must reject it");
  1400. }
  1401. #[test]
  1402. fn output_format_ubam_alone_validates() {
  1403. let cli = Cli::parse_from(["trim_galore", "--output-format", "ubam", R1]);
  1404. cli.validate()
  1405. .expect("plain --output-format ubam must validate");
  1406. }
  1407. #[test]
  1408. fn output_format_ubam_plus_clumpify_rejected() {
  1409. let cli = Cli::parse_from([
  1410. "trim_galore",
  1411. "--output-format",
  1412. "ubam",
  1413. "--clumpify",
  1414. "--cores",
  1415. "2",
  1416. R1,
  1417. ]);
  1418. let err = cli.validate().unwrap_err().to_string();
  1419. assert!(
  1420. err.contains("--clumpify") && err.contains("--output-format ubam"),
  1421. "expected clumpify+ubam rejection, got: {err}"
  1422. );
  1423. }
  1424. #[test]
  1425. fn output_format_ubam_plus_passthrough_rejected() {
  1426. let cli = Cli::parse_from([
  1427. "trim_galore",
  1428. "--paired",
  1429. "--output-format",
  1430. "ubam",
  1431. "--passthrough",
  1432. ALT_R1,
  1433. R1,
  1434. R2,
  1435. ]);
  1436. let err = cli.validate().unwrap_err().to_string();
  1437. assert!(
  1438. err.contains("--passthrough") && err.contains("--output-format ubam"),
  1439. "expected passthrough+ubam rejection, got: {err}"
  1440. );
  1441. }
  1442. #[test]
  1443. fn output_format_ubam_plus_clock_rejected() {
  1444. let cli = Cli::parse_from(["trim_galore", "--clock", "--output-format", "ubam", R1, R2]);
  1445. let err = cli.validate().unwrap_err().to_string();
  1446. assert!(
  1447. err.contains("--clock") && err.contains("--output-format ubam"),
  1448. "expected clock+ubam rejection, got: {err}"
  1449. );
  1450. }
  1451. #[test]
  1452. fn output_format_ubam_plus_implicon_rejected() {
  1453. let cli = Cli::parse_from([
  1454. "trim_galore",
  1455. "--paired",
  1456. "--implicon",
  1457. "8",
  1458. "--output-format",
  1459. "ubam",
  1460. R1,
  1461. R2,
  1462. ]);
  1463. let err = cli.validate().unwrap_err().to_string();
  1464. assert!(
  1465. err.contains("--implicon") && err.contains("--output-format ubam"),
  1466. "expected implicon+ubam rejection, got: {err}"
  1467. );
  1468. }
  1469. #[test]
  1470. fn output_format_ubam_plus_retain_unpaired_rejected() {
  1471. // PLAN v2.1 §3.4a addendum: --retain_unpaired produces additional
  1472. // FASTQ files (`*_unpaired_{1,2}.fq.gz`); multi-output BAM is out of
  1473. // scope for v1.
  1474. let cli = Cli::parse_from([
  1475. "trim_galore",
  1476. "--paired",
  1477. "--retain_unpaired",
  1478. "--output-format",
  1479. "ubam",
  1480. R1,
  1481. R2,
  1482. ]);
  1483. let err = cli.validate().unwrap_err().to_string();
  1484. assert!(
  1485. err.contains("--retain_unpaired") && err.contains("--output-format ubam"),
  1486. "expected retain_unpaired+ubam rejection, got: {err}"
  1487. );
  1488. }
  1489. #[test]
  1490. fn output_format_ubam_plus_demux_rejected() {
  1491. // --demux takes a barcode-file path; any path string suffices for parser.
  1492. let cli = Cli::parse_from([
  1493. "trim_galore",
  1494. "--demux",
  1495. "test_files/demux_test_samplesheet.txt",
  1496. "--output-format",
  1497. "ubam",
  1498. R1,
  1499. ]);
  1500. let err = cli.validate().unwrap_err().to_string();
  1501. assert!(
  1502. err.contains("--demux") && err.contains("--output-format ubam"),
  1503. "expected demux+ubam rejection, got: {err}"
  1504. );
  1505. }
  1506. }

cli.rs at commit c6528e5, under GPL-3.0 · at the source

Overview

Authors: Annika Mordelt1,2, Imke M.E. Schuurmans1, Nicky Scheefhals1, Marina P. Hommersom1, Koen Slottje1, Kimberly Mast1, Mara Graziani1,2, Carlos O. González1,2, Klaas W. Mulder3, Laura J.A. Wingens3, Dirk Schubert1,2, Nael Nadif Kasri1,2, Lot D. de Witte1,2,4
ORCID iDs: Lot D. de Witte
  1. Radboud University Medical Center, Department of Human Genetics, Nijmegen, the Netherlands
  2. Donders Institute for Brain, Cognition and Behaviour, Medical Neuroscience Department, Nijmegen, the Netherlands
  3. Department of Molecular Developmental Biology, Radboud Single Cell Center, Radboud University, Nijmegen, the Netherlands
  4. Radboud University Medical Center, Department of Psychiatry, Nijmegen, the Netherlands
Journal: Stem cell reports, volume 21, issue 9, article 103053
Dates: received 18 November 2025; accepted 23 July 2026; published online 20 August 2026; in print September 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1016/j.stemcr.2026.103053 · PMID 42624101 · PMCID PMC13555563 · OpenAlex W7203834369
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: human (organism), cellular / molecular (subfield)
Methods: Statistics, Evoked potentials, Single-unit activity, calcium imaging
Keywords: microglia, neurons, iPSC, differentiation, protocol, co-culture, long-term, neuron-microglia communications, neuro-immune interactions
MeSH: Cell Differentiation*, Induced Pluripotent Stem Cells*, Microglia*, Nerve Net*, Neurons*, Astrocytes, Cells, Cultured, Coculture Techniques, Humans, Membrane Proteins, Receptors, Purinergic P2Y12 (* major topic)
Journal subjects: Resource
Topic: Neuroinflammation and Neurodegeneration Mechanisms (Neurology, Neuroscience), according to OpenAlex
Funding: Simons Foundation (00010410); ZonMw (09120012110034)
Citations: not cited yet (Europe PMC); 53 references in the paper
Research resources: CX3CR1-APC RRID:AB_11125577, CD43-PE (clone 10G7) RRID:AB_1877299, Anti-GFAP, rabbit polyclonal RRID:AB_2109645, Anti-MAP2, guinea pig polyclonal RRID:AB_2147440, Anti-Synapsin I, rabbit polyclonal RRID:AB_2200400, Anti-P2RY12, rabbit polyclonal RRID:AB_2337543, Anti-Ankyrin-G, mouse monoclonal RRID:AB_2533145, Anti-CX3CR1, rabbit polyclonal RRID:AB_2538394, CD24-PE Cyanine7 RRID:AB_2561736, RRID:AB_2566782, GFAP-BV421 RRID:AB_2734370, P2RY12-BV421 RRID:AB_2750213, CD11b-PE RRID:AB_314152, CD206-APC RRID:AB_393992, CD45-PE/Cyanine7 RRID:AB_493729, Anti-Homer1b/c, mouse monoclonal RRID:AB_887730, Lentiviral Ngn2 overexpression construct RRID:Addgene_198754, Lentiviral rtTA construct RRID:Addgene_198756, RRID:Addgene_20945, Human: HPSI0314i-hoik_1 hiPSC RRID:CVCL_TBD, Human: WTC-11 hiPSC RRID:CVCL_Y803, R v4.2.0 / RStudio RRID:SCR_001905, GraphPad Prism v8.0.0 RRID:SCR_002798, STAR aligner RRID:SCR_004463, Seurat v4.3.0 RRID:SCR_007322, Trim Galore RRID:SCR_011847, FastQC RRID:SCR_014583, MultiQC RRID:SCR_014982, Kaluza C Analysis Software RRID:SCR_016182, Salmon RRID:SCR_017036, Harmony v1.2.3 RRID:SCR_023206, Cell Ranger v9.0.0 RRID:SCR_023221

Abstract

Microglia-neuron interactions play a key role in a variety of central nervous system disorders. Technologies using human induced pluripotent stem cells (hiPSCs) have been developed to model human brain cells with the goal to understand their function. To effectively study neuro-immune crosstalk and investigate microglial contributions to neuronal network development and function, both microglia and neurons should co-mature allowing for long-term interactions throughout their differentiation. Here, we present a co-maturation protocol that robustly generates glutamatergic neuronal networks containing hiPSC-derived microglia. We validated the long-term co-cultures using single-cell transcriptomics, imaging, and neuronal activity readouts.

In this protocol, astrocytes were required for long-term survival of microglia and for their integration into neuronal networks. Our co-maturation approach induced the typical ramified microglia morphology and characteristic microglia-neuron interactions. Homeostatic markers such as P2RY12 and TMEM119 and neuronal remodeling-associated genes were upregulated compared to microglia monocultures, highlighting the necessity of the environment to generate and maintain the context-dependent microglia signature in vitro. In this manuscript, we include the full optimization process of our co-maturation approach, a comprehensive description of the protocol, practical guidelines, and troubleshooting tips. Our co-maturation model provides a powerful tool to assess the role of human microglia in modulating neuronal function and development in health and disease.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repository

Its files are read in the Code ↔ Paper reader above, with 1 match between paragraphs and lines of code.

FelixKrueger/TrimGalore

License: GPL-3.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: c6528e54512e0e388a392d36475291bcbf0eb0dd, 27 June 2026
Languages: Rust (24), TypeScript (2), Shell (2), Python (1)
Size: 267 files, 29 scripts
Software Heritage: not archived
Found in: the text, “Key resources table”
Holds: README, license file, CITATION.cff, environment (Dockerfile), tests, continuous integration, documentation
Tools: Matplotlib (1 file), SAMtools (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
31 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 29 scripts, each with its path and the digest of its content;
  • 1 match between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data and code availability

• Source data of transcriptomic and MEA analyses underlying Figures 2, 4, and 5 are available in Table S2. • The raw single-cell RNA-seq data are deposited in the GEO repository (GSE339528). • All other raw datasets generated during the current study are available from the lead author on reasonable request.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 2, 28 September 2026

  • Authors: added Lot D. de Witte (0000-0002-7235-9958); removed Lot D. de Witte

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 13 authors, 9 keywords, 11 MeSH terms, 2 funders, 53 references, 32 RRIDs.

Cite

This paper

Mordelt, A., Schuurmans, I. M., Scheefhals, N., Hommersom, M. P., Slottje, K., Mast, K., Graziani, M., González, C. O., Mulder, K. W., Wingens, L. J., Schubert, D., Nadif Kasri, N., & de Witte, L. D. (2026). Development and validation of a long-term co-maturation protocol for human stem cell-derived microglia and neuronal networks. Stem cell reports, 21(9), 103053. https://doi.org/10.1016/j.stemcr.2026.103053

BibTeX

@article{mordelt2026development,
author = {Mordelt, Annika and Schuurmans, Imke M.E. and Scheefhals, Nicky and Hommersom, Marina P. and Slottje, Koen and Mast, Kimberly and Graziani, Mara and González, Carlos O. and Mulder, Klaas W. and Wingens, Laura J.A. and Schubert, Dirk and Nadif Kasri, Nael and de Witte, Lot D.},
title = {{Development and validation of a long-term co-maturation protocol for human stem cell-derived microglia and neuronal networks}},
journal = {Stem cell reports},
year = {2026},
month = aug,
volume = {21},
number = {9},
pages = {103053},
publisher = {Elsevier},
issn = {2213-6711},
doi = {10.1016/j.stemcr.2026.103053},
url = {https://doi.org/10.1016/j.stemcr.2026.103053},
pmid = {42624101},
pmcid = {PMC13555563}
}

RIS

TY - JOUR
AU - Mordelt, Annika
AU - Schuurmans, Imke M.E.
AU - Scheefhals, Nicky
AU - Hommersom, Marina P.
AU - Slottje, Koen
AU - Mast, Kimberly
AU - Graziani, Mara
AU - González, Carlos O.
AU - Mulder, Klaas W.
AU - Wingens, Laura J.A.
AU - Schubert, Dirk
AU - Nadif Kasri, Nael
AU - de Witte, Lot D.
TI - Development and validation of a long-term co-maturation protocol for human stem cell-derived microglia and neuronal networks
T2 - Stem cell reports
J2 - Stem Cell Reports
PY - 2026
DA - 2026/08/20
VL - 21
IS - 9
SP - 103053
SN - 2213-6711
PB - Elsevier
DO - 10.1016/j.stemcr.2026.103053
UR - https://doi.org/10.1016/j.stemcr.2026.103053
LA - en
ER -

CSL-JSON

{
"id": "10.1016/j.stemcr.2026.103053",
"type": "article-journal",
"title": "Development and validation of a long-term co-maturation protocol for human stem cell-derived microglia and neuronal networks",
"container-title": "Stem cell reports",
"author": [
{
"family": "Mordelt",
"given": "Annika"
},
{
"family": "Schuurmans",
"given": "Imke M.E."
},
{
"family": "Scheefhals",
"given": "Nicky"
},
{
"family": "Hommersom",
"given": "Marina P."
},
{
"family": "Slottje",
"given": "Koen"
},
{
"family": "Mast",
"given": "Kimberly"
},
{
"family": "Graziani",
"given": "Mara"
},
{
"family": "González",
"given": "Carlos O."
},
{
"family": "Mulder",
"given": "Klaas W."
},
{
"family": "Wingens",
"given": "Laura J.A."
},
{
"family": "Schubert",
"given": "Dirk"
},
{
"family": "Nadif Kasri",
"given": "Nael"
},
{
"family": "de Witte",
"given": "Lot D."
}
],
"container-title-short": "Stem Cell Reports",
"volume": "21",
"issue": "9",
"page": "103053",
"DOI": "10.1016/j.stemcr.2026.103053",
"PMID": "42624101",
"PMCID": "PMC13555563",
"ISSN": "2213-6711",
"publisher": "Elsevier",
"URL": "https://doi.org/10.1016/j.stemcr.2026.103053",
"language": "en",
"issued": {
"date-parts": [
[
2026,
8,
20
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1016/j.neuron.2026.07.007 [code]
Human-specific SRGAP2 paralogs synchronize neotenic microglial maturation and synaptic development.
Journal: Neuron
In common: 8 references
[2] doi:10.1093/brain/awaf340
PU.1 restores microglial dysfunction caused by C9ORF72 repeat expansions in neural organoids.
Journal: Brain : a journal of neurology
In common: cellular / molecular, 7 references
[3] doi:10.1038/s41386-026-02406-1 [code]
Functional genomic profiling of schizophrenia-associated genes reveals key microglial regulators.
Journal: Neuropsychopharmacology : official publication of the American College of Neuropsychopharmacology
In common: SAMtools, cellular / molecular, 5 references
[4] doi:10.1038/s41593-026-02367-0 [code]
A reproducible three-dimensional model of human brain tissue to investigate physiological and disease-associated microglia phenotypes.
Journal: Nature neuroscience
In common: cellular / molecular, 6 references
[5] doi:10.1002/advs.202520408
Innate Immunocompetent hiPSC-Derived Neurospheroids Capture Early CNS Responses to rAAV.
Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany)
In common: 6 references
[6] doi:10.1016/j.isci.2026.116412 [code]
KOLF2.1J iTF-Microglia: A standardized platform to study microglial transcriptional regulatory networks in CNS disease.
Journal: iScience
In common: SAMtools, Matplotlib, 4 references
[7] doi:10.1038/s41420-026-03185-w [code]
An electrophysiological and proteomics roadmap for human induced glutamatergic neurons: fine-tuning of culture conditions for pathophysiological studies.
Journal: Cell death discovery
In common: 5 references
[8] doi:10.3389/fnins.2026.1799542
Disease-associated RNA and protein signatures in iPSC-derived microglia model of Alzheimer's disease.
Journal: Frontiers in neuroscience
In common: cellular / molecular, 5 references
[9] doi:10.1002/glia.70158
A Dynamic Change of Microglial States Occurs During the Transition From Photoreceptor Degeneration to Regeneration in Zebrafish pde6c Mutants.
Journal: Glia
In common: cellular / molecular, 5 references
[10] doi:10.1038/s41467-026-76341-6 [code]
Neonatal inflammation disrupts a temporally restricted postnatal Numb-enriched microglial state in mice.
Journal: Nature communications
In common: Matplotlib, 4 references

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.