chematic-mol 0.4.28

MOL/SDF V2000 and V3000 parser and writer for chematic — pure-Rust RDKit alternative
Documentation
//! MDL RXN V2000 file format parser and writer.
//!
//! The MDL RXN format stores reactions as a sequence of MOL blocks.
//!
//! File structure:
//! ```text
//! $RXN
//! <blank line>
//! <program/date line>
//! <comment line>
//! nreactants nproducts
//! $MOL
//! <MOL block for reactant 1>
//! $MOL
//! <MOL block for reactant 2>
//!//! $MOL
//! <MOL block for product 1>
//!//! ```

use chematic_rxn::Reaction;

use crate::error::MolParseError;
use crate::mol2000::parse_mol;

/// Error produced by [`parse_rxn_file`].
#[derive(Debug)]
pub enum RxnParseError {
    /// The file does not start with `$RXN`.
    MissingHeader,
    /// The reactant/product count line could not be parsed.
    BadCountLine,
    /// A MOL block inside the RXN file failed to parse.
    MolParse(MolParseError),
}

impl core::fmt::Display for RxnParseError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::MissingHeader => write!(f, "RXN file must start with $RXN"),
            Self::BadCountLine => write!(f, "cannot parse reactant/product count line"),
            Self::MolParse(e) => write!(f, "MOL parse error in RXN: {e}"),
        }
    }
}

impl std::error::Error for RxnParseError {}

impl From<MolParseError> for RxnParseError {
    fn from(e: MolParseError) -> Self {
        Self::MolParse(e)
    }
}

/// Parse an MDL RXN V2000 string into a [`Reaction`].
pub fn parse_rxn_file(text: &str) -> Result<Reaction, RxnParseError> {
    let mut lines = text.lines();

    // Line 1: $RXN
    match lines.next() {
        Some(l) if l.trim() == "$RXN" => {}
        _ => return Err(RxnParseError::MissingHeader),
    }

    // Lines 2-4: blank, program, comment (skip)
    for _ in 0..3 {
        lines.next();
    }

    // Line 5: "  nreactants  nproducts  …"
    let count_line = lines.next().unwrap_or("");
    let counts: Vec<i64> = count_line
        .split_whitespace()
        .filter_map(|s| s.parse().ok())
        .collect();
    if counts.len() < 2 {
        return Err(RxnParseError::BadCountLine);
    }
    let n_reactants = counts[0].max(0) as usize;
    let n_products = counts[1].max(0) as usize;

    // Work directly on the remaining text to find "$MOL" blocks.
    // Find the position of the first "$MOL" in the original text.
    let first_mol_pos = match text.find("$MOL") {
        Some(p) => p,
        None => {
            return Ok(Reaction {
                reactants: vec![],
                agents: vec![],
                products: vec![],
            });
        }
    };
    let mol_section = &text[first_mol_pos..];

    // Split on "$MOL\n" to get individual MOL blocks.
    let mol_blocks: Vec<&str> = mol_section.split("$MOL\n").skip(1).collect();

    let mut reactants = Vec::with_capacity(n_reactants);
    let mut products = Vec::with_capacity(n_products);

    for (i, block) in mol_blocks.iter().enumerate() {
        // Each block is already a valid MOL V2000 block (3 header lines + data).
        let (mol, _meta) = parse_mol(block)?;
        if i < n_reactants {
            reactants.push(mol);
        } else if i < n_reactants + n_products {
            products.push(mol);
        }
    }

    Ok(Reaction {
        reactants,
        agents: vec![],
        products,
    })
}

/// Write a [`Reaction`] as an MDL RXN V2000 string.
pub fn write_rxn_file(rxn: &Reaction) -> String {
    use crate::mol2000::{MolMetadata, write_mol};

    let mut out = String::new();
    out.push_str("$RXN\n");
    out.push('\n'); // program line (blank)
    out.push_str("     chematic\n"); // program/date
    out.push('\n'); // comment (blank)
    out.push_str(&format!(
        "{:3}{:3}\n",
        rxn.reactants.len(),
        rxn.products.len()
    ));

    let meta = MolMetadata::default();
    for mol in rxn.reactants.iter().chain(rxn.products.iter()) {
        out.push_str("$MOL\n");
        out.push_str(&write_mol(mol, &meta));
    }
    out
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn minimal_rxn_block() -> String {
        // Build ethane→ethanol reaction by writing real MOL blocks via write_mol.
        use crate::mol2000::{MolMetadata, write_mol};
        use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};

        let mut b = MoleculeBuilder::new();
        let c1 = b.add_atom(Atom::new(Element::C));
        let c2 = b.add_atom(Atom::new(Element::C));
        b.add_bond(c1, c2, BondOrder::Single).unwrap();
        let ethane = b.build();

        let mut b2 = MoleculeBuilder::new();
        let c1 = b2.add_atom(Atom::new(Element::C));
        let c2 = b2.add_atom(Atom::new(Element::C));
        let o = b2.add_atom(Atom::new(Element::O));
        b2.add_bond(c1, c2, BondOrder::Single).unwrap();
        b2.add_bond(c2, o, BondOrder::Single).unwrap();
        let ethanol = b2.build();

        let meta = MolMetadata::default();
        format!(
            "$RXN\n\n     test\n\n  1  1\n$MOL\n{}$MOL\n{}",
            write_mol(&ethane, &meta),
            write_mol(&ethanol, &meta),
        )
    }

    #[test]
    fn test_parse_rxn_file_counts() {
        let rxn = parse_rxn_file(&minimal_rxn_block()).unwrap();
        assert_eq!(rxn.reactants.len(), 1);
        assert_eq!(rxn.products.len(), 1);
        assert_eq!(rxn.reactants[0].atom_count(), 2); // ethane
        assert_eq!(rxn.products[0].atom_count(), 3); // ethanol
    }

    #[test]
    fn test_parse_rxn_missing_header() {
        let err = parse_rxn_file("not a rxn file\n");
        assert!(matches!(err, Err(RxnParseError::MissingHeader)));
    }

    #[test]
    fn test_write_rxn_file_roundtrip() {
        let rxn = parse_rxn_file(&minimal_rxn_block()).unwrap();
        let written = write_rxn_file(&rxn);
        assert!(written.starts_with("$RXN"));
        // Round-trip: re-parse and check counts.
        let rxn2 = parse_rxn_file(&written).unwrap();
        assert_eq!(rxn2.reactants.len(), 1);
        assert_eq!(rxn2.products.len(), 1);
    }
}