Skip to main content

chematic_rxn/
transform.rs

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/// Error type for SMIRKS transformation.
15#[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
40/// Apply a SMIRKS template to input reactant molecules.
41///
42/// Returns all combinations of product sets — one per unique match across all
43/// reactant templates.  Each inner `Vec<Molecule>` contains one product per
44/// product component in the SMIRKS right-hand side.
45///
46/// Returns `Ok(vec![])` when no match is found.
47///
48/// Unmapped atoms attached to a mapped core atom (substituents) are
49/// automatically carried through to the matching product template.
50/// Use [`run_reactants_strict`] to return only mapped atoms.
51pub fn run_reactants(
52    smirks: &str,
53    reactants: &[&Molecule],
54) -> Result<Vec<Vec<Molecule>>, TransformError> {
55    run_reactants_impl(smirks, reactants, true)
56}
57
58/// Like [`run_reactants`] but **does not carry through substituents**.
59///
60/// Only atoms that appear explicitly in the product template (via atom maps or
61/// new template atoms) are included in each product.  Unmapped neighbors of
62/// core atoms are **not** collected via BFS.
63///
64/// Useful when the SMIRKS describes a complete molecule transformation and
65/// you do not want R-group carry-through behaviour.
66pub 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    // Build a QueryMolecule from each reactant template, and record the
89    // atom-map number for each query atom index.
90    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    // Detect whether any reactant template carries @/@@ stereo, so we can apply
102    // the parity-aware post-check after VF2 completes.  Chirality is NOT encoded
103    // into the VF2 query because the raw flag comparison in eval_chirality is
104    // SMILES-write-order-dependent; the correct check requires the full mapping
105    // (see smirks_chirality_ok below).
106    let has_stereo = rxn
107        .reactants
108        .iter()
109        .any(|r| r.atoms().any(|(_, a)| a.chirality != Chirality::None));
110    // Similarly, E/Z double-bond stereo (/ and \) is NOT encoded into the VF2
111    // query; it is checked post-VF2 via smirks_ez_stereo_ok.
112    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    // VF2 match: for each (template_query, input_mol) pair.
118    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    // No products when any template has no match.
125    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        // global_map: atom_map_number → (reactant_mol_idx, matched_AtomIdx)
133        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        // all_template_atoms: every (mol_idx, AtomIdx) matched by any reactant template atom.
143        // Used as BFS walls to prevent substituent collection from crossing into the
144        // template region, and to identify bonds that the product template replaces.
145        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        // Parity-aware chirality post-check.  Runs only when the SMIRKS has @/@@.
153        // This must happen after the complete VF2 mapping is known, because
154        // correct chirality comparison requires the full neighbor permutation.
155        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        // E/Z double-bond stereo post-check.  Runs only when the SMIRKS has /\.
163        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        // Skip product sets that contain any over-valenced atom.
186        if products.iter().all(|p| validate_valence(p).is_empty()) {
187            results.push(products);
188        }
189    }
190
191    Ok(results)
192}
193
194// ---------------------------------------------------------------------------
195// SMIRKS chirality post-check (parity-aware)
196// ---------------------------------------------------------------------------
197
198/// Returns the parity of the permutation P that maps `from_seq` to `to_seq`:
199/// `Some(true)` = even (same chirality sense), `Some(false)` = odd (inverted).
200/// Returns `None` when the sequences differ in length or contain elements that
201/// cannot be aligned (e.g. an unmapped neighbour).
202fn 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    // Build perm[j] = index i in from_seq where from_seq[i] == to_seq[j].
208    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    // Count inversions to determine parity.
214    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)) // true = even = chirality flags must agree for same config
223}
224
225/// Parity-aware stereo check for one (template, reactant, mapping) triple.
226///
227/// For each chiral atom in `tmpl`, maps the template's recorded SMILES stereo
228/// neighbor order through the VF2 `match_map` into the reactant atom-index space,
229/// then computes the parity of the permutation relative to the reactant's recorded
230/// SMILES stereo neighbor order.
231///
232/// - Even parity → chirality flags must agree for same absolute configuration.
233/// - Odd parity  → chirality flags must differ for same absolute configuration.
234///
235/// Returns `true` if all chiral centres are consistent, `false` if any mismatch.
236fn 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        // Template atom's SMILES stereo neighbour order (template atom indices).
248        let Some(tmpl_order) = tmpl.stereo_neighbor_order(AtomIdx(i as u32)) else {
249            continue; // No recorded order — skip this centre.
250        };
251
252        // Corresponding matched reactant atom.
253        let Some(&react_idx) = match_map.get(&i) else {
254            continue; // Template atom not in mapping (shouldn't happen for complete match).
255        };
256
257        let react_atom = reactant.atom(react_idx);
258        if react_atom.chirality == Chirality::None {
259            return false; // Template requires stereo; reactant atom has none.
260        }
261
262        // Map each template stereo-neighbour index to the corresponding reactant atom index.
263        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; // Partial substructure match — cannot verify chirality.
280        }
281
282        // Reactant atom's SMILES stereo neighbour order (reactant atom indices).
283        let Some(react_order) = reactant.stereo_neighbor_order(react_idx) else {
284            // No recorded order in reactant — fall back to raw flag comparison.
285            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; // Alignment failed — skip.
293        };
294
295        // Check: even parity → flags must agree; odd parity → flags must differ.
296        let same_flag = tmpl_atom.chirality == react_atom.chirality;
297        if same_flag != even_parity {
298            return false;
299        }
300    }
301    true
302}
303
304// ---------------------------------------------------------------------------
305// SMIRKS E/Z double-bond stereo post-check
306// ---------------------------------------------------------------------------
307
308/// Returns the "outward" stereo-bond direction from `atom` (one endpoint of a
309/// double bond) toward its Up/Down-annotated substituent, or `None` if no
310/// such bond exists.
311///
312/// E/Z stereo is encoded on the *substituent* bonds adjacent to a C=C, not on
313/// the double bond itself.  Whether the stored bond goes *into* or *out of*
314/// `atom` determines how to read its direction:
315/// - Outgoing (`atom` is `bond.atom1`): direction is as stored.
316/// - Incoming (`atom` is `bond.atom2`): direction is flipped (Up ↔ Down).
317///
318/// The `other` parameter is the opposite endpoint of the double bond; that
319/// bond is skipped so we look only at substituents.
320fn 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; // skip the double bond itself
324        }
325        let bond = mol.bond(bidx);
326        match bond.order {
327            BondOrder::Up | BondOrder::Down => {
328                let outward = if bond.atom1 == atom {
329                    // bond goes FROM atom outward → direction as stored
330                    bond.order
331                } else {
332                    // bond comes INTO atom → flip to get outward direction
333                    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
346/// E/Z stereo post-check for one (template, reactant, mapping) triple.
347///
348/// For each double bond in `tmpl` that has Up/Down substituent bonds on **both**
349/// sides, verify that the corresponding double bond in `reactant` (found via
350/// the VF2 `match_map`) encodes the same E/Z parity.
351///
352/// Parity is determined by comparing the "outward direction" from each end of
353/// the double bond (see [`ez_stereo_outward`]).  Two outward directions that are
354/// equal → same-side (Z/cis); directions that differ → opposite-side (E/trans).
355///
356/// If only one side of a template double bond has a stereo bond (or the
357/// reactant doesn't annotate stereo at all), the constraint is skipped — this
358/// matches the behaviour of SMIRKS templates extracted from rdchiral where a
359/// single-sided annotation is common.
360///
361/// Returns `true` if all constrained double bonds are consistent.
362fn 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        // Outward directions from each end of the template double bond.
375        let sa = ez_stereo_outward(tmpl, ta, tb);
376        let sb = ez_stereo_outward(tmpl, tb, ta);
377
378        // Both sides must be specified to establish an E/Z constraint.
379        let (sa, sb) = match (sa, sb) {
380            (Some(a), Some(b)) => (a, b),
381            _ => continue, // no constraint on this double bond
382        };
383
384        // Map template atoms to reactant atoms.
385        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        // Outward directions from the corresponding reactant double bond ends.
393        let ma = ez_stereo_outward(reactant, ra, rb);
394        let mb = ez_stereo_outward(reactant, rb, ra);
395
396        // If the reactant doesn't encode stereo on either end, skip (don't reject).
397        let (ma, mb) = match (ma, mb) {
398            (Some(a), Some(b)) => (a, b),
399            _ => continue,
400        };
401
402        // Compare E/Z parity: same outward direction = Z, different = E.
403        // If template and reactant disagree, reject this mapping.
404        if (sa == sb) != (ma == mb) {
405            return false;
406        }
407    }
408    true
409}
410
411/// Convert a SMIRKS reactant-template `Molecule` to a `QueryMolecule` for VF2.
412///
413/// Constraints included:
414/// - `AtomicNum` and `Aromatic` (always)
415/// - `Charge` when non-zero
416/// - `HCount` when a bracket atom specifies H > 0 (e.g. `[NH2:1]`)
417///   Zero-H bracket atoms (`[N:1]`) are treated as "any H count" because
418///   the parser returns 0 for both unspecified and explicit-zero H.
419///
420/// Chirality (`@`/`@@`) is NOT encoded into the query here; it is checked after
421/// VF2 matching via `smirks_chirality_ok`, which uses a permutation-parity
422/// comparison so that the same absolute configuration is recognised regardless
423/// of how the reactant molecule was written as SMILES.
424fn 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
484/// Clear Up/Down stereo markers from bonds that have no adjacent double bond.
485///
486/// After a SMIRKS reaction, C=C → C=O conversions can leave stale Up/Down
487/// markers (E/Z direction indicators) on single bonds that are no longer
488/// adjacent to any double bond.  Such orphaned markers produce invalid SMILES
489/// (`/C=O` is nonsensical) and must be demoted to plain Single bonds (RDKit #9339).
490fn 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            // Up/Down is valid only when at least one endpoint has an adjacent
498            // double bond (the one that the Up/Down bond specifies direction for).
499            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    // copy_stereo_from copies stereo_neighbor_order but NOT stereo_groups.
524    // Preserve both by applying each separately.
525    builder.copy_stereo_from(&mol);
526    let mut result = builder.build();
527    // Restore enhanced stereo groups (ABS/OR/AND) that copy_stereo_from omits.
528    result.set_stereo_groups(mol.stereo_groups().to_vec());
529    result
530}
531
532/// Build one product molecule applying full SMIRKS semantics.
533///
534/// 1. Atom-mapped product atoms: copy source atom + override aromatic/charge/H from template.
535/// 2. New product atoms (no map): clone from template.
536/// 3. BFS from core (mapped) atoms through input molecules, collecting substituents
537///    (non-template atoms reachable without crossing template-atom walls).
538/// 4. Add product-template bonds (new/changed bonds).
539/// 5. Carry through bonds from source molecules where at least one endpoint is a substituent.
540fn 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    // template_idx_to_new[i]: new AtomIdx for product template atom i.
550    let mut template_idx_to_new: Vec<Option<AtomIdx>> = vec![None; product_template.atom_count()];
551    // src_to_new: (mol_idx, src_AtomIdx) → new AtomIdx in the product.
552    let mut src_to_new: FxHashMap<(usize, AtomIdx), AtomIdx> = FxHashMap::default();
553
554    // --- Step 1: add product template atoms ---
555    // core_keys: only source atoms that are mapped by THIS product template.
556    // Using global_map.values() (all matched atoms across all templates) would
557    // seed the BFS in Step 2 from atoms belonging to *other* product templates,
558    // causing their substituents to leak into this product (issue #13).
559    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                // Core atom: copy source, then override electronic state from template.
573                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                // Copy H count only when template specifies > 0 (e.g. [NH2:1]).
578                // A bare bracket atom ([O:1], [N:1]) has hydrogen_count=Some(0) which
579                // means "unspecified" in a product context — clear it so implicit
580                // valence rules determine H count (fixes issue #18).
581                new_atom.hydrogen_count = tmpl_atom.hydrogen_count.filter(|&h| h > 0);
582                // Apply product-template chirality when explicitly specified (@/@@).
583                // When the template has Chirality::None:
584                //   • If the non-mapped substituents written into the product template
585                //     have a DIFFERENT element composition from what the reactant
586                //     template matched (substituent replacement, e.g. Br→I), the
587                //     neighbour topology changes and the source stereo is invalid.
588                //   • If the substituent element sets are identical (pass-through,
589                //     e.g. [C:1](F)(Cl)Br >> [C:1](F)(Cl)Br)) preserve the source
590                //     chirality — the common case for remote-reaction SMIRKS.
591                if tmpl_atom.chirality != Chirality::None {
592                    new_atom.chirality = tmpl_atom.chirality;
593                } else {
594                    // Element multiset of non-mapped atoms in the product template
595                    // adjacent to this core atom.
596                    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                        // Element multiset of this core atom's neighbours that were
606                        // matched by the reactant template (heavy atoms only).
607                        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                // Map number not in reactants — new atom from template.
626                let mut new_atom = tmpl_atom.clone();
627                new_atom.atom_map = None;
628                builder.add_atom(new_atom)
629            }
630        } else {
631            // No atom_map — entirely new atom from template.
632            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    // --- Step 2: BFS from core atoms to collect substituents ---
640    // Skipped when carry_substituents = false (run_reactants_strict mode).
641    // Seed visited with all template atoms so BFS cannot cross into the template region.
642    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    // --- Step 3: add product template bonds ---
664    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    // --- Step 4: carry-through bonds from source molecules ---
674    // Bonds where both endpoints are template atoms are replaced or broken by the template;
675    // bonds where at least one endpoint is a substituent are carried through.
676    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            // Preserve the original atom1→atom2 orientation. Up/Down (E/Z)
694            // bond semantics are direction-dependent, so adding the bond with
695            // endpoints swapped relative to the source would flip the geometry.
696            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 any Up/Down stereo markers left on bonds that are no longer adjacent
706    // to a double bond (e.g. after C=C → C=O conversion via SMIRKS).
707    clear_orphaned_stereo_bonds(builder.build())
708}
709
710/// Standard Cartesian product: given `sets[0], sets[1], …`, return all
711/// ordered selections of one element from each set.
712fn 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        // O normally has max valence 2.
811        // SMIRKS adds two carbons to an oxygen that already has one bond → 3 bonds on O → invalid.
812        // CCO: the O is bonded to 1 C (bond_sum=1). Template [O:1]>>[O:1](C)C adds 2 more.
813        let ethanol = parse("CCO").unwrap();
814        let results = run_reactants("[O:1]>>[O:1](C)C", &[&ethanol]).unwrap();
815        // The O that already had 1 bond would get 3 → over-valenced → filtered out.
816        // The only match is the terminal O (1 bond → +2 = 3 bonds, invalid).
817        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        // N with charge +1 can have up to 4 bonds (normal valences [3,5], +1 allows 4).
827        // trimethylamine N(C)(C)C has N with bond_sum=3, charge=0.
828        // Template [N:1]>>[N+:1] just changes charge, keeps 3 bonds → valid.
829        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        // NH3 + H-C(=O)-Cl → H-C(=O)-NH2 (formamide)
849        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        // Methylamine + acetyl chloride → N-methylacetamide (5 heavy atoms)
874        // CH3-NH2 + CH3-C(=O)-Cl → CH3-C(=O)-NH-CH3
875        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        // Issue #13: in diethylamine (CCNCC), the SMIRKS [N:1][C:2]>>[N:1].[C:2]
895        // should cleave the N-C bond and produce:
896        //   product1 [N:1] = N + right ethyl chain  (3 atoms: N, C, C)
897        //   product2 [C:2] = left ethyl fragment     (2 atoms: C, C)
898        //
899        // Before the #13 fix, the BFS for product2 was seeded from BOTH N and C:2,
900        // causing the right ethyl chain (atoms beyond N) to leak into product2
901        // → product2 would have 4 atoms instead of 2.
902        let diethylamine = parse("CCNCC").unwrap(); // C-C-N-C-C, 5 heavy atoms
903        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        // Find a result where product2 ([C:2]) has exactly 2 atoms (ethyl fragment)
910        // — this is only possible when BFS does NOT leak the other ethyl chain.
911        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        // Ethane cleavage: each product should be a single carbon atom.
930        let ethane = parse("CC").unwrap();
931        let results = run_reactants("[C:1][C:2]>>[C:1].[C:2]", &[&ethane]).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    // ── Stereo SMIRKS tests ───────────────────────────────────────────────────
941
942    #[test]
943    fn stereo_preserved_when_template_has_no_spec() {
944        // Product template [C:1] has no chirality → source @@ is preserved via clone.
945        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        // The core C atom is first in the builder (index 0).
950        let core_chirality = prod.atom(AtomIdx(0)).chirality;
951        // Template has None → source Clockwise (@@ in SMILES = Clockwise) is preserved.
952        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        // Product template [C@H:1] has @ (CounterClockwise) → overrides source @@ (Clockwise).
962        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    // ── run_reactants_strict tests ────────────────────────────────────────────
975
976    #[test]
977    fn strict_mode_excludes_substituents() {
978        // Methylamine (NC): in normal mode [N:1]>>[N:1] carries C through as substituent.
979        // In strict mode only N is returned (no C).
980        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        // Ethane cleavage: strict mode gives 1-atom products, same as normal here
998        // (no unmapped substituents on either C).
999        let ethane = parse("CC").unwrap();
1000        let results = run_reactants_strict("[C:1][C:2]>>[C:1].[C:2]", &[&ethane]).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    // ── Issue #18: product bracket notation cleanup ───────────────────────────
1009
1010    #[test]
1011    fn product_removes_bracket_from_bare_bracket_atoms() {
1012        // Issue #18: [O:1] in product template (hydrogen_count=Some(0)) must produce
1013        // clean `O` SMILES, not `[O]`.
1014        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        // [NH2:1] in product template specifies 2H explicitly — must be kept.
1028        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        // With explicit H2, the N must be in brackets.
1034        assert!(
1035            smi.contains("[NH2]"),
1036            "explicit [NH2:1] in product must keep [NH2], got: {smi}"
1037        );
1038    }
1039
1040    // ── Issue #20: SMIRKS stereo filtering ───────────────────────────────────
1041
1042    #[test]
1043    fn stereo_filter_rejects_wrong_enantiomer() {
1044        // Issue #20: SMIRKS with @@ reactant template must match @@ but NOT @ reactant.
1045        let l_ala = parse("N[C@@H](C)C(=O)O").unwrap(); // L-alanine (@@)
1046        let d_ala = parse("N[C@H](C)C(=O)O").unwrap(); // D-alanine (@)
1047
1048        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        // SMIRKS without @/@@ must still match both enantiomers (backward compat).
1065        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        // Parity-aware matching must accept the same absolute configuration
1077        // regardless of SMILES atom write order (the confirmed bug in raw flag
1078        // comparison). Both molecules ARE L-alanine.
1079        //   Form A: N[C@@H](C)C(=O)O  — N written first, stored as Clockwise
1080        //   Form B: C[C@H](N)C(=O)O   — C_methyl first, stored as CounterClockwise
1081        // A raw flag comparison would reject Form B against an @@ template.
1082        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(); // same absolute config, diff write order
1084        let d_form = parse("N[C@H](C)C(=O)O").unwrap(); // D-alanine (opposite config)
1085
1086        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    // ── RDKit #9339: orphaned stereo bonds cleared from products ─────────────
1102
1103    #[test]
1104    fn smirks_reaction_clears_orphaned_stereo_bonds() {
1105        // (E)-2-butene C/C=C/C has Up/Down bonds adjacent to the C=C double bond.
1106        // After SMIRKS [C:1]=[C:2]>>[C:1][C:2] the double bond is reduced to a
1107        // single bond.  The Up/Down single bonds on C0-C1 and C2-C3 are no longer
1108        // adjacent to ANY double bond → orphaned → must be cleared (RDKit PR #9339).
1109        let mol = parse("C/C=C/C").unwrap(); // (E)-2-butene
1110        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        // If the double bond is kept unchanged, the *exact* E/Z geometry must be
1133        // preserved — not merely "some Up/Down bond survives" (which passed even
1134        // while the geometry was flipping E↔Z, issue #50). Verify by comparing the
1135        // product's canonical SMILES to the canonical SMILES of the known input.
1136        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    // -----------------------------------------------------------------------
1151    // E/Z double-bond stereo filtering (issue #21)
1152    // -----------------------------------------------------------------------
1153
1154    #[test]
1155    fn ez_stereo_e_template_matches_e_alkene() {
1156        // Template specifies E: [C:1]/[C:2]=[C:3]/[C:4]
1157        // E-2-butene (C/C=C/C) should produce 1 result.
1158        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        // Template specifies E: [C:1]/[C:2]=[C:3]/[C:4]
1167        // Z-2-butene (C/C=C\C) should produce 0 results.
1168        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        // Template without stereo: [C:1][C:2]=[C:3][C:4]>>[C:1]
1177        // Both E and Z alkenes should match.
1178        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        // Single-sided stereo bond in template: [C:1]/[C:2]=[C:3][C:4]
1194        // Without both sides specified, E/Z is ambiguous → no filtering.
1195        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        // Retro-Wittig (Z-alkene → two carbonyls).
1211        // SMIRKS: [C:1]/[C:2]=[C:3]\[C:4]>>[C:1][C:2]=O.[O:3]=[C:4]
1212        //   reads: C:2 and C:3 on OPPOSITE sides (E/trans for those two)
1213        //   but the substituents C:1 and C:4 are on the SAME side (Z-selectivity)
1214        //
1215        // Z-3-hexene (CC/C=C\CC) should match; E-3-hexene (CC/C=C/CC) should not.
1216        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        // Template specifies Z: [C:1]/[C:2]=[C:3]\[C:4]
1232        // Z-2-butene (C/C=C\C) should match.
1233        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    // -----------------------------------------------------------------------
1247    // E/Z stereo transfer & creation in products (issue #50)
1248    //
1249    // Geometry is verified by comparing the product's canonical SMILES to the
1250    // canonical SMILES of a reference molecule of known E/Z — exact, not "some
1251    // Up/Down survives". (CipCode-based verification lives in the Python tests;
1252    // chematic-rxn cannot depend on chematic-chem without a dependency cycle.)
1253    // -----------------------------------------------------------------------
1254
1255    /// Canonical SMILES of the single product of `smirks` applied to `inputs`.
1256    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    /// Writer-invariant E/Z of the first C=C double bond: `Some(true)` = E,
1270    /// `Some(false)` = Z, `None` = no specified geometry. Reuses the crate's
1271    /// own `ez_stereo_outward` (same convention as the #21 filter): equal
1272    /// outward directions → Z, opposite → E.
1273    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        // Identity SMIRKS on E-2-butene must yield an E product (was Z before Fix A).
1287        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        // Product template introduces an E double bond from a saturated chain.
1304        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        // Reaction at a remote site (aldehyde→alcohol) must not disturb the
1321        // E geometry of a carried-through alkene. The canonical writer may pick
1322        // `/C=C/` or `\C=C\` (both E) depending on traversal, so assert geometry
1323        // directly rather than the exact string.
1324        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        // And a Z input stays Z.
1331        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        // The pre-fix bug was nondeterministic (FxHashMap iteration order).
1342        // The same transform must give the same geometry on every run.
1343        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}