rust-ise 0.2.0

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
//! ISfinder ingest — the clean, authoritative side of the union DB.
//!
//! `IS.faa` headers look like:
//!   >html.2020//<ISname> ~~~<orf_descriptor>~~~<function>~~~
//! We keep only ORFs whose `<function>` is exactly `Transposase` (7,121 of them;
//! the `Accessory`/`Passenger` genes are dropped — this is why the ISfinder side
//! carries no passenger-gene contamination). Family comes from `IS.csv` (Name→Family).

use super::DbOrf;
use std::collections::HashMap;
use std::path::Path;

/// Ingest ISfinder transposase ORFs, family-tagged from `IS.csv`.
pub fn ingest_isfinder(is_faa: &Path, is_csv: &Path) -> Result<Vec<DbOrf>, String> {
    let fam = parse_is_csv(is_csv)?; // ISname -> family
    let data = std::fs::read(is_faa).map_err(|e| format!("read {}: {e}", is_faa.display()))?;
    let text = String::from_utf8_lossy(&data);

    let mut out: Vec<DbOrf> = Vec::new();
    let mut keep = false;
    let mut id = String::new();
    let mut family = String::new();
    let mut seq: Vec<u8> = Vec::new();

    for line in text.lines() {
        if let Some(hdr) = line.strip_prefix('>') {
            // Flush the previous record before starting a new one.
            if keep && !seq.is_empty() {
                out.push(DbOrf {
                    id: std::mem::take(&mut id),
                    family: std::mem::take(&mut family),
                    source: "ISfinder",
                    seq: std::mem::take(&mut seq),
                });
            }
            seq.clear();

            let parts: Vec<&str> = hdr.split("~~~").collect();
            let first_tok = parts
                .first()
                .and_then(|s| s.split_whitespace().next())
                .unwrap_or("");
            let func = parts.get(2).map(|s| s.trim()).unwrap_or("");
            keep = func == "Transposase";
            // Unique id = the ORF descriptor (parts[1]); multiple transposase ORFs can
            // share one ISname, so the descriptor keeps profile-sequence names unique.
            let descr = parts
                .get(1)
                .map(|s| s.trim())
                .filter(|s| !s.is_empty())
                .unwrap_or(first_tok);
            id = descr.to_string();
            let isname = first_tok.strip_prefix("html.2020//").unwrap_or(first_tok);
            family = fam.get(isname).cloned().unwrap_or_else(|| "unknown".to_string());
        } else if keep {
            seq.extend(line.trim().bytes().filter(|&b| b != b'\r'));
        }
    }
    if keep && !seq.is_empty() {
        out.push(DbOrf { id, family, source: "ISfinder", seq });
    }
    Ok(out)
}

/// Parse `IS.csv` (header row then rows) into `Name → Family`. Columns 2 (Name) and
/// 3 (Family) precede any quoted field, so a plain comma split is safe for them.
fn parse_is_csv(path: &Path) -> Result<HashMap<String, String>, String> {
    let data = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
    let mut map = HashMap::new();
    for (i, line) in data.lines().enumerate() {
        if i == 0 || line.trim().is_empty() {
            continue; // header
        }
        let cols: Vec<&str> = line.splitn(4, ',').collect();
        if cols.len() >= 3 {
            let name = cols[1].trim();
            let family = cols[2].trim();
            if !name.is_empty() && !family.is_empty() {
                map.insert(name.to_string(), family.to_string());
            }
        }
    }
    Ok(map)
}