rust-ise 0.2.3

Fast Rust-native ISEScan-equivalent insertion-sequence (IS) scanner for bacterial (meta)genomes: rustygal ORFs + MMseqs2 profile search + native affine-SW terminal inverted repeats.
Documentation
// rust-ise CLI — a thin wrapper over the `rust_ise` library.
//
// Parses args, runs the detection pipeline (`rust_ise::run`), and writes the
// TSV/GFF/.sum outputs. All detection logic lives in the library so it can be
// consumed in-process (e.g. by bactars).
use rust_ise::{run, write_gff, write_sum, write_tsv, IseConfig};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;

// `build-db` subcommand: reproducible union IS profile DB build (rustygal ORFs +
// mmseqs cluster + in-process poasta MSA) PLUS the nt neg-pos FP-control refset.
// Purely additive to the scanner (`rust_ise::run` is untouched); gated behind the
// `build-db` first-arg in `main`.
mod build_db;

/// Handle `rust-ise build-db …` — rebuild the union IS profile DB (+ optional
/// `fpc/refset`) from source. Mirrors the isscan monolith's dispatch.
fn run_build_db(rest: &[String]) {
    use build_db::BuildArgs;
    // Explicit source paths (a flag given for each overrides any fetched copy).
    let mut iso: Option<PathBuf> = None;
    let mut fam: Option<PathBuf> = None;
    let mut faa: Option<PathBuf> = None;
    let mut csv: Option<PathBuf> = None;
    let mut out: Option<PathBuf> = None;
    let mut work: Option<PathBuf> = None;
    let mut isfinder_fna: Option<PathBuf> = None;
    let mut fpc_neg: Option<PathBuf> = None;
    let mut fpc_pos_extra: Option<PathBuf> = None;
    let mut strict_version = false;
    // Source auto-download (`--fetch-sources`): pull any un-supplied source, pinned by
    // commit SHA + sha256, into a local cache. Offline/local-first: explicit paths win.
    let mut fetch = false;
    let mut fetch_host = false;
    let mut cache_dir: Option<PathBuf> = None;
    let mut i = 0;
    while i < rest.len() {
        match rest[i].as_str() {
            "--isosdb-fna" => { i += 1; iso = Some(PathBuf::from(&rest[i])); }
            "--fam-annot" => { i += 1; fam = Some(PathBuf::from(&rest[i])); }
            "--isfinder-faa" => { i += 1; faa = Some(PathBuf::from(&rest[i])); }
            "--isfinder-csv" => { i += 1; csv = Some(PathBuf::from(&rest[i])); }
            // ISfinder element DNA (IS.fna): auto-derives the ISfinder half of the fpc
            // P| pool (transposase CDS located by 6-frame translation, pure Rust).
            "--isfinder-fna" => { i += 1; isfinder_fna = Some(PathBuf::from(&rest[i])); }
            "--out" | "--db" => { i += 1; out = Some(PathBuf::from(&rest[i])); }
            "--work" => { i += 1; work = Some(PathBuf::from(&rest[i])); }
            // Host-lineage NEGATIVE nt pool for the FP-control refset (fpc/refset N|).
            // Source guidance: a nucleotide FASTA of non-IS host CDS drawn from
            // representative host genomes (e.g. GTDB reps' CDS with IS regions removed).
            // Without it, fpc/refset is not built and the scanner's FP-control no-ops.
            "--host" | "--fpc-neg" => { i += 1; fpc_neg = Some(PathBuf::from(&rest[i])); }
            // OPTIONAL escape-hatch extra IS-lineage POSITIVE nt pool (fpc/refset P|). Not
            // required: both IS pools (ISOSDB per-ORF CDS + ISfinder transposase CDS) are
            // auto-derived in pure Rust (ISfinder via --isfinder-fna).
            "--fpc-pos-extra" => { i += 1; fpc_pos_extra = Some(PathBuf::from(&rest[i])); }
            "--strict-mmseqs-version" => strict_version = true,
            // Auto-download any un-supplied source (pinned commit SHA + sha256-verified).
            "--fetch-sources" => fetch = true,
            // Additionally fetch the host-lineage negative pool (a GitHub Release asset)
            // so fpc/refset can be built without a locally-curated --host FASTA.
            "--fetch-host" => { fetch = true; fetch_host = true; }
            // Override the source download cache dir (default: ~/.cache/rust-ise/sources).
            "--cache-dir" => { i += 1; cache_dir = Some(PathBuf::from(&rest[i])); }
            "-h" | "--help" => {
                eprintln!("usage: rust-ise build-db --out DIR [sources…]");
                eprintln!();
                eprintln!("Sources (supply locally, or use --fetch-sources to auto-download):");
                eprintln!("  --isosdb-fna F --fam-annot F --isfinder-faa F --isfinder-csv F [--isfinder-fna IS.fna]");
                eprintln!("  --fetch-sources           download+verify any un-supplied source (pinned SHA + sha256)");
                eprintln!("  --fetch-host              also fetch the host negative pool (Release asset) for fpc");
                eprintln!("  --cache-dir DIR           source download cache (default ~/.cache/rust-ise/sources)");
                eprintln!("fpc reference (optional):");
                eprintln!("  --host HOST.fna | --fpc-neg HOST.fna   host-lineage N| pool (or use --fetch-host)");
                eprintln!("  --fpc-pos-extra EXTRA.fna              optional extra P| pool (escape hatch)");
                eprintln!("misc: [--work DIR] [--strict-mmseqs-version]");
                eprintln!();
                eprintln!("Builds <out>/mmdb_union/ + <out>/manifest_union.tsv. With a host pool, also builds");
                eprintln!("<out>/fpc/refset* (nt neg-pos FP-control reference: P|=IS-lineage, N|=host-lineage).");
                eprintln!("The P| pool is auto-derived in pure Rust: ISOSDB per-ORF CDS + (with --isfinder-fna)");
                eprintln!("ISfinder transposase CDS located in IS.fna by 6-frame translation.");
                return;
            }
            x => { eprintln!("unknown build-db arg {x}"); std::process::exit(2); }
        }
        i += 1;
    }

    // Fill any un-supplied source from the pinned download cache (explicit flags win).
    if fetch {
        let cache = cache_dir.unwrap_or_else(build_db::fetch::default_cache);
        let src = match build_db::fetch::fetch_sources(&cache, fetch_host) {
            Ok(s) => s,
            Err(e) => { eprintln!("build-db fetch error: {e}"); std::process::exit(1); }
        };
        iso = iso.or(Some(src.isosdb_fna));
        fam = fam.or(Some(src.fam_annot));
        faa = faa.or(Some(src.isfinder_faa));
        csv = csv.or(Some(src.isfinder_csv));
        isfinder_fna = isfinder_fna.or(Some(src.isfinder_fna));
        if fpc_neg.is_none() {
            fpc_neg = src.host;
        }
    }

    let need = |o: Option<PathBuf>, flag: &str| -> PathBuf {
        o.unwrap_or_else(|| {
            eprintln!("build-db needs {flag} (or --fetch-sources)");
            std::process::exit(2);
        })
    };
    let out_dir = out.unwrap_or_else(|| { eprintln!("build-db needs --out DIR"); std::process::exit(2); });
    let work = work.unwrap_or_else(|| out_dir.join("work"));
    let cfg = BuildArgs {
        isosdb_fna: need(iso, "--isosdb-fna"),
        fam_annot: need(fam, "--fam-annot"),
        isfinder_faa: need(faa, "--isfinder-faa"),
        isfinder_csv: need(csv, "--isfinder-csv"),
        isfinder_fna,
        out_dir,
        work,
        strict_version,
        fpc_neg,
        fpc_pos_extra,
    };
    if let Err(e) = build_db::run(&cfg) {
        eprintln!("build-db error: {e}");
        std::process::exit(1);
    }
}

fn main() {
    let t0 = Instant::now();
    let args: Vec<String> = std::env::args().collect();
    // Subcommand: `rust-ise build-db …` rebuilds the union IS profile DB (+ fpc) from source.
    if args.get(1).map(String::as_str) == Some("build-db") {
        run_build_db(&args[2..]);
        return;
    }
    let mut seqfile = String::new();
    let mut output = String::new();
    let mut threads = 16usize;
    let mut gpu = false;
    let mut keep = false;
    let mut strict = false;
    let mut db_dir: Option<PathBuf> = None; // resolve from --db, then $ISSCAN_DB
    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "-i" | "--seqfile" => {
                i += 1;
                seqfile = args[i].clone();
            }
            "-o" | "--output" => {
                i += 1;
                output = args[i].clone();
            }
            "-t" | "--threads" => {
                i += 1;
                threads = args[i].parse().unwrap();
            }
            "--gpu" => gpu = true,
            "--keep" => keep = true,
            "--strict" => strict = true, // drop fpFlag=host calls (host-lineage FPs)
            "--db" => {
                i += 1;
                db_dir = Some(PathBuf::from(args[i].clone()));
            }
            _ => {
                eprintln!("unknown arg {}", args[i]);
                std::process::exit(2);
            }
        }
        i += 1;
    }
    assert!(!seqfile.is_empty() && !output.is_empty(), "need -i and -o");

    // DB is external data (not bundled). Resolve --db, else $ISSCAN_DB, else error.
    let db_dir = db_dir
        .or_else(|| std::env::var_os("ISSCAN_DB").map(PathBuf::from))
        .unwrap_or_else(|| {
            eprintln!("error: IS database not set. Pass --db <dir> or set ISSCAN_DB (see README).");
            std::process::exit(2);
        });

    rayon::ThreadPoolBuilder::new()
        .num_threads(threads)
        .build_global()
        .ok();
    fs::create_dir_all(&output).unwrap();

    let config = IseConfig { threads, gpu, strict, db_dir };
    let calls = match run(Path::new(&seqfile), Path::new(&output), &config) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("error: {e}");
            std::process::exit(1);
        }
    };

    let stem = Path::new(&output);
    write_tsv(&calls, &stem.join("isscan.tsv"));
    write_gff(&calls, &stem.join("isscan.gff"));
    write_sum(&calls, &stem.join("isscan.sum"));
    if !keep {
        let _ = fs::remove_dir_all(stem.join("isscan_tmp"));
    }
    eprintln!(
        "[{:.1}s] DONE -> {}/isscan.tsv  ({} IS)",
        t0.elapsed().as_secs_f64(),
        output,
        calls.len()
    );
}