Skip to main content

chematic_mol/
rxn.rs

1//! MDL RXN V2000 file format parser and writer.
2//!
3//! The MDL RXN format stores reactions as a sequence of MOL blocks.
4//!
5//! File structure:
6//! ```text
7//! $RXN
8//! <blank line>
9//! <program/date line>
10//! <comment line>
11//! nreactants nproducts
12//! $MOL
13//! <MOL block for reactant 1>
14//! $MOL
15//! <MOL block for reactant 2>
16//! …
17//! $MOL
18//! <MOL block for product 1>
19//! …
20//! ```
21
22use chematic_rxn::Reaction;
23
24use crate::error::MolParseError;
25use crate::mol2000::parse_mol;
26
27/// Error produced by [`parse_rxn_file`].
28#[derive(Debug)]
29pub enum RxnParseError {
30    /// The file does not start with `$RXN`.
31    MissingHeader,
32    /// The reactant/product count line could not be parsed.
33    BadCountLine,
34    /// A MOL block inside the RXN file failed to parse.
35    MolParse(MolParseError),
36}
37
38impl core::fmt::Display for RxnParseError {
39    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
40        match self {
41            Self::MissingHeader => write!(f, "RXN file must start with $RXN"),
42            Self::BadCountLine => write!(f, "cannot parse reactant/product count line"),
43            Self::MolParse(e) => write!(f, "MOL parse error in RXN: {e}"),
44        }
45    }
46}
47
48impl std::error::Error for RxnParseError {}
49
50impl From<MolParseError> for RxnParseError {
51    fn from(e: MolParseError) -> Self {
52        Self::MolParse(e)
53    }
54}
55
56/// Parse an MDL RXN V2000 string into a [`Reaction`].
57pub fn parse_rxn_file(text: &str) -> Result<Reaction, RxnParseError> {
58    let mut lines = text.lines();
59
60    // Line 1: $RXN
61    match lines.next() {
62        Some(l) if l.trim() == "$RXN" => {}
63        _ => return Err(RxnParseError::MissingHeader),
64    }
65
66    // Lines 2-4: blank, program, comment (skip)
67    for _ in 0..3 {
68        lines.next();
69    }
70
71    // Line 5: "  nreactants  nproducts  …"
72    let count_line = lines.next().unwrap_or("");
73    let counts: Vec<i64> = count_line
74        .split_whitespace()
75        .filter_map(|s| s.parse().ok())
76        .collect();
77    if counts.len() < 2 {
78        return Err(RxnParseError::BadCountLine);
79    }
80    let n_reactants = counts[0].max(0) as usize;
81    let n_products = counts[1].max(0) as usize;
82
83    // Work directly on the remaining text to find "$MOL" blocks.
84    // Find the position of the first "$MOL" in the original text.
85    let first_mol_pos = match text.find("$MOL") {
86        Some(p) => p,
87        None => {
88            return Ok(Reaction {
89                reactants: vec![],
90                agents: vec![],
91                products: vec![],
92            });
93        }
94    };
95    let mol_section = &text[first_mol_pos..];
96
97    // Split on "$MOL\n" to get individual MOL blocks.
98    let mol_blocks: Vec<&str> = mol_section.split("$MOL\n").skip(1).collect();
99
100    let mut reactants = Vec::with_capacity(n_reactants);
101    let mut products = Vec::with_capacity(n_products);
102
103    for (i, block) in mol_blocks.iter().enumerate() {
104        // Each block is already a valid MOL V2000 block (3 header lines + data).
105        let (mol, _meta) = parse_mol(block)?;
106        if i < n_reactants {
107            reactants.push(mol);
108        } else if i < n_reactants + n_products {
109            products.push(mol);
110        }
111    }
112
113    Ok(Reaction {
114        reactants,
115        agents: vec![],
116        products,
117    })
118}
119
120/// Write a [`Reaction`] as an MDL RXN V2000 string.
121pub fn write_rxn_file(rxn: &Reaction) -> String {
122    use crate::mol2000::{MolMetadata, write_mol};
123
124    let mut out = String::new();
125    out.push_str("$RXN\n");
126    out.push('\n'); // program line (blank)
127    out.push_str("     chematic\n"); // program/date
128    out.push('\n'); // comment (blank)
129    out.push_str(&format!(
130        "{:3}{:3}\n",
131        rxn.reactants.len(),
132        rxn.products.len()
133    ));
134
135    let meta = MolMetadata::default();
136    for mol in rxn.reactants.iter().chain(rxn.products.iter()) {
137        out.push_str("$MOL\n");
138        out.push_str(&write_mol(mol, &meta));
139    }
140    out
141}
142
143// ---------------------------------------------------------------------------
144// Tests
145// ---------------------------------------------------------------------------
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn minimal_rxn_block() -> String {
152        // Build ethane→ethanol reaction by writing real MOL blocks via write_mol.
153        use crate::mol2000::{MolMetadata, write_mol};
154        use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
155
156        let mut b = MoleculeBuilder::new();
157        let c1 = b.add_atom(Atom::new(Element::C));
158        let c2 = b.add_atom(Atom::new(Element::C));
159        b.add_bond(c1, c2, BondOrder::Single).unwrap();
160        let ethane = b.build();
161
162        let mut b2 = MoleculeBuilder::new();
163        let c1 = b2.add_atom(Atom::new(Element::C));
164        let c2 = b2.add_atom(Atom::new(Element::C));
165        let o = b2.add_atom(Atom::new(Element::O));
166        b2.add_bond(c1, c2, BondOrder::Single).unwrap();
167        b2.add_bond(c2, o, BondOrder::Single).unwrap();
168        let ethanol = b2.build();
169
170        let meta = MolMetadata::default();
171        format!(
172            "$RXN\n\n     test\n\n  1  1\n$MOL\n{}$MOL\n{}",
173            write_mol(&ethane, &meta),
174            write_mol(&ethanol, &meta),
175        )
176    }
177
178    #[test]
179    fn test_parse_rxn_file_counts() {
180        let rxn = parse_rxn_file(&minimal_rxn_block()).unwrap();
181        assert_eq!(rxn.reactants.len(), 1);
182        assert_eq!(rxn.products.len(), 1);
183        assert_eq!(rxn.reactants[0].atom_count(), 2); // ethane
184        assert_eq!(rxn.products[0].atom_count(), 3); // ethanol
185    }
186
187    #[test]
188    fn test_parse_rxn_missing_header() {
189        let err = parse_rxn_file("not a rxn file\n");
190        assert!(matches!(err, Err(RxnParseError::MissingHeader)));
191    }
192
193    #[test]
194    fn test_write_rxn_file_roundtrip() {
195        let rxn = parse_rxn_file(&minimal_rxn_block()).unwrap();
196        let written = write_rxn_file(&rxn);
197        assert!(written.starts_with("$RXN"));
198        // Round-trip: re-parse and check counts.
199        let rxn2 = parse_rxn_file(&written).unwrap();
200        assert_eq!(rxn2.reactants.len(), 1);
201        assert_eq!(rxn2.products.len(), 1);
202    }
203}