qbix 0.1.1

Random access to BAM records by read name using a compact .qbi index
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};

use crate::error::Result;

pub(crate) const BGZF_CACHE_SIZE: usize = 64 * 1024 * 1024;

#[repr(C)]
struct RawHtsFile {
    _private: [u8; 0],
}

#[repr(C)]
struct RawSamHdr {
    _private: [u8; 0],
}

#[repr(C)]
struct RawBam1 {
    _private: [u8; 0],
}

extern "C" {
    fn hts_open(path: *const c_char, mode: *const c_char) -> *mut RawHtsFile;
    fn hts_close(fp: *mut RawHtsFile) -> c_int;
    fn sam_hdr_read(fp: *mut RawHtsFile) -> *mut RawSamHdr;
    fn sam_hdr_write(fp: *mut RawHtsFile, h: *const RawSamHdr) -> c_int;
    fn sam_hdr_destroy(h: *mut RawSamHdr);
    fn sam_hdr_dup(h: *const RawSamHdr) -> *mut RawSamHdr;
    fn sam_hdr_nref(h: *const RawSamHdr) -> c_int;
    fn sam_hdr_tid2len(h: *const RawSamHdr, tid: c_int) -> i64;
    fn sam_idx_init(
        fp: *mut RawHtsFile,
        h: *mut RawSamHdr,
        min_shift: c_int,
        fnidx: *const c_char,
    ) -> c_int;
    fn sam_idx_save(fp: *mut RawHtsFile) -> c_int;
    fn bam_init1() -> *mut RawBam1;
    fn bam_destroy1(b: *mut RawBam1);
    fn sam_read1(fp: *mut RawHtsFile, h: *mut RawSamHdr, b: *mut RawBam1) -> c_int;
    fn sam_write1(fp: *mut RawHtsFile, h: *const RawSamHdr, b: *const RawBam1) -> c_int;
    fn hts_set_threads(fp: *mut RawHtsFile, n: c_int) -> c_int;
    fn qbix_hts_shim_bam_qname(b: *mut RawBam1) -> *const c_char;
    fn qbix_hts_shim_sam_hdr_text(h: *const RawSamHdr) -> *const c_char;
    fn qbix_hts_shim_sam_hdr_text_len(h: *const RawSamHdr) -> usize;
    fn qbix_hts_shim_set_unknown_sort_order(h: *mut RawSamHdr) -> c_int;
    fn qbix_hts_shim_add_pg(
        h: *mut RawSamHdr,
        version: *const c_char,
        command_line: *const c_char,
    ) -> c_int;
    fn qbix_hts_shim_bgzf_tell(fp: *mut RawHtsFile) -> i64;
    fn qbix_hts_shim_bgzf_seek(fp: *mut RawHtsFile, offset: i64) -> i64;
    fn qbix_hts_shim_bgzf_set_cache_size(fp: *mut RawHtsFile, size: c_int) -> c_int;
    #[cfg(feature = "biosyntax")]
    fn qbix_hts_shim_sam_format1(
        h: *const RawSamHdr,
        b: *const RawBam1,
        out: *mut *mut c_char,
        len: *mut usize,
    ) -> c_int;
    #[cfg(feature = "biosyntax")]
    fn qbix_hts_shim_free(ptr: *mut std::ffi::c_void);
}

pub(crate) struct HtsFile(*mut RawHtsFile);
pub(crate) struct Header(*mut RawSamHdr);
pub(crate) struct BamRecord(*mut RawBam1);

impl HtsFile {
    pub(crate) fn close(mut self) -> Result<()> {
        let file = std::mem::replace(&mut self.0, std::ptr::null_mut());
        if unsafe { hts_close(file) } < 0 {
            return Err("[qbix] could not close alignment output".to_string());
        }
        Ok(())
    }

    pub(crate) fn start_index(&self, header: &Header, path: &CStr, csi: bool) -> Result<()> {
        let min_shift = if csi { 14 } else { 0 };
        if unsafe { sam_idx_init(self.0, header.0, min_shift, path.as_ptr()) } < 0 {
            return Err("[qbix] could not initialize BAM coordinate index".to_string());
        }
        Ok(())
    }

    pub(crate) fn save_index(&self) -> Result<()> {
        if unsafe { sam_idx_save(self.0) } < 0 {
            return Err("[qbix] could not write BAM coordinate index".to_string());
        }
        Ok(())
    }

    pub(crate) fn open(path: &str, mode: &str) -> std::result::Result<Self, ()> {
        let path = CString::new(path).map_err(|_| ())?;
        let mode = CString::new(mode).map_err(|_| ())?;
        let fp = unsafe { hts_open(path.as_ptr(), mode.as_ptr()) };
        if fp.is_null() {
            Err(())
        } else {
            Ok(Self(fp))
        }
    }

    pub(crate) fn read_header(&self) -> std::result::Result<Header, ()> {
        let header = unsafe { sam_hdr_read(self.0) };
        if header.is_null() {
            Err(())
        } else {
            Ok(Header(header))
        }
    }

    pub(crate) fn set_threads(&self, threads: usize) -> Result<()> {
        if threads <= 1 {
            return Ok(());
        }
        let threads = c_int::try_from(threads)
            .map_err(|_| "[qbix] thread count is too large for htslib".to_string())?;
        if unsafe { hts_set_threads(self.0, threads) } < 0 {
            return Err("[qbix] hts_set_threads failed".to_string());
        }
        Ok(())
    }

    pub(crate) fn set_bgzf_cache_size(&self, size: usize) -> Result<()> {
        let size = c_int::try_from(size)
            .map_err(|_| "[qbix] BGZF cache size is too large for htslib".to_string())?;
        if unsafe { qbix_hts_shim_bgzf_set_cache_size(self.0, size) } != 0 {
            return Err("[qbix] bgzf_set_cache_size failed".to_string());
        }
        Ok(())
    }

    pub(crate) fn read_next(&self, header: &Header, record: &BamRecord) -> i32 {
        unsafe { sam_read1(self.0, header.0, record.0) }
    }

    pub(crate) fn write_record(&self, header: &Header, record: &BamRecord) -> Result<()> {
        if unsafe { sam_write1(self.0, header.0, record.0) } < 0 {
            return Err("[qbix] sam_write1 failed".to_string());
        }
        Ok(())
    }

    pub(crate) fn write_header(&self, header: &Header) -> Result<()> {
        if unsafe { sam_hdr_write(self.0, header.0) } < 0 {
            return Err("[qbix] sam_hdr_write failed".to_string());
        }
        Ok(())
    }

    pub(crate) fn read_record_at(
        &self,
        header: &Header,
        record: &BamRecord,
        offset: i64,
    ) -> Result<()> {
        if offset < 0 {
            return Err("[qbix] invalid negative BGZF offset".to_string());
        }
        if unsafe { qbix_hts_shim_bgzf_seek(self.0, offset) } != 0 {
            return Err("[qbix] bgzf_seek failed".to_string());
        }
        if self.read_next(header, record) < 0 {
            return Err("[qbix] sam_read1 failed".to_string());
        }
        Ok(())
    }

    pub(crate) fn tell(&self) -> Result<i64> {
        let offset = unsafe { qbix_hts_shim_bgzf_tell(self.0) };
        if offset < 0 {
            Err("[qbix] bgzf_tell failed".to_string())
        } else {
            Ok(offset)
        }
    }
}

impl Drop for HtsFile {
    fn drop(&mut self) {
        if !self.0.is_null() {
            unsafe {
                hts_close(self.0);
            }
        }
    }
}

impl Drop for Header {
    fn drop(&mut self) {
        if !self.0.is_null() {
            unsafe {
                sam_hdr_destroy(self.0);
            }
        }
    }
}

impl Header {
    pub(crate) fn is_coordinate_sorted(&self) -> Result<bool> {
        Ok(self.text()?.split(|byte| *byte == b'\n').any(|line| {
            line.starts_with(b"@HD\t")
                && line
                    .split(|byte| *byte == b'\t')
                    .any(|field| field == b"SO:coordinate")
        }))
    }

    pub(crate) fn needs_csi(&self) -> Result<bool> {
        let nref = unsafe { sam_hdr_nref(self.0) };
        if nref < 0 {
            return Err("[qbix] could not inspect BAM references".to_string());
        }
        for tid in 0..nref {
            if unsafe { sam_hdr_tid2len(self.0, tid) } > 1_i64 << 29 {
                return Ok(true);
            }
        }
        Ok(false)
    }

    pub(crate) fn duplicate(&self) -> Result<Self> {
        let header = unsafe { sam_hdr_dup(self.0) };
        if header.is_null() {
            Err("[qbix] could not duplicate BAM header".to_string())
        } else {
            Ok(Self(header))
        }
    }

    pub(crate) fn set_unknown_sort_order(&self) -> Result<()> {
        if unsafe { qbix_hts_shim_set_unknown_sort_order(self.0) } < 0 {
            return Err("[qbix] could not update output sort order".to_string());
        }
        Ok(())
    }

    pub(crate) fn add_qbix_program(&self, command_line: &str) -> Result<()> {
        let version = CString::new(crate::VERSION).expect("package version has no NUL");
        let command_line = CString::new(command_line)
            .map_err(|_| "[qbix] command line contains a NUL byte".to_string())?;
        if unsafe { qbix_hts_shim_add_pg(self.0, version.as_ptr(), command_line.as_ptr()) } < 0 {
            return Err("[qbix] could not add qbix program record".to_string());
        }
        Ok(())
    }

    pub(crate) fn text(&self) -> Result<&[u8]> {
        if self.0.is_null() {
            return Err("[qbix] invalid BAM header".to_string());
        }
        let text = unsafe { qbix_hts_shim_sam_hdr_text(self.0) };
        let len = unsafe { qbix_hts_shim_sam_hdr_text_len(self.0) };
        if text.is_null() && len > 0 {
            return Err("[qbix] invalid BAM header text".to_string());
        }
        Ok(if len == 0 {
            &[]
        } else {
            unsafe { std::slice::from_raw_parts(text as *const u8, len) }
        })
    }

    pub(crate) fn text_hash(&self) -> Result<u64> {
        Ok(fnv1a64(self.text()?))
    }
}

impl BamRecord {
    pub(crate) fn new() -> Result<Self> {
        let record = unsafe { bam_init1() };
        if record.is_null() {
            Err("[qbix] could not allocate BAM record".to_string())
        } else {
            Ok(Self(record))
        }
    }

    pub(crate) fn qname(&self) -> Result<&str> {
        if self.0.is_null() {
            return Err("[qbix] invalid BAM record".to_string());
        }
        let qname = unsafe { qbix_hts_shim_bam_qname(self.0) };
        if qname.is_null() {
            return Err("[qbix] invalid BAM record".to_string());
        }
        let cstr = unsafe { CStr::from_ptr(qname) };
        cstr.to_str()
            .map_err(|_| "[qbix] BAM read name is not valid UTF-8".to_string())
    }

    #[cfg(feature = "biosyntax")]
    pub(crate) fn format_sam(&self, header: &Header) -> Result<Vec<u8>> {
        let mut ptr = std::ptr::null_mut();
        let mut len = 0usize;
        let ret = unsafe { qbix_hts_shim_sam_format1(header.0, self.0, &mut ptr, &mut len) };
        if ret < 0 || ptr.is_null() {
            return Err("[qbix] sam_format1 failed".to_string());
        }
        let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len) }.to_vec();
        unsafe {
            qbix_hts_shim_free(ptr.cast());
        }
        Ok(bytes)
    }
}

fn fnv1a64(bytes: &[u8]) -> u64 {
    let mut hash = 0xcbf29ce484222325u64;
    for &byte in bytes {
        hash ^= u64::from(byte);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    hash
}

impl Drop for BamRecord {
    fn drop(&mut self) {
        if !self.0.is_null() {
            unsafe {
                bam_destroy1(self.0);
            }
        }
    }
}