1use chematic_rxn::Reaction;
23
24use crate::error::MolParseError;
25use crate::mol2000::parse_mol;
26
27#[derive(Debug)]
29pub enum RxnParseError {
30 MissingHeader,
32 BadCountLine,
34 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
56pub fn parse_rxn_file(text: &str) -> Result<Reaction, RxnParseError> {
58 let mut lines = text.lines();
59
60 match lines.next() {
62 Some(l) if l.trim() == "$RXN" => {}
63 _ => return Err(RxnParseError::MissingHeader),
64 }
65
66 for _ in 0..3 {
68 lines.next();
69 }
70
71 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 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 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 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
120pub 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'); out.push_str(" chematic\n"); out.push('\n'); 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#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn minimal_rxn_block() -> String {
152 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(ðane, &meta),
174 write_mol(ðanol, &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); assert_eq!(rxn.products[0].atom_count(), 3); }
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 let rxn2 = parse_rxn_file(&written).unwrap();
200 assert_eq!(rxn2.reactants.len(), 1);
201 assert_eq!(rxn2.products.len(), 1);
202 }
203}