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 build-db --fetch-sources` — reproducible source auto-download.
//!
//! `build-db` needs five public source files (three from ISfinder, two from the
//! pseudoR/ISOSDB project). Rather than require the maintainer to hand-collect
//! them, `--fetch-sources` downloads each one **pinned by commit SHA and verified
//! by sha256**, so every user's rebuild starts from byte-identical inputs and thus
//! reproduces the same union DB + fpc reference. The optional host-lineage negative
//! pool (`--fetch-host`) is pinned to a GitHub Release asset the same way.
//!
//! Downloads use the system `curl` (build-db already shells out to `mmseqs`, so no
//! new Rust HTTP dependency); hashing uses `sha2` and the one `.zip` is extracted
//! in-process with the `zip` crate — both pure Rust, no external `sha256sum`/`unzip`.
//!
//! ## Provenance / copyright (see NOTICE)
//! - ISfinder sequences © the ISfinder team (isfinder.biotoul.fr), fetched from the
//!   community mirror `thanhleviet/ISfinder-sequences`.
//! - ISOSDB / `IS_fam_annot.txt` from `joshuakirsch/pseudoR` (Kirsch et al.).
//! Auto-download is not redistribution — each user fetches from the upstream origin.

use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;

// ---------------------------------------------------------------- pinned sources
/// A file pinned in a public GitHub repo tree (fetched via `raw.githubusercontent.com`).
struct RawFile {
    owner: &'static str,
    repo: &'static str,
    /// Commit SHA the file is pinned to (NOT a branch — a moved mirror must not change the build).
    commit: &'static str,
    /// Repo-relative path (also the cached basename; all our sources are repo-root files).
    path: &'static str,
    /// Expected sha256 of the downloaded bytes (hard-verified; mismatch aborts).
    sha256: &'static str,
    /// Expected byte length (a fast pre-hash sanity check).
    bytes: u64,
}

impl RawFile {
    fn url(&self) -> String {
        format!(
            "https://raw.githubusercontent.com/{}/{}/{}/{}",
            self.owner, self.repo, self.commit, self.path
        )
    }
}

/// A file pinned to a GitHub Release asset (for data that is not in a repo tree).
struct ReleaseAsset {
    owner: &'static str,
    repo: &'static str,
    /// Release tag the asset is attached to.
    tag: &'static str,
    /// Asset filename (also the cached basename).
    name: &'static str,
    sha256: &'static str,
    bytes: u64,
}

impl ReleaseAsset {
    fn url(&self) -> String {
        format!(
            "https://github.com/{}/{}/releases/download/{}/{}",
            self.owner, self.repo, self.tag, self.name
        )
    }
}

// ISfinder sequences — community mirror thanhleviet/ISfinder-sequences @ 2020-Oct
// (© ISfinder team). Commit bedbeb5 is the pinned "Update 2020-Oct" snapshot.
const IS_FAA: RawFile = RawFile {
    owner: "thanhleviet", repo: "ISfinder-sequences",
    commit: "bedbeb5c16618eca3e16ef74adf865d6b88cafb5", path: "IS.faa",
    sha256: "7672fecd114c962c2d8640b70324e10b9a2f111c9ad6d40112800fdb65213b12", bytes: 3_324_545,
};
const IS_CSV: RawFile = RawFile {
    owner: "thanhleviet", repo: "ISfinder-sequences",
    commit: "bedbeb5c16618eca3e16ef74adf865d6b88cafb5", path: "IS.csv",
    sha256: "1d7d4fa2d33488f2a21a6ca1ad908d4960267582b56addcfa5e098a5414cd5e2", bytes: 893_572,
};
const IS_FNA: RawFile = RawFile {
    owner: "thanhleviet", repo: "ISfinder-sequences",
    commit: "bedbeb5c16618eca3e16ef74adf865d6b88cafb5", path: "IS.fna",
    sha256: "47607f9e398d200f1954b18b9d91d6f5a612331ee137cb10d63897688f9d5507", bytes: 8_697_930,
};

// pseudoR / ISOSDB — joshuakirsch/pseudoR @ 2024-11-11 (Kirsch et al.).
const ISOSDB_ZIP: RawFile = RawFile {
    owner: "joshuakirsch", repo: "pseudoR",
    commit: "9e44803ddca7f2af678c72c9f02284e94a204164", path: "ISOSDB.V3.fna.zip",
    sha256: "f24b89f3fee3d7e929a745d7a3587fdef2e3ca5da7792451fa510b5b4134e60a", bytes: 9_910_945,
};
const IS_FAM_ANNOT: RawFile = RawFile {
    owner: "joshuakirsch", repo: "pseudoR",
    commit: "9e44803ddca7f2af678c72c9f02284e94a204164", path: "IS_fam_annot.txt",
    sha256: "41d5120ea638456829029a02c91abc24943f3bdb71fd663a314356d2ba3a34d0", bytes: 367_225,
};
/// The single member inside `ISOSDB.V3.fna.zip`, and its expected sha256 once extracted.
const ISOSDB_ZIP_MEMBER: &str = "ISOSDB.V3.fna";
const ISOSDB_FNA_SHA256: &str = "31541c4348da01e19db8be71c8f44b0e21c3462d36bfcd217aee3315474e7748";

// Host-lineage NEGATIVE nt pool for the fpc reference (curation artifact — no
// canonical upstream, so it ships as a Release asset of this crate's own repo).
const HOST_NEG: ReleaseAsset = ReleaseAsset {
    owner: "necoli1822", repo: "rust-ise", tag: "v0.2.3", name: "negharvest_reps.fna",
    sha256: "35dbe24307005b9da14d881698bd0195072ed56e7a29ca39fa377fa3973fdfbd", bytes: 35_668_576,
};

// ---------------------------------------------------------------- fetch outputs
/// Local paths to the (fetched or cached) build-db source files.
pub struct Sources {
    pub isosdb_fna: PathBuf,
    pub fam_annot: PathBuf,
    pub isfinder_faa: PathBuf,
    pub isfinder_csv: PathBuf,
    pub isfinder_fna: PathBuf,
    /// Host-lineage negative pool — `Some` only when `want_host` was requested.
    pub host: Option<PathBuf>,
}

/// Default source cache dir: `$XDG_CACHE_HOME/rust-ise/sources`, else
/// `$HOME/.cache/rust-ise/sources`, else a temp fallback.
pub fn default_cache() -> PathBuf {
    if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
        return PathBuf::from(x).join("rust-ise").join("sources");
    }
    if let Some(h) = std::env::var_os("HOME") {
        return PathBuf::from(h).join(".cache").join("rust-ise").join("sources");
    }
    std::env::temp_dir().join("rust-ise-sources")
}

// ---------------------------------------------------------------- helpers
/// Stream-hash a file with sha256, returning the lowercase hex digest.
fn sha256_hex(path: &Path) -> Result<String, String> {
    use sha2::{Digest, Sha256};
    let mut f = File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 1 << 16];
    loop {
        let n = f.read(&mut buf).map_err(|e| format!("read {}: {e}", path.display()))?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    let digest = hasher.finalize();
    let mut s = String::with_capacity(64);
    for b in digest {
        s.push_str(&format!("{b:02x}"));
    }
    Ok(s)
}

/// True if `path` exists with the exact `expect` sha256 (used for cache hits).
fn matches(path: &Path, expect: &str) -> bool {
    path.exists() && sha256_hex(path).map(|h| h == expect).unwrap_or(false)
}

/// Download `url` to `dest` via system `curl` (fail on HTTP error, a few retries).
fn curl(url: &str, dest: &Path) -> Result<(), String> {
    let dest_s = dest.to_str().ok_or("non-utf8 dest path")?;
    let status = Command::new("curl")
        .args([
            "-sSL", "--fail", "--retry", "3", "--retry-delay", "2", "--max-time", "600",
            "-o", dest_s, url,
        ])
        .status()
        .map_err(|e| format!("failed to launch curl (is it on PATH?): {e}"))?;
    if !status.success() {
        return Err(format!("curl failed ({status}) for {url}"));
    }
    Ok(())
}

/// Verify `path`'s size + sha256 against `bytes`/`sha256`; error (and remove the
/// bad file) on any mismatch.
fn verify(path: &Path, bytes: u64, sha256: &str) -> Result<(), String> {
    let got = std::fs::metadata(path).map_err(|e| format!("stat {}: {e}", path.display()))?.len();
    if got != bytes {
        let _ = std::fs::remove_file(path);
        return Err(format!("{}: size {got} != expected {bytes}", path.display()));
    }
    let h = sha256_hex(path)?;
    if h != sha256 {
        let _ = std::fs::remove_file(path);
        return Err(format!("{}: sha256 {h} != expected {sha256}", path.display()));
    }
    Ok(())
}

/// Download `url` to `cache/basename` (or reuse a cached copy whose sha256 matches),
/// verifying size + sha256. Atomic: writes to `<dest>.part` then renames.
fn fetch_to(url: &str, basename: &str, bytes: u64, sha256: &str, cache: &Path) -> Result<PathBuf, String> {
    let dest = cache.join(basename);
    if matches(&dest, sha256) {
        eprintln!("[build-db] cached {basename} (sha256 ok)");
        return Ok(dest);
    }
    eprintln!("[build-db] downloading {basename}");
    let part = cache.join(format!("{basename}.part"));
    curl(url, &part)?;
    verify(&part, bytes, sha256)?;
    std::fs::rename(&part, &dest).map_err(|e| format!("rename {}: {e}", dest.display()))?;
    Ok(dest)
}

fn fetch_raw(spec: &RawFile, cache: &Path) -> Result<PathBuf, String> {
    let base = Path::new(spec.path)
        .file_name()
        .and_then(|s| s.to_str())
        .ok_or("bad source path")?;
    fetch_to(&spec.url(), base, spec.bytes, spec.sha256, cache)
}

fn fetch_release(a: &ReleaseAsset, cache: &Path) -> Result<PathBuf, String> {
    fetch_to(&a.url(), a.name, a.bytes, a.sha256, cache)
}

/// Fetch `ISOSDB.V3.fna.zip` and extract its single `ISOSDB.V3.fna` member in-process
/// (`zip` crate), verifying the unzipped sha256. Cached extraction is reused.
fn fetch_isosdb_fna(cache: &Path) -> Result<PathBuf, String> {
    let dest = cache.join(ISOSDB_ZIP_MEMBER);
    if matches(&dest, ISOSDB_FNA_SHA256) {
        eprintln!("[build-db] cached {ISOSDB_ZIP_MEMBER} (sha256 ok)");
        return Ok(dest);
    }
    let zip_path = fetch_raw(&ISOSDB_ZIP, cache)?;
    eprintln!("[build-db] extracting {ISOSDB_ZIP_MEMBER} from ISOSDB.V3.fna.zip …");
    let f = File::open(&zip_path).map_err(|e| format!("open {}: {e}", zip_path.display()))?;
    let mut archive = zip::ZipArchive::new(f).map_err(|e| format!("open zip: {e}"))?;
    let mut entry = archive
        .by_name(ISOSDB_ZIP_MEMBER)
        .map_err(|e| format!("zip member {ISOSDB_ZIP_MEMBER}: {e}"))?;
    let part = cache.join(format!("{ISOSDB_ZIP_MEMBER}.part"));
    {
        let mut out = File::create(&part).map_err(|e| format!("create {}: {e}", part.display()))?;
        std::io::copy(&mut entry, &mut out).map_err(|e| format!("extract: {e}"))?;
    }
    // The zip is size-verified as a whole; only the unzipped sha256 is pinned here.
    let h = sha256_hex(&part)?;
    if h != ISOSDB_FNA_SHA256 {
        let _ = std::fs::remove_file(&part);
        return Err(format!("{ISOSDB_ZIP_MEMBER}: sha256 {h} != expected {ISOSDB_FNA_SHA256}"));
    }
    std::fs::rename(&part, &dest).map_err(|e| format!("rename {}: {e}", dest.display()))?;
    Ok(dest)
}

/// Download every pinned build-db source into `cache` (verified + cached), returning
/// their local paths. `want_host` additionally fetches the host-lineage negative pool.
pub fn fetch_sources(cache: &Path, want_host: bool) -> Result<Sources, String> {
    std::fs::create_dir_all(cache).map_err(|e| format!("mkdir {}: {e}", cache.display()))?;
    eprintln!("[build-db] source cache: {}", cache.display());
    let isfinder_faa = fetch_raw(&IS_FAA, cache)?;
    let isfinder_csv = fetch_raw(&IS_CSV, cache)?;
    let isfinder_fna = fetch_raw(&IS_FNA, cache)?;
    let fam_annot = fetch_raw(&IS_FAM_ANNOT, cache)?;
    let isosdb_fna = fetch_isosdb_fna(cache)?;
    let host = if want_host {
        Some(fetch_release(&HOST_NEG, cache)?)
    } else {
        None
    };
    Ok(Sources { isosdb_fna, fam_annot, isfinder_faa, isfinder_csv, isfinder_fna, host })
}

#[cfg(test)]
mod tests {
    use super::*;

    // The pinned URLs are well-formed and commit-locked (no branch refs).
    #[test]
    fn urls_are_commit_pinned() {
        assert_eq!(
            IS_FAA.url(),
            "https://raw.githubusercontent.com/thanhleviet/ISfinder-sequences/\
             bedbeb5c16618eca3e16ef74adf865d6b88cafb5/IS.faa"
        );
        assert!(ISOSDB_ZIP.url().contains("9e44803ddca7f2af678c72c9f02284e94a204164"));
        assert_eq!(
            HOST_NEG.url(),
            "https://github.com/necoli1822/rust-ise/releases/download/v0.2.3/negharvest_reps.fna"
        );
        // Every pinned sha256 is a 64-char lowercase hex string.
        for s in [
            IS_FAA.sha256, IS_CSV.sha256, IS_FNA.sha256, ISOSDB_ZIP.sha256,
            IS_FAM_ANNOT.sha256, ISOSDB_FNA_SHA256, HOST_NEG.sha256,
        ] {
            assert_eq!(s.len(), 64, "sha256 must be 64 hex chars: {s}");
            assert!(s.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()));
        }
    }

    // sha256 of a known vector (pure-Rust helper sanity).
    #[test]
    fn sha256_of_known_file() {
        let p = std::env::temp_dir().join(format!("rust_ise_sha_{}.bin", std::process::id()));
        std::fs::write(&p, b"abc").unwrap();
        let h = sha256_hex(&p).unwrap();
        let _ = std::fs::remove_file(&p);
        assert_eq!(h, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
    }
}