1use rustc_hash::{FxHashMap, FxHashSet};
2use std::collections::VecDeque;
3
4use chematic_core::{
5 AtomIdx, BondIdx, BondOrder, Chirality, Molecule, MoleculeBuilder, STEREO_H_SENTINEL,
6 validate_valence,
7};
8use chematic_smarts::{
9 AtomPrimitive, AtomQuery, BondPrimitive, BondQuery, QueryMolecule, find_matches,
10};
11
12use crate::reaction::{RxnError, parse_reaction};
13
14#[derive(Debug)]
16pub enum TransformError {
17 SmirksParse(RxnError),
18 ReactantCountMismatch { expected: usize, got: usize },
19}
20
21impl core::fmt::Display for TransformError {
22 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
23 match self {
24 Self::SmirksParse(e) => write!(f, "SMIRKS parse error: {e}"),
25 Self::ReactantCountMismatch { expected, got } => {
26 write!(f, "reactant count mismatch: expected {expected}, got {got}")
27 }
28 }
29 }
30}
31
32impl std::error::Error for TransformError {}
33
34impl From<RxnError> for TransformError {
35 fn from(e: RxnError) -> Self {
36 Self::SmirksParse(e)
37 }
38}
39
40pub fn run_reactants(
52 smirks: &str,
53 reactants: &[&Molecule],
54) -> Result<Vec<Vec<Molecule>>, TransformError> {
55 run_reactants_impl(smirks, reactants, true)
56}
57
58pub fn run_reactants_strict(
67 smirks: &str,
68 reactants: &[&Molecule],
69) -> Result<Vec<Vec<Molecule>>, TransformError> {
70 run_reactants_impl(smirks, reactants, false)
71}
72
73fn run_reactants_impl(
74 smirks: &str,
75 reactants: &[&Molecule],
76 carry_substituents: bool,
77) -> Result<Vec<Vec<Molecule>>, TransformError> {
78 let rxn = parse_reaction(smirks)?;
79
80 let n_templates = rxn.reactants.len();
81 if reactants.len() != n_templates {
82 return Err(TransformError::ReactantCountMismatch {
83 expected: n_templates,
84 got: reactants.len(),
85 });
86 }
87
88 let queries: Vec<QueryMolecule> = rxn.reactants.iter().map(mol_to_query).collect();
91 let template_atom_maps: Vec<Vec<Option<u16>>> = rxn
92 .reactants
93 .iter()
94 .map(|tmpl| {
95 (0..tmpl.atom_count())
96 .map(|i| tmpl.atom(AtomIdx(i as u32)).atom_map)
97 .collect()
98 })
99 .collect();
100
101 let has_stereo = rxn
107 .reactants
108 .iter()
109 .any(|r| r.atoms().any(|(_, a)| a.chirality != Chirality::None));
110 let has_ez_stereo = rxn.reactants.iter().any(|r| {
113 r.bonds()
114 .any(|(_, b)| matches!(b.order, BondOrder::Up | BondOrder::Down))
115 });
116
117 let all_match_sets: Vec<Vec<FxHashMap<usize, AtomIdx>>> = queries
119 .iter()
120 .zip(reactants.iter())
121 .map(|(q, mol)| find_matches(q, mol))
122 .collect();
123
124 if all_match_sets.iter().any(|ms| ms.is_empty()) {
126 return Ok(vec![]);
127 }
128
129 let mut results: Vec<Vec<Molecule>> = Vec::new();
130
131 for combo in cartesian_product(&all_match_sets) {
132 let mut global_map: FxHashMap<u16, (usize, AtomIdx)> = FxHashMap::default();
134 for (ri, match_map) in combo.iter().enumerate() {
135 for (&qi, &t_idx) in match_map {
136 if let Some(am) = template_atom_maps[ri][qi] {
137 global_map.insert(am, (ri, t_idx));
138 }
139 }
140 }
141
142 let mut all_template_atoms: FxHashSet<(usize, AtomIdx)> = FxHashSet::default();
146 for (ri, match_map) in combo.iter().enumerate() {
147 for &t_idx in match_map.values() {
148 all_template_atoms.insert((ri, t_idx));
149 }
150 }
151
152 if has_stereo {
156 let ok = (0..rxn.reactants.len())
157 .all(|ri| smirks_chirality_ok(&rxn.reactants[ri], reactants[ri], &combo[ri]));
158 if !ok {
159 continue;
160 }
161 }
162 if has_ez_stereo {
164 let ok = (0..rxn.reactants.len())
165 .all(|ri| smirks_ez_stereo_ok(&rxn.reactants[ri], reactants[ri], &combo[ri]));
166 if !ok {
167 continue;
168 }
169 }
170
171 let products: Vec<Molecule> = rxn
172 .products
173 .iter()
174 .map(|pt| {
175 build_product(
176 pt,
177 &global_map,
178 reactants,
179 &all_template_atoms,
180 carry_substituents,
181 )
182 })
183 .collect();
184
185 if products.iter().all(|p| validate_valence(p).is_empty()) {
187 results.push(products);
188 }
189 }
190
191 Ok(results)
192}
193
194fn permutation_parity(from_seq: &[u32], to_seq: &[u32]) -> Option<bool> {
203 let n = from_seq.len();
204 if n != to_seq.len() {
205 return None;
206 }
207 let mut perm = Vec::with_capacity(n);
209 for &t in to_seq {
210 let pos = from_seq.iter().position(|&f| f == t)?;
211 perm.push(pos);
212 }
213 let mut inv = 0usize;
215 for i in 0..n {
216 for j in (i + 1)..n {
217 if perm[i] > perm[j] {
218 inv += 1;
219 }
220 }
221 }
222 Some(inv.is_multiple_of(2)) }
224
225fn smirks_chirality_ok(
237 tmpl: &Molecule,
238 reactant: &Molecule,
239 match_map: &FxHashMap<usize, AtomIdx>,
240) -> bool {
241 for i in 0..tmpl.atom_count() {
242 let tmpl_atom = tmpl.atom(AtomIdx(i as u32));
243 if tmpl_atom.chirality == Chirality::None {
244 continue;
245 }
246
247 let Some(tmpl_order) = tmpl.stereo_neighbor_order(AtomIdx(i as u32)) else {
249 continue; };
251
252 let Some(&react_idx) = match_map.get(&i) else {
254 continue; };
256
257 let react_atom = reactant.atom(react_idx);
258 if react_atom.chirality == Chirality::None {
259 return false; }
261
262 let mut mapped: Vec<u32> = Vec::with_capacity(tmpl_order.len());
264 let mut all_mapped = true;
265 for &t in tmpl_order {
266 if t == STEREO_H_SENTINEL {
267 mapped.push(STEREO_H_SENTINEL);
268 } else {
269 match match_map.get(&(t as usize)) {
270 Some(ri) => mapped.push(ri.0),
271 None => {
272 all_mapped = false;
273 break;
274 }
275 }
276 }
277 }
278 if !all_mapped {
279 continue; }
281
282 let Some(react_order) = reactant.stereo_neighbor_order(react_idx) else {
284 if react_atom.chirality != tmpl_atom.chirality {
286 return false;
287 }
288 continue;
289 };
290
291 let Some(even_parity) = permutation_parity(&mapped, react_order) else {
292 continue; };
294
295 let same_flag = tmpl_atom.chirality == react_atom.chirality;
297 if same_flag != even_parity {
298 return false;
299 }
300 }
301 true
302}
303
304fn ez_stereo_outward(mol: &Molecule, atom: AtomIdx, other: AtomIdx) -> Option<BondOrder> {
321 for (nb, bidx) in mol.neighbors(atom) {
322 if nb == other {
323 continue; }
325 let bond = mol.bond(bidx);
326 match bond.order {
327 BondOrder::Up | BondOrder::Down => {
328 let outward = if bond.atom1 == atom {
329 bond.order
331 } else {
332 match bond.order {
334 BondOrder::Up => BondOrder::Down,
335 _ => BondOrder::Up,
336 }
337 };
338 return Some(outward);
339 }
340 _ => {}
341 }
342 }
343 None
344}
345
346fn smirks_ez_stereo_ok(
363 tmpl: &Molecule,
364 reactant: &Molecule,
365 match_map: &FxHashMap<usize, AtomIdx>,
366) -> bool {
367 for (_, bond) in tmpl.bonds() {
368 if bond.order != BondOrder::Double {
369 continue;
370 }
371 let ta = bond.atom1;
372 let tb = bond.atom2;
373
374 let sa = ez_stereo_outward(tmpl, ta, tb);
376 let sb = ez_stereo_outward(tmpl, tb, ta);
377
378 let (sa, sb) = match (sa, sb) {
380 (Some(a), Some(b)) => (a, b),
381 _ => continue, };
383
384 let Some(&ra) = match_map.get(&(ta.0 as usize)) else {
386 continue;
387 };
388 let Some(&rb) = match_map.get(&(tb.0 as usize)) else {
389 continue;
390 };
391
392 let ma = ez_stereo_outward(reactant, ra, rb);
394 let mb = ez_stereo_outward(reactant, rb, ra);
395
396 let (ma, mb) = match (ma, mb) {
398 (Some(a), Some(b)) => (a, b),
399 _ => continue,
400 };
401
402 if (sa == sb) != (ma == mb) {
405 return false;
406 }
407 }
408 true
409}
410
411fn mol_to_query(mol: &Molecule) -> QueryMolecule {
425 let mut qmol = QueryMolecule::new();
426
427 for (_, atom) in mol.atoms() {
428 let mut q = AtomQuery::And(
429 Box::new(AtomQuery::Primitive(AtomPrimitive::AtomicNum(
430 atom.element.atomic_number(),
431 ))),
432 Box::new(AtomQuery::Primitive(AtomPrimitive::Aromatic(atom.aromatic))),
433 );
434
435 if atom.charge != 0 {
436 q = AtomQuery::And(
437 Box::new(q),
438 Box::new(AtomQuery::Primitive(AtomPrimitive::Charge(atom.charge))),
439 );
440 }
441
442 if let Some(h) = atom.hydrogen_count
443 && h > 0
444 {
445 q = AtomQuery::And(
446 Box::new(q),
447 Box::new(AtomQuery::Primitive(AtomPrimitive::HCount(h))),
448 );
449 }
450
451 qmol.add_atom_with_map(q, atom.atom_map);
452 }
453
454 for (_bidx, bond) in mol.bonds() {
455 let bq = match bond.order {
456 BondOrder::Single | BondOrder::Up | BondOrder::Down | BondOrder::Dative => {
457 BondQuery::Primitive(BondPrimitive::Single)
458 }
459 BondOrder::Double => BondQuery::Primitive(BondPrimitive::Double),
460 BondOrder::Triple => BondQuery::Primitive(BondPrimitive::Triple),
461 BondOrder::Aromatic => BondQuery::Primitive(BondPrimitive::Aromatic),
462 BondOrder::QuerySingleOrDouble => BondQuery::Or(
463 Box::new(BondQuery::Primitive(BondPrimitive::Single)),
464 Box::new(BondQuery::Primitive(BondPrimitive::Double)),
465 ),
466 BondOrder::QuerySingleOrAromatic => BondQuery::Or(
467 Box::new(BondQuery::Primitive(BondPrimitive::Single)),
468 Box::new(BondQuery::Primitive(BondPrimitive::Aromatic)),
469 ),
470 BondOrder::QueryDoubleOrAromatic => BondQuery::Or(
471 Box::new(BondQuery::Primitive(BondPrimitive::Double)),
472 Box::new(BondQuery::Primitive(BondPrimitive::Aromatic)),
473 ),
474 BondOrder::Quadruple | BondOrder::Zero | BondOrder::QueryAny => {
475 BondQuery::Primitive(BondPrimitive::Any)
476 }
477 };
478 qmol.add_bond(bond.atom1.0 as usize, bond.atom2.0 as usize, bq);
479 }
480
481 qmol
482}
483
484fn clear_orphaned_stereo_bonds(mol: Molecule) -> Molecule {
491 let orphaned: Vec<BondIdx> = mol
492 .bonds()
493 .filter_map(|(bidx, bond)| {
494 if bond.order != BondOrder::Up && bond.order != BondOrder::Down {
495 return None;
496 }
497 let has_double = [bond.atom1, bond.atom2].iter().any(|&a| {
500 mol.neighbors(a)
501 .any(|(_, nb_bidx)| mol.bond(nb_bidx).order == BondOrder::Double)
502 });
503 if has_double { None } else { Some(bidx) }
504 })
505 .collect();
506
507 if orphaned.is_empty() {
508 return mol;
509 }
510
511 let mut builder = chematic_core::MoleculeBuilder::new();
512 for (_, atom) in mol.atoms() {
513 builder.add_atom(atom.clone());
514 }
515 for (bidx, bond) in mol.bonds() {
516 let order = if orphaned.contains(&bidx) {
517 BondOrder::Single
518 } else {
519 bond.order
520 };
521 let _ = builder.add_bond(bond.atom1, bond.atom2, order);
522 }
523 builder.copy_stereo_from(&mol);
526 let mut result = builder.build();
527 result.set_stereo_groups(mol.stereo_groups().to_vec());
529 result
530}
531
532fn build_product(
541 product_template: &Molecule,
542 global_map: &FxHashMap<u16, (usize, AtomIdx)>,
543 input_mols: &[&Molecule],
544 all_template_atoms: &FxHashSet<(usize, AtomIdx)>,
545 carry_substituents: bool,
546) -> Molecule {
547 let mut builder = MoleculeBuilder::new();
548
549 let mut template_idx_to_new: Vec<Option<AtomIdx>> = vec![None; product_template.atom_count()];
551 let mut src_to_new: FxHashMap<(usize, AtomIdx), AtomIdx> = FxHashMap::default();
553
554 let product_maps: FxHashSet<u16> = (0..product_template.atom_count())
560 .filter_map(|i| product_template.atom(AtomIdx(i as u32)).atom_map)
561 .collect();
562 let core_keys: FxHashSet<(usize, AtomIdx)> = global_map
563 .iter()
564 .filter(|(am, _)| product_maps.contains(am))
565 .map(|(_, &src)| src)
566 .collect();
567
568 for (i, slot) in template_idx_to_new.iter_mut().enumerate() {
569 let tmpl_atom = product_template.atom(AtomIdx(i as u32));
570 let new_idx = if let Some(am) = tmpl_atom.atom_map {
571 if let Some(&(mol_idx, src_idx)) = global_map.get(&am) {
572 let src_atom = input_mols[mol_idx].atom(src_idx);
574 let mut new_atom = src_atom.clone();
575 new_atom.aromatic = tmpl_atom.aromatic;
576 new_atom.charge = tmpl_atom.charge;
577 new_atom.hydrogen_count = tmpl_atom.hydrogen_count.filter(|&h| h > 0);
582 if tmpl_atom.chirality != Chirality::None {
592 new_atom.chirality = tmpl_atom.chirality;
593 } else {
594 let mut prod_elems: FxHashMap<u8, usize> = FxHashMap::default();
597 for (nb, _) in product_template.neighbors(AtomIdx(i as u32)) {
598 if product_template.atom(nb).atom_map.is_none() {
599 *prod_elems
600 .entry(product_template.atom(nb).element.atomic_number())
601 .or_insert(0) += 1;
602 }
603 }
604 if !prod_elems.is_empty() {
605 let mut rxn_elems: FxHashMap<u8, usize> = FxHashMap::default();
608 for (nb, _) in input_mols[mol_idx].neighbors(src_idx) {
609 if all_template_atoms.contains(&(mol_idx, nb)) {
610 *rxn_elems
611 .entry(input_mols[mol_idx].atom(nb).element.atomic_number())
612 .or_insert(0) += 1;
613 }
614 }
615 if prod_elems != rxn_elems {
616 new_atom.chirality = Chirality::None;
617 }
618 }
619 }
620 new_atom.atom_map = None;
621 let idx = builder.add_atom(new_atom);
622 src_to_new.insert((mol_idx, src_idx), idx);
623 idx
624 } else {
625 let mut new_atom = tmpl_atom.clone();
627 new_atom.atom_map = None;
628 builder.add_atom(new_atom)
629 }
630 } else {
631 let mut new_atom = tmpl_atom.clone();
633 new_atom.atom_map = None;
634 builder.add_atom(new_atom)
635 };
636 *slot = Some(new_idx);
637 }
638
639 let mut visited: FxHashSet<(usize, AtomIdx)> = all_template_atoms.clone();
643 if carry_substituents {
644 let mut queue: VecDeque<(usize, AtomIdx)> = core_keys.iter().cloned().collect();
645
646 while let Some((mol_idx, cur_idx)) = queue.pop_front() {
647 for (nb_idx, _bond_idx) in input_mols[mol_idx].neighbors(cur_idx) {
648 let key = (mol_idx, nb_idx);
649 if visited.contains(&key) {
650 continue;
651 }
652 visited.insert(key);
653 let src_atom = input_mols[mol_idx].atom(nb_idx);
654 let mut new_atom = src_atom.clone();
655 new_atom.atom_map = None;
656 let new_idx = builder.add_atom(new_atom);
657 src_to_new.insert(key, new_idx);
658 queue.push_back(key);
659 }
660 }
661 }
662
663 let mut added_bond_pairs: FxHashSet<(AtomIdx, AtomIdx)> = FxHashSet::default();
665
666 for (_bidx, bond) in product_template.bonds() {
667 let a_new = template_idx_to_new[bond.atom1.0 as usize].unwrap();
668 let b_new = template_idx_to_new[bond.atom2.0 as usize].unwrap();
669 let _ = builder.add_bond(a_new, b_new, bond.order);
670 added_bond_pairs.insert((a_new.min(b_new), a_new.max(b_new)));
671 }
672
673 for (&(mol_idx, src_idx), &a_new) in &src_to_new {
677 for (nb_idx, bond_idx) in input_mols[mol_idx].neighbors(src_idx) {
678 let nb_key = (mol_idx, nb_idx);
679 let Some(&b_new) = src_to_new.get(&nb_key) else {
680 continue;
681 };
682 if all_template_atoms.contains(&(mol_idx, src_idx))
683 && all_template_atoms.contains(&nb_key)
684 {
685 continue;
686 }
687 let pair = (a_new.min(b_new), a_new.max(b_new));
688 if added_bond_pairs.contains(&pair) {
689 continue;
690 }
691 added_bond_pairs.insert(pair);
692 let ob = input_mols[mol_idx].bond(bond_idx);
693 let (a, b) = if ob.atom1 == src_idx {
697 (a_new, b_new)
698 } else {
699 (b_new, a_new)
700 };
701 let _ = builder.add_bond(a, b, ob.order);
702 }
703 }
704
705 clear_orphaned_stereo_bonds(builder.build())
708}
709
710fn cartesian_product<T: Clone>(sets: &[Vec<T>]) -> Vec<Vec<T>> {
713 let mut result: Vec<Vec<T>> = vec![vec![]];
714 for set in sets {
715 result = result
716 .into_iter()
717 .flat_map(|combo| {
718 set.iter().map(move |item| {
719 let mut new_combo = combo.clone();
720 new_combo.push(item.clone());
721 new_combo
722 })
723 })
724 .collect();
725 }
726 result
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use chematic_smiles::parse;
733
734 #[test]
735 fn identity_single_atom() {
736 let mol = parse("C").unwrap();
737 let results = run_reactants("[C:1]>>[C:1]", &[&mol]).unwrap();
738 assert_eq!(results.len(), 1);
739 assert_eq!(results[0].len(), 1);
740 assert_eq!(results[0][0].atom_count(), 1);
741 }
742
743 #[test]
744 fn no_match_returns_empty() {
745 let mol = parse("C").unwrap();
746 let results = run_reactants("[N:1]>>[N:1]", &[&mol]).unwrap();
747 assert!(
748 results.is_empty(),
749 "nitrogen template must not match methane"
750 );
751 }
752
753 #[test]
754 fn multiple_matches_in_single_mol() {
755 let mol = parse("NCCN").unwrap();
756 let results = run_reactants("[N:1]>>[N:1]", &[&mol]).unwrap();
757 assert_eq!(results.len(), 2, "two N atoms in NCCN → two product sets");
758 }
759
760 #[test]
761 fn bond_formation_two_mols() {
762 let n_mol = parse("N").unwrap();
763 let c_mol = parse("C").unwrap();
764 let results = run_reactants("[N:1].[C:2]>>[N:1][C:2]", &[&n_mol, &c_mol]).unwrap();
765 assert!(!results.is_empty());
766 let prod = &results[0][0];
767 assert_eq!(prod.atom_count(), 2, "product must have 2 atoms");
768 assert_eq!(prod.bonds().count(), 1, "product must have 1 bond");
769 }
770
771 #[test]
772 fn bond_cleavage_two_products() {
773 let mol = parse("CC").unwrap();
774 let results = run_reactants("[C:1][C:2]>>[C:1].[C:2]", &[&mol]).unwrap();
775 assert!(!results.is_empty());
776 let products = &results[0];
777 assert_eq!(products.len(), 2, "two product templates → two products");
778 assert_eq!(products[0].atom_count(), 1);
779 assert_eq!(products[1].atom_count(), 1);
780 }
781
782 #[test]
783 fn reactant_count_mismatch_error() {
784 let mol = parse("C").unwrap();
785 let err = run_reactants("[N:1].[C:2]>>[N:1][C:2]", &[&mol]);
786 assert!(
787 matches!(
788 err,
789 Err(TransformError::ReactantCountMismatch {
790 expected: 2,
791 got: 1
792 })
793 ),
794 "two-template SMIRKS with one reactant must error"
795 );
796 }
797
798 #[test]
799 fn invalid_smirks_error() {
800 let mol = parse("C").unwrap();
801 let err = run_reactants("[X]>>[X]", &[&mol]);
802 assert!(
803 matches!(err, Err(TransformError::SmirksParse(_))),
804 "unknown element must yield SmirksParse error"
805 );
806 }
807
808 #[test]
809 fn overvalent_product_filtered_oxygen() {
810 let ethanol = parse("CCO").unwrap();
814 let results = run_reactants("[O:1]>>[O:1](C)C", &[ðanol]).unwrap();
815 assert!(
818 results.is_empty(),
819 "product with O having 3 bonds must be filtered out, got {} sets",
820 results.len()
821 );
822 }
823
824 #[test]
825 fn valid_charged_product_kept() {
826 let tma = parse("N(C)(C)C").unwrap();
830 let results = run_reactants("[N:1]>>[N+:1]", &[&tma]).unwrap();
831 assert!(
832 !results.is_empty(),
833 "N+ with 3 bonds must be valid and kept"
834 );
835 }
836
837 #[test]
838 fn new_atom_in_product() {
839 let mol = parse("C").unwrap();
840 let results = run_reactants("[C:1]>>[C:1]=O", &[&mol]).unwrap();
841 assert!(!results.is_empty());
842 let prod = &results[0][0];
843 assert_eq!(prod.atom_count(), 2, "C + new O = 2 atoms");
844 }
845
846 #[test]
847 fn amide_bond_formation() {
848 let nh3 = parse("N").unwrap();
850 let hcocl = parse("C(=O)Cl").unwrap();
851 let results = run_reactants("[N:1].[C:2](=O)Cl>>[C:2](=O)[N:1]", &[&nh3, &hcocl]).unwrap();
852 assert!(!results.is_empty());
853 let prod = &results[0][0];
854 assert_eq!(prod.atom_count(), 3, "C + O(new) + N = 3 atoms");
855 }
856
857 #[test]
858 fn double_bond_product() {
859 let mol = parse("CC").unwrap();
860 let results = run_reactants("[C:1][C:2]>>[C:1]=[C:2]", &[&mol]).unwrap();
861 assert!(!results.is_empty());
862 let prod = &results[0][0];
863 assert_eq!(prod.atom_count(), 2);
864 let bond_orders: Vec<BondOrder> = prod.bonds().map(|(_, b)| b.order).collect();
865 assert!(
866 bond_orders.contains(&BondOrder::Double),
867 "product must contain a double bond"
868 );
869 }
870
871 #[test]
872 fn substituent_carry_through() {
873 let methylamine = parse("NC").unwrap();
876 let acetyl_cl = parse("CC(=O)Cl").unwrap();
877 let results = run_reactants(
878 "[N:1].[C:2](=O)Cl>>[C:2](=O)[N:1]",
879 &[&methylamine, &acetyl_cl],
880 )
881 .unwrap();
882 assert!(!results.is_empty(), "must produce at least one product set");
883 let prod = &results[0][0];
884 assert_eq!(
885 prod.atom_count(),
886 5,
887 "N-methylacetamide has 5 heavy atoms, got {}",
888 prod.atom_count()
889 );
890 }
891
892 #[test]
893 fn bfs_no_leakage_into_other_product_template_atoms() {
894 let diethylamine = parse("CCNCC").unwrap(); let results = run_reactants("[N:1][C:2]>>[N:1].[C:2]", &[&diethylamine]).unwrap();
904 assert!(
905 !results.is_empty(),
906 "should find at least one N-C bond match"
907 );
908
909 let clean_cleavage = results.iter().find(|ps| {
912 ps.len() == 2
913 && ((ps[0].atom_count() == 3 && ps[1].atom_count() == 2)
914 || (ps[0].atom_count() == 2 && ps[1].atom_count() == 3))
915 });
916 assert!(
917 clean_cleavage.is_some(),
918 "expected at least one product set with sizes {{3, 2}} (N+ethyl, ethyl); \
919 all sets: {:?}",
920 results
921 .iter()
922 .map(|ps| ps.iter().map(|p| p.atom_count()).collect::<Vec<_>>())
923 .collect::<Vec<_>>()
924 );
925 }
926
927 #[test]
928 fn single_product_no_leakage_from_other_template_core() {
929 let ethane = parse("CC").unwrap();
931 let results = run_reactants("[C:1][C:2]>>[C:1].[C:2]", &[ðane]).unwrap();
932 assert!(!results.is_empty());
933 for ps in &results {
934 assert_eq!(ps.len(), 2, "two product templates → two products");
935 assert_eq!(ps[0].atom_count(), 1, "each product is a single carbon");
936 assert_eq!(ps[1].atom_count(), 1, "each product is a single carbon");
937 }
938 }
939
940 #[test]
943 fn stereo_preserved_when_template_has_no_spec() {
944 let mol = parse("[C@@H](F)(Cl)Br").unwrap();
946 let results = run_reactants("[C@@H:1](F)(Cl)Br>>[C:1](F)(Cl)Br", &[&mol]).unwrap();
947 assert!(!results.is_empty(), "should match and produce a product");
948 let prod = &results[0][0];
949 let core_chirality = prod.atom(AtomIdx(0)).chirality;
951 assert_eq!(
953 core_chirality,
954 Chirality::Clockwise,
955 "source @@ chirality must be preserved when template has no stereo spec"
956 );
957 }
958
959 #[test]
960 fn stereo_inverted_by_template() {
961 let mol = parse("[C@@H](F)(Cl)Br").unwrap();
963 let results = run_reactants("[C@@H:1](F)(Cl)Br>>[C@H:1](F)(Cl)Br", &[&mol]).unwrap();
964 assert!(!results.is_empty(), "should match and produce a product");
965 let prod = &results[0][0];
966 let core_chirality = prod.atom(AtomIdx(0)).chirality;
967 assert_eq!(
968 core_chirality,
969 Chirality::CounterClockwise,
970 "product template @ must override source @@ → CounterClockwise"
971 );
972 }
973
974 #[test]
977 fn strict_mode_excludes_substituents() {
978 let mol = parse("NC").unwrap();
981 let normal = run_reactants("[N:1]>>[N:1]", &[&mol]).unwrap();
982 let strict = run_reactants_strict("[N:1]>>[N:1]", &[&mol]).unwrap();
983 assert!(!normal.is_empty());
984 assert!(!strict.is_empty());
985 let normal_atoms = normal[0][0].atom_count();
986 let strict_atoms = strict[0][0].atom_count();
987 assert!(
988 normal_atoms > strict_atoms,
989 "normal mode carries substituent C (got {normal_atoms}), \
990 strict mode only mapped N (got {strict_atoms})"
991 );
992 assert_eq!(strict_atoms, 1, "strict mode: only the mapped N atom");
993 }
994
995 #[test]
996 fn strict_mode_bond_cleavage() {
997 let ethane = parse("CC").unwrap();
1000 let results = run_reactants_strict("[C:1][C:2]>>[C:1].[C:2]", &[ðane]).unwrap();
1001 assert!(!results.is_empty());
1002 for ps in &results {
1003 assert_eq!(ps[0].atom_count(), 1);
1004 assert_eq!(ps[1].atom_count(), 1);
1005 }
1006 }
1007
1008 #[test]
1011 fn product_removes_bracket_from_bare_bracket_atoms() {
1012 use chematic_smiles::canonical_smiles;
1015 let mol = parse("OCC").unwrap();
1016 let results = run_reactants("[OH:1]>>[O:1]", &[&mol]).unwrap();
1017 assert!(!results.is_empty(), "should match hydroxyl");
1018 let prod_smi = canonical_smiles(&results[0][0]);
1019 assert!(
1020 !prod_smi.contains("[O]"),
1021 "bare [O:1] product must write as O, not [O], got: {prod_smi}"
1022 );
1023 }
1024
1025 #[test]
1026 fn product_preserves_explicit_h_from_template() {
1027 use chematic_smiles::canonical_smiles;
1029 let mol = parse("NC").unwrap();
1030 let results = run_reactants("[N:1]>>[NH2:1]", &[&mol]).unwrap();
1031 assert!(!results.is_empty(), "should match amine N");
1032 let smi = canonical_smiles(&results[0][0]);
1033 assert!(
1035 smi.contains("[NH2]"),
1036 "explicit [NH2:1] in product must keep [NH2], got: {smi}"
1037 );
1038 }
1039
1040 #[test]
1043 fn stereo_filter_rejects_wrong_enantiomer() {
1044 let l_ala = parse("N[C@@H](C)C(=O)O").unwrap(); let d_ala = parse("N[C@H](C)C(=O)O").unwrap(); let smirks = "[N:1][C@@H:2](C)C(=O)O>>[N:1][C@@H:2](C)C(=O)O";
1049 let results_l = run_reactants(smirks, &[&l_ala]).unwrap();
1050 let results_d = run_reactants(smirks, &[&d_ala]).unwrap();
1051
1052 assert!(
1053 !results_l.is_empty(),
1054 "L-alanine (@@) must match @@ template"
1055 );
1056 assert!(
1057 results_d.is_empty(),
1058 "D-alanine (@) must NOT match @@ template (stereo filter, issue #20)"
1059 );
1060 }
1061
1062 #[test]
1063 fn stereo_neutral_smirks_matches_both_enantiomers() {
1064 let l_ala = parse("N[C@@H](C)C(=O)O").unwrap();
1066 let d_ala = parse("N[C@H](C)C(=O)O").unwrap();
1067 let smirks = "[N:1][CH:2](C)C(=O)O>>[N:1][CH:2](C)C(=O)O";
1068 let r_l = run_reactants(smirks, &[&l_ala]).unwrap();
1069 let r_d = run_reactants(smirks, &[&d_ala]).unwrap();
1070 assert!(!r_l.is_empty(), "L-alanine must match non-stereo template");
1071 assert!(!r_d.is_empty(), "D-alanine must match non-stereo template");
1072 }
1073
1074 #[test]
1075 fn stereo_filter_same_config_different_write_order() {
1076 let l_form_a = parse("N[C@@H](C)C(=O)O").unwrap();
1083 let l_form_b = parse("C[C@H](N)C(=O)O").unwrap(); let d_form = parse("N[C@H](C)C(=O)O").unwrap(); let smirks = "[N:1][C@@H:2](C)C(=O)O>>[N:1][C@@H:2](C)C(=O)O";
1087
1088 let r_a = run_reactants(smirks, &[&l_form_a]).unwrap();
1089 let r_b = run_reactants(smirks, &[&l_form_b]).unwrap();
1090 let r_d = run_reactants(smirks, &[&d_form]).unwrap();
1091
1092 assert!(!r_a.is_empty(), "L-alanine form A (N-first @@) must match");
1093 assert!(
1094 !r_b.is_empty(),
1095 "L-alanine form B (C-first @, same absolute config) must also match \
1096 — parity-aware comparison required"
1097 );
1098 assert!(r_d.is_empty(), "D-alanine must still be rejected");
1099 }
1100
1101 #[test]
1104 fn smirks_reaction_clears_orphaned_stereo_bonds() {
1105 let mol = parse("C/C=C/C").unwrap(); let results = run_reactants("[C:1]=[C:2]>>[C:1][C:2]", &[&mol]).unwrap();
1111 assert!(!results.is_empty(), "should produce at least one product");
1112 for prod_set in &results {
1113 for prod in prod_set {
1114 for (_, bond) in prod.bonds() {
1115 assert_ne!(
1116 bond.order,
1117 BondOrder::Up,
1118 "stray Up bond in product after C=C→C-C (RDKit #9339)"
1119 );
1120 assert_ne!(
1121 bond.order,
1122 BondOrder::Down,
1123 "stray Down bond in product after C=C→C-C (RDKit #9339)"
1124 );
1125 }
1126 }
1127 }
1128 }
1129
1130 #[test]
1131 fn smirks_preserves_stereo_bonds_adjacent_to_remaining_double() {
1132 use chematic_smiles::canonical_smiles;
1137 for input in ["C/C=C/C", "C/C=C\\C"] {
1138 let mol = parse(input).unwrap();
1139 let results = run_reactants("[C:1]=[C:2]>>[C:1]=[C:2]", &[&mol]).unwrap();
1140 assert!(!results.is_empty());
1141 let expected = canonical_smiles(&mol);
1142 let got = canonical_smiles(&results[0][0]);
1143 assert_eq!(
1144 got, expected,
1145 "identity SMIRKS must preserve exact E/Z geometry for {input}"
1146 );
1147 }
1148 }
1149
1150 #[test]
1155 fn ez_stereo_e_template_matches_e_alkene() {
1156 let e_alkene = parse("C/C=C/C").unwrap();
1159 let smirks = "[C:1]/[C:2]=[C:3]/[C:4]>>[C:1][C:2][C:3][C:4]";
1160 let results = run_reactants(smirks, &[&e_alkene]).unwrap();
1161 assert!(!results.is_empty(), "E-template must match E-alkene");
1162 }
1163
1164 #[test]
1165 fn ez_stereo_e_template_rejects_z_alkene() {
1166 let z_alkene = parse("C/C=C\\C").unwrap();
1169 let smirks = "[C:1]/[C:2]=[C:3]/[C:4]>>[C:1][C:2][C:3][C:4]";
1170 let results = run_reactants(smirks, &[&z_alkene]).unwrap();
1171 assert!(results.is_empty(), "E-template must reject Z-alkene");
1172 }
1173
1174 #[test]
1175 fn ez_stereo_neutral_template_matches_both_geometries() {
1176 let e_alkene = parse("C/C=C/C").unwrap();
1179 let z_alkene = parse("C/C=C\\C").unwrap();
1180 let smirks = "[C:1][C:2]=[C:3][C:4]>>[C:1]";
1181 assert!(
1182 !run_reactants(smirks, &[&e_alkene]).unwrap().is_empty(),
1183 "neutral template must match E-alkene"
1184 );
1185 assert!(
1186 !run_reactants(smirks, &[&z_alkene]).unwrap().is_empty(),
1187 "neutral template must match Z-alkene"
1188 );
1189 }
1190
1191 #[test]
1192 fn ez_stereo_one_sided_template_matches_both_geometries() {
1193 let e_alkene = parse("C/C=C/C").unwrap();
1196 let z_alkene = parse("C/C=C\\C").unwrap();
1197 let smirks = "[C:1]/[C:2]=[C:3][C:4]>>[C:1]";
1198 assert!(
1199 !run_reactants(smirks, &[&e_alkene]).unwrap().is_empty(),
1200 "one-sided template must match E-alkene"
1201 );
1202 assert!(
1203 !run_reactants(smirks, &[&z_alkene]).unwrap().is_empty(),
1204 "one-sided template must match Z-alkene"
1205 );
1206 }
1207
1208 #[test]
1209 fn ez_stereo_retro_wittig_z_matches_z_hexene() {
1210 let z_hexene = parse("CC/C=C\\CC").unwrap();
1217 let e_hexene = parse("CC/C=C/CC").unwrap();
1218 let smirks = "[C:1]/[C:2]=[C:3]\\[C:4]>>[C:1][C:2]=O.[O:3]=[C:4]";
1219 assert!(
1220 !run_reactants(smirks, &[&z_hexene]).unwrap().is_empty(),
1221 "Z-template must match Z-3-hexene"
1222 );
1223 assert!(
1224 run_reactants(smirks, &[&e_hexene]).unwrap().is_empty(),
1225 "Z-template must reject E-3-hexene"
1226 );
1227 }
1228
1229 #[test]
1230 fn ez_stereo_z_template_matches_z_alkene() {
1231 let z_alkene = parse("C/C=C\\C").unwrap();
1234 let e_alkene = parse("C/C=C/C").unwrap();
1235 let smirks = "[C:1]/[C:2]=[C:3]\\[C:4]>>[C:1][C:2][C:3][C:4]";
1236 assert!(
1237 !run_reactants(smirks, &[&z_alkene]).unwrap().is_empty(),
1238 "Z-template must match Z-alkene"
1239 );
1240 assert!(
1241 run_reactants(smirks, &[&e_alkene]).unwrap().is_empty(),
1242 "Z-template must reject E-alkene"
1243 );
1244 }
1245
1246 fn product_canon(smirks: &str, inputs: &[&str]) -> String {
1257 use chematic_smiles::canonical_smiles;
1258 let mols: Vec<Molecule> = inputs.iter().map(|s| parse(s).unwrap()).collect();
1259 let refs: Vec<&Molecule> = mols.iter().collect();
1260 let results = run_reactants(smirks, &refs).unwrap();
1261 assert!(!results.is_empty(), "no product for {smirks} on {inputs:?}");
1262 canonical_smiles(&results[0][0])
1263 }
1264
1265 fn canon(smiles: &str) -> String {
1266 chematic_smiles::canonical_smiles(&parse(smiles).unwrap())
1267 }
1268
1269 fn double_bond_is_e(smiles: &str) -> Option<bool> {
1274 let mol = parse(smiles).unwrap();
1275 let (a1, a2) = mol
1276 .bonds()
1277 .find(|(_, b)| b.order == BondOrder::Double)
1278 .map(|(_, b)| (b.atom1, b.atom2))?;
1279 let sa = ez_stereo_outward(&mol, a1, a2)?;
1280 let sb = ez_stereo_outward(&mol, a2, a1)?;
1281 Some(sa != sb)
1282 }
1283
1284 #[test]
1285 fn issue50_transfer_identity_preserves_e() {
1286 assert_eq!(
1288 product_canon("[C:1]=[C:2]>>[C:1]=[C:2]", &["C/C=C/C"]),
1289 canon("C/C=C/C"),
1290 );
1291 }
1292
1293 #[test]
1294 fn issue50_transfer_identity_preserves_z() {
1295 assert_eq!(
1296 product_canon("[C:1]=[C:2]>>[C:1]=[C:2]", &["C/C=C\\C"]),
1297 canon("C/C=C\\C"),
1298 );
1299 }
1300
1301 #[test]
1302 fn issue50_create_e_from_template() {
1303 assert_eq!(
1305 product_canon("[C:1][C:2][C:3][C:4]>>[C:1]/[C:2]=[C:3]/[C:4]", &["CCCC"]),
1306 canon("C/C=C/C"),
1307 );
1308 }
1309
1310 #[test]
1311 fn issue50_create_z_from_template() {
1312 assert_eq!(
1313 product_canon("[C:1][C:2][C:3][C:4]>>[C:1]/[C:2]=[C:3]\\[C:4]", &["CCCC"]),
1314 canon("C/C=C\\C"),
1315 );
1316 }
1317
1318 #[test]
1319 fn issue50_transfer_remote_reaction_keeps_e() {
1320 let got = product_canon("[CH:1]=O>>[C:1]O", &["CC/C=C/CC=O"]);
1325 assert_eq!(
1326 double_bond_is_e(&got),
1327 Some(true),
1328 "E geometry must survive a remote edit"
1329 );
1330 let got_z = product_canon("[CH:1]=O>>[C:1]O", &["CC/C=C\\CC=O"]);
1332 assert_eq!(
1333 double_bond_is_e(&got_z),
1334 Some(false),
1335 "Z geometry must survive a remote edit"
1336 );
1337 }
1338
1339 #[test]
1340 fn issue50_geometry_is_deterministic() {
1341 let first = product_canon("[C:1]=[C:2]>>[C:1]=[C:2]", &["CC/C=C/CC"]);
1344 for _ in 0..6 {
1345 assert_eq!(
1346 product_canon("[C:1]=[C:2]>>[C:1]=[C:2]", &["CC/C=C/CC"]),
1347 first,
1348 "product geometry must be deterministic across runs"
1349 );
1350 }
1351 }
1352}