Skip to main content

chematic_core/
molecule.rs

1//! Molecule graph: atoms, bonds, and adjacency list.
2
3use crate::atom::Atom;
4use crate::bond::{BondEntry, BondOrder};
5use crate::element::Element;
6use crate::stereo_group::StereoGroup;
7
8/// Newtype index for an atom in a Molecule.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
10pub struct AtomIdx(pub u32);
11
12/// Newtype index for a bond in a Molecule.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct BondIdx(pub u32);
15
16/// Error types for molecule construction.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum MolError {
19    /// Atom index out of range.
20    InvalidAtomIdx(AtomIdx),
21    /// Duplicate bond between the same pair of atoms.
22    DuplicateBond(AtomIdx, AtomIdx),
23}
24
25impl core::fmt::Display for MolError {
26    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
27        match self {
28            Self::InvalidAtomIdx(idx) => write!(f, "invalid atom index: {}", idx.0),
29            Self::DuplicateBond(a, b) => {
30                write!(f, "duplicate bond between atoms {} and {}", a.0, b.0)
31            }
32        }
33    }
34}
35
36impl std::error::Error for MolError {}
37
38/// An immutable molecular graph built via [`MoleculeBuilder`].
39///
40/// Representation: atom list + bond list + per-atom adjacency list.
41/// No external graph library is used; all graph traversal is domain-aware.
42/// Sentinel used in `stereo_neighbor_order` to represent the implicit H in a bracket atom.
43pub const STEREO_H_SENTINEL: u32 = u32::MAX;
44
45#[derive(Clone)]
46pub struct Molecule {
47    atoms: Vec<Atom>,
48    bonds: Vec<BondEntry>,
49    /// adjacency[atom_idx] = list of (neighbor_atom_idx, bond_idx)
50    adjacency: Vec<Vec<(AtomIdx, BondIdx)>>,
51    /// Enhanced stereo groups (ChemDraw V3000 Absolute / Or / And).
52    stereo_groups: Vec<StereoGroup>,
53    /// SMILES-text-order neighbor sequence for chiral atoms.
54    ///
55    /// Keyed by atom index.  Each value lists the atom indices of neighbors in
56    /// the order they appeared in the SMILES string (including ring-closure
57    /// partners), with [`STEREO_H_SENTINEL`] (`u32::MAX`) standing in for the
58    /// implicit bracket H.  Populated by the SMILES parser; absent for atoms
59    /// not parsed from SMILES or without recorded stereo.
60    stereo_neighbor_order: std::collections::HashMap<u32, Vec<u32>>,
61    /// Directional (`/`, `\`) bond marker, stashed for bonds whose `order` was
62    /// overwritten to `Aromatic` (e.g. an exocyclic C=N adjacent to an
63    /// aromatic ring atom — `order` must stay `Aromatic` for SMARTS `:a`
64    /// matching, but the E/Z direction would otherwise be lost). Keyed by
65    /// bond index; value is `BondOrder::Up` or `BondOrder::Down`. Absent for
66    /// bonds whose direction is already carried directly by `order`.
67    bond_directions: std::collections::HashMap<u32, BondOrder>,
68    /// Parser-side anchor endpoint for a directional marker stashed on an
69    /// aromatic bond. This preserves which endpoint introduced the marker
70    /// when atom/bond indices are rebuilt during canonical search.
71    bond_direction_anchors: std::collections::HashMap<u32, AtomIdx>,
72}
73
74impl Molecule {
75    /// Number of heavy atoms (does not count implicit H).
76    pub fn atom_count(&self) -> usize {
77        self.atoms.len()
78    }
79
80    /// Number of bonds (edges).
81    pub fn bond_count(&self) -> usize {
82        self.bonds.len()
83    }
84
85    /// Borrow atom by index.
86    ///
87    /// # Panics
88    /// Panics if `idx` is out of range (should not happen with indices from this molecule).
89    ///
90    /// For a non-panicking variant, use [`Self::atom_opt`].
91    pub fn atom(&self, idx: AtomIdx) -> &Atom {
92        let i = idx.0 as usize;
93        if i >= self.atoms.len() {
94            panic!("atom index out of range");
95        }
96        &self.atoms[i]
97    }
98
99    /// Borrow atom by index, returning `None` if out of range.
100    pub fn atom_opt(&self, idx: AtomIdx) -> Option<&Atom> {
101        let i = idx.0 as usize;
102        if i < self.atoms.len() {
103            Some(&self.atoms[i])
104        } else {
105            None
106        }
107    }
108
109    /// Borrow bond by index.
110    ///
111    /// # Panics
112    /// Panics if `idx` is out of range (should not happen with indices from this molecule).
113    ///
114    /// For a non-panicking variant, use [`Self::bond_opt`].
115    pub fn bond(&self, idx: BondIdx) -> &BondEntry {
116        let i = idx.0 as usize;
117        if i >= self.bonds.len() {
118            panic!("bond index out of range");
119        }
120        &self.bonds[i]
121    }
122
123    /// Borrow bond by index, returning `None` if out of range.
124    pub fn bond_opt(&self, idx: BondIdx) -> Option<&BondEntry> {
125        let i = idx.0 as usize;
126        if i < self.bonds.len() {
127            Some(&self.bonds[i])
128        } else {
129            None
130        }
131    }
132
133    /// Iterate over all atoms as `(AtomIdx, &Atom)`.
134    pub fn atoms(&self) -> impl Iterator<Item = (AtomIdx, &Atom)> {
135        self.atoms
136            .iter()
137            .enumerate()
138            .map(|(i, a)| (AtomIdx(i as u32), a))
139    }
140
141    /// Iterate over all bonds as `(BondIdx, &BondEntry)`.
142    pub fn bonds(&self) -> impl Iterator<Item = (BondIdx, &BondEntry)> {
143        self.bonds
144            .iter()
145            .enumerate()
146            .map(|(i, b)| (BondIdx(i as u32), b))
147    }
148
149    /// Iterate over neighbors of `idx` as `(neighbor_atom_idx, bond_idx)`.
150    ///
151    /// # Panics
152    /// Panics if `idx` is out of range (should not happen with indices from this molecule).
153    ///
154    /// For a non-panicking variant, use [`Self::neighbors_opt`].
155    pub fn neighbors(&self, idx: AtomIdx) -> impl Iterator<Item = (AtomIdx, BondIdx)> + '_ {
156        let i = idx.0 as usize;
157        if i >= self.adjacency.len() {
158            // Keep the panic diagnostic free of input-derived values. Callers
159            // handling untrusted atom indices should use `degree_opt`.
160            panic!("atom index out of range");
161        }
162        self.adjacency[i].iter().copied()
163    }
164
165    /// Iterate over neighbors of `idx` as `(neighbor_atom_idx, bond_idx)`, returning `None` if out of range.
166    pub fn neighbors_opt(&self, idx: AtomIdx) -> Option<Vec<(AtomIdx, BondIdx)>> {
167        let i = idx.0 as usize;
168        if i < self.adjacency.len() {
169            Some(self.adjacency[i].to_vec())
170        } else {
171            None
172        }
173    }
174
175    /// Degree (number of connected bonds) of atom `idx`.
176    ///
177    /// # Panics
178    /// Panics if `idx` is out of range (should not happen with indices from this molecule).
179    ///
180    /// For a non-panicking variant, use [`Self::degree_opt`].
181    pub fn degree(&self, idx: AtomIdx) -> usize {
182        let i = idx.0 as usize;
183        if i >= self.adjacency.len() {
184            panic!("atom index out of range");
185        }
186        self.adjacency[i].len()
187    }
188
189    /// Degree (number of connected bonds) of atom `idx`, returning `None` if out of range.
190    pub fn degree_opt(&self, idx: AtomIdx) -> Option<usize> {
191        let i = idx.0 as usize;
192        if i < self.adjacency.len() {
193            Some(self.adjacency[i].len())
194        } else {
195            None
196        }
197    }
198
199    /// Return the bond between `a` and `b`, or `None` if not connected or indices are out of bounds.
200    pub fn bond_between(&self, a: AtomIdx, b: AtomIdx) -> Option<(BondIdx, &BondEntry)> {
201        let a_idx = a.0 as usize;
202        let b_idx = b.0 as usize;
203        if a_idx >= self.adjacency.len() || b_idx >= self.atoms.len() {
204            return None;
205        }
206        self.adjacency[a_idx]
207            .iter()
208            .find(|&&(nb, _)| nb == b)
209            .and_then(|&(_, bidx)| {
210                let bond_idx = bidx.0 as usize;
211                if bond_idx < self.bonds.len() {
212                    Some((bidx, &self.bonds[bond_idx]))
213                } else {
214                    None
215                }
216            })
217    }
218
219    /// Molecular formula as a Hill-order string (C first, H second, then alphabetical).
220    pub fn formula(&self) -> String {
221        use std::collections::BTreeMap;
222        let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
223        for (_, atom) in self.atoms() {
224            *counts.entry(atom.element.symbol()).or_insert(0) += 1;
225        }
226        let mut result = Self::format_hill_order_formula(&counts);
227        let total_charge: i32 = self.atoms().map(|(_, a)| a.charge as i32).sum();
228        match total_charge {
229            0 => {}
230            1 => result.push('+'),
231            -1 => result.push('-'),
232            n if n > 0 => result.push_str(&format!("+{n}")),
233            n => result.push_str(&n.to_string()),
234        }
235        result
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Immutable update methods (functional-style editing)
241// ---------------------------------------------------------------------------
242
243impl Molecule {
244    /// Format element counts in Hill order: C, H, then alphabetically.
245    fn format_hill_order_formula(counts: &std::collections::BTreeMap<&str, u32>) -> String {
246        let mut counts = counts.clone();
247        let mut result = String::new();
248        let push_count = |sym: &str, n: u32, out: &mut String| {
249            out.push_str(sym);
250            if n > 1 {
251                out.push_str(&n.to_string());
252            }
253        };
254        if let Some(c) = counts.remove("C") {
255            push_count("C", c, &mut result);
256        }
257        if let Some(h) = counts.remove("H")
258            && h > 0
259        {
260            push_count("H", h, &mut result);
261        }
262        for (sym, count) in &counts {
263            push_count(sym, *count, &mut result);
264        }
265        result
266    }
267
268    /// Return a new `Molecule` with one extra atom appended, along with the
269    /// index that the new atom will have in the returned molecule.
270    pub fn with_atom_added(&self, atom: Atom) -> (Molecule, AtomIdx) {
271        let mut builder = MoleculeBuilder::from_molecule(self);
272        let new_idx = builder.add_atom(atom);
273        (builder.build(), new_idx)
274    }
275
276    /// Return a new `Molecule` with one extra bond added, along with the index
277    /// of the newly added bond in the returned molecule.
278    ///
279    /// Returns `Err` if `a == b` or the bond already exists (same semantics as
280    /// [`MoleculeBuilder::add_bond`]).
281    pub fn with_bond_added(
282        &self,
283        a: AtomIdx,
284        b: AtomIdx,
285        order: BondOrder,
286    ) -> Result<(Molecule, BondIdx), MolError> {
287        let mut builder = MoleculeBuilder::from_molecule(self);
288        let bond_idx = builder.add_bond(a, b, order)?;
289        Ok((builder.build(), bond_idx))
290    }
291
292    /// Return a new `Molecule` with the formal charge of atom `idx` changed.
293    pub fn with_atom_charge(&self, idx: AtomIdx, charge: i8) -> Molecule {
294        let mut builder = MoleculeBuilder::new();
295        for (aidx, atom) in self.atoms() {
296            let mut a = atom.clone();
297            if aidx == idx {
298                a.charge = charge;
299            }
300            builder.add_atom(a);
301        }
302        for (_, bond) in self.bonds() {
303            let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
304        }
305        builder.copy_stereo_from(self);
306        builder.copy_bond_directions_from(self);
307        builder.build()
308    }
309
310    /// Return a new `Molecule` with the element of atom `idx` changed.
311    ///
312    /// Chirality and hydrogen count are reset to `None` when the element
313    /// changes, since those properties are element-specific.
314    pub fn with_atom_element(&self, idx: AtomIdx, el: Element) -> Molecule {
315        let mut builder = MoleculeBuilder::new();
316        for (aidx, atom) in self.atoms() {
317            let mut a = atom.clone();
318            if aidx == idx {
319                a.element = el;
320                // Reset element-specific fields so valence stays consistent.
321                a.chirality = crate::atom::Chirality::None;
322                a.hydrogen_count = None;
323                a.aromatic = false;
324            }
325            builder.add_atom(a);
326        }
327        for (_, bond) in self.bonds() {
328            let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
329        }
330        builder.copy_stereo_from(self);
331        builder.copy_bond_directions_from(self);
332        // Chirality was cleared for the changed atom; remove its stereo order too.
333        builder.clear_stereo_neighbor_order(idx);
334        builder.build()
335    }
336
337    /// Return a new `Molecule` with atom `idx` and all bonds involving it
338    /// removed.  Atom indices of survivors shift down past the removed slot.
339    ///
340    /// The returned tuple also includes a mapping from **old** `AtomIdx` to
341    /// **new** `AtomIdx` (indices that fall below `idx` are unchanged; indices
342    /// above `idx` decrease by 1).
343    pub fn with_atom_removed(&self, idx: AtomIdx) -> (Molecule, Vec<Option<AtomIdx>>) {
344        let n = self.atom_count();
345        let removed = idx.0 as usize;
346
347        // Build old→new index table.
348        let mut remap: Vec<Option<AtomIdx>> = vec![None; n];
349        let mut new_pos = 0u32;
350        for (old, slot) in remap.iter_mut().enumerate() {
351            if old == removed {
352                continue;
353            }
354            *slot = Some(AtomIdx(new_pos));
355            new_pos += 1;
356        }
357
358        let mut builder = MoleculeBuilder::new();
359        for (aidx, atom) in self.atoms() {
360            if aidx == idx {
361                continue;
362            }
363            builder.add_atom(atom.clone());
364        }
365        // Track old→new BOND index so `bond_directions` (keyed by bond index,
366        // not atom index) can be remapped the same way `stereo_neighbor_order`
367        // is remapped below — a prior version of this method dropped
368        // `bond_directions` entirely on atom removal (silent loss, not
369        // misattribution, but still a real gap the E/Z-direction side
370        // channel needs closed: see `Molecule::bond_direction`).
371        let mut bond_remap: Vec<Option<BondIdx>> = vec![None; self.bonds.len()];
372        for (old_bidx, bond) in self.bonds() {
373            if bond.atom1 == idx || bond.atom2 == idx {
374                continue;
375            }
376            if let (Some(a1), Some(a2)) =
377                (remap[bond.atom1.0 as usize], remap[bond.atom2.0 as usize])
378                && let Ok(new_bidx) = builder.add_bond(a1, a2, bond.order)
379            {
380                bond_remap[old_bidx.0 as usize] = Some(new_bidx);
381            }
382        }
383        // Remap stereo neighbor order: drop removed atom's entry, remap neighbor indices.
384        for (old_key, order) in &self.stereo_neighbor_order {
385            let old_atom = *old_key as usize;
386            if old_atom == removed {
387                continue; // removed atom's stereo is gone
388            }
389            if let Some(Some(new_key)) = remap.get(old_atom) {
390                let new_order: Vec<u32> = order
391                    .iter()
392                    .filter_map(|&v| {
393                        if v == STEREO_H_SENTINEL {
394                            Some(STEREO_H_SENTINEL)
395                        } else if v as usize == removed {
396                            None // neighbor was the removed atom — stereo is now invalid
397                        } else {
398                            remap.get(v as usize).and_then(|r| r.map(|a| a.0))
399                        }
400                    })
401                    .collect();
402                builder.set_stereo_neighbor_order(*new_key, new_order);
403            }
404        }
405        for (old_bidx, direction) in &self.bond_directions {
406            if let Some(Some(new_bidx)) = bond_remap.get(*old_bidx as usize) {
407                builder.set_bond_direction(*new_bidx, *direction);
408            }
409        }
410        (builder.build(), remap)
411    }
412
413    /// Implicit hydrogen count for atom `idx` based on valence rules.
414    ///
415    /// Delegates to [`crate::valence::implicit_hcount`].
416    pub fn implicit_hydrogen_count(&self, idx: AtomIdx) -> u8 {
417        crate::valence::implicit_hcount(self, idx)
418    }
419
420    /// Hill-order molecular formula including implicit hydrogens.
421    ///
422    /// Unlike [`Self::formula`] (which counts only explicit heavy atoms),
423    /// this method adds the implicit H count for every atom so the result
424    /// reflects the true molecular composition (e.g. methane → "CH4").
425    pub fn total_formula(&self) -> String {
426        use std::collections::BTreeMap;
427        let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
428        let mut implicit_h: u32 = 0;
429        for (aidx, atom) in self.atoms() {
430            *counts.entry(atom.element.symbol()).or_insert(0) += 1;
431            implicit_h += crate::valence::implicit_hcount(self, aidx) as u32;
432        }
433        *counts.entry("H").or_insert(0) += implicit_h;
434        Self::format_hill_order_formula(&counts)
435    }
436
437    /// Hill-order molecular formula with isotope labels.
438    ///
439    /// Like [`Self::formula`] but prefixes each element symbol with its
440    /// isotope number when `atom.isotope` is `Some(n)`.
441    /// Example: a molecule with one `¹³C` and one `O` → `"¹³CO"`.
442    pub fn formula_with_isotopes(&self) -> String {
443        use std::collections::BTreeMap;
444        // Collect (isotope_prefix + symbol) counts, heavy atoms only.
445        let mut counts: BTreeMap<String, u32> = BTreeMap::new();
446        let mut has_carbon = false;
447        let mut has_explicit_h = false;
448        for (_, atom) in self.atoms() {
449            let sym = atom.element.symbol();
450            let key = match atom.isotope {
451                Some(n) => format!("{n}{sym}"),
452                None => sym.to_string(),
453            };
454            if sym == "C" && atom.isotope.is_none() {
455                has_carbon = true;
456            }
457            if sym == "H" {
458                has_explicit_h = true;
459            }
460            *counts.entry(key).or_insert(0) += 1;
461        }
462
463        let push_count = |key: &str, n: u32, out: &mut String| {
464            out.push_str(key);
465            if n > 1 {
466                out.push_str(&n.to_string());
467            }
468        };
469
470        let mut result = String::new();
471        // Hill order: C first (if unlabelled C present), then H, then rest alphabetically.
472        if has_carbon && let Some(c) = counts.remove("C") {
473            push_count("C", c, &mut result);
474        }
475        if has_explicit_h && let Some(h) = counts.remove("H") {
476            push_count("H", h, &mut result);
477        }
478        for (key, count) in &counts {
479            push_count(key, *count, &mut result);
480        }
481        result
482    }
483
484    /// Return a new `Molecule` with atom `idx`'s aromatic flag changed.
485    pub fn with_atom_aromatic(&self, idx: AtomIdx, aromatic: bool) -> Molecule {
486        let mut builder = MoleculeBuilder::new();
487        for (aidx, atom) in self.atoms() {
488            let mut a = atom.clone();
489            if aidx == idx {
490                a.aromatic = aromatic;
491            }
492            builder.add_atom(a);
493        }
494        for (_, bond) in self.bonds() {
495            let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
496        }
497        builder.copy_stereo_from(self);
498        builder.copy_bond_directions_from(self);
499        builder.build()
500    }
501
502    /// Return a new `Molecule` with bond `idx`'s order changed.
503    pub fn with_bond_order(&self, idx: BondIdx, order: BondOrder) -> Molecule {
504        let mut builder = MoleculeBuilder::new();
505        for (_, atom) in self.atoms() {
506            builder.add_atom(atom.clone());
507        }
508        for (bidx, bond) in self.bonds() {
509            let o = if bidx == idx { order } else { bond.order };
510            let _ = builder.add_bond(bond.atom1, bond.atom2, o);
511        }
512        builder.copy_stereo_from(self);
513        builder.copy_bond_directions_from(self);
514        builder.build()
515    }
516
517    /// Return a new `Molecule` with bond `idx` removed.
518    ///
519    /// Atom indices are unchanged.  Bond indices of survivors shift down, so
520    /// `bond_directions` (keyed by bond index) is remapped bond-by-bond
521    /// rather than copied wholesale — `copy_bond_directions_from` would
522    /// misattribute directions to the wrong bond for every survivor after
523    /// the removed one.
524    pub fn with_bond_removed(&self, idx: BondIdx) -> Molecule {
525        let mut builder = MoleculeBuilder::new();
526        for (_, atom) in self.atoms() {
527            builder.add_atom(atom.clone());
528        }
529        for (bidx, bond) in self.bonds() {
530            if bidx == idx {
531                continue;
532            }
533            if let Ok(new_bidx) = builder.add_bond(bond.atom1, bond.atom2, bond.order)
534                && let Some(direction) = self.bond_direction(bidx)
535            {
536                builder.set_bond_direction(new_bidx, direction);
537            }
538        }
539        builder.copy_stereo_from(self);
540        builder.build()
541    }
542}
543
544// ---------------------------------------------------------------------------
545// In-place mutation methods
546// ---------------------------------------------------------------------------
547
548impl Molecule {
549    /// Append a new atom and return its index.
550    pub fn add_atom(&mut self, atom: Atom) -> AtomIdx {
551        let idx = AtomIdx(self.atoms.len() as u32);
552        self.atoms.push(atom);
553        self.adjacency.push(vec![]);
554        idx
555    }
556
557    /// Remove atom `idx` and all bonds involving it.
558    ///
559    /// Returns a remapping table: `remap[old_idx]` gives the new `AtomIdx`
560    /// for surviving atoms, or `None` for the removed atom.  Atom indices
561    /// of atoms after the removed slot shift down by 1.
562    pub fn remove_atom(&mut self, idx: AtomIdx) -> Vec<Option<AtomIdx>> {
563        let n = self.atoms.len();
564        let removed = idx.0 as usize;
565
566        let mut remap: Vec<Option<AtomIdx>> = vec![None; n];
567        let mut new_pos = 0u32;
568        for (old, slot) in remap.iter_mut().enumerate() {
569            if old == removed {
570                continue;
571            }
572            *slot = Some(AtomIdx(new_pos));
573            new_pos += 1;
574        }
575
576        // Drain the single slot instead of calling `Vec::remove` directly.
577        // Besides making the order-preserving intent explicit, this avoids a
578        // CodeQL `rust/cleartext-logging` false positive on the PyO3 RWMol
579        // mutable-reference path (the analyzer models that method name as a
580        // logging sink even though this is only an in-memory graph edit).
581        let _ = self.atoms.drain(removed..=removed).next();
582
583        // Keep only bonds not involving the removed atom; remap endpoints and
584        // track each surviving bond's new index so `bond_directions` (keyed
585        // by bond index) can be remapped the same way as `stereo_neighbor_order`
586        // is remapped by atom index below.
587        let mut new_bonds: Vec<BondEntry> = Vec::new();
588        let mut bond_remap: Vec<Option<u32>> = vec![None; self.bonds.len()];
589        for (old_bidx, bond) in self.bonds.iter().enumerate() {
590            if bond.atom1 == idx || bond.atom2 == idx {
591                continue;
592            }
593            if let (Some(a1), Some(a2)) =
594                (remap[bond.atom1.0 as usize], remap[bond.atom2.0 as usize])
595            {
596                bond_remap[old_bidx] = Some(new_bonds.len() as u32);
597                new_bonds.push(BondEntry {
598                    atom1: a1,
599                    atom2: a2,
600                    order: bond.order,
601                });
602            }
603        }
604        self.bonds = new_bonds;
605
606        // Remap bond directions in-place, same shift as bond_remap above.
607        let old_bond_directions = std::mem::take(&mut self.bond_directions);
608        for (old_key, direction) in old_bond_directions {
609            if let Some(Some(new_key)) = bond_remap.get(old_key as usize) {
610                self.bond_directions.insert(*new_key, direction);
611            }
612        }
613        let old_bond_anchors = std::mem::take(&mut self.bond_direction_anchors);
614        for (old_key, atom) in old_bond_anchors {
615            if let (Some(Some(new_key)), Some(new_atom)) = (
616                bond_remap.get(old_key as usize),
617                remap.get(atom.0 as usize).and_then(|r| *r),
618            ) {
619                self.bond_direction_anchors.insert(*new_key, new_atom);
620            }
621        }
622
623        // Rebuild adjacency from scratch.
624        let new_n = self.atoms.len();
625        self.adjacency = vec![vec![]; new_n];
626        for (bidx, bond) in self.bonds.iter().enumerate() {
627            let bi = BondIdx(bidx as u32);
628            self.adjacency[bond.atom1.0 as usize].push((bond.atom2, bi));
629            self.adjacency[bond.atom2.0 as usize].push((bond.atom1, bi));
630        }
631
632        // Remap stereo neighbor order in-place.
633        let old_stereo = std::mem::take(&mut self.stereo_neighbor_order);
634        for (old_key, order) in old_stereo {
635            let old_atom = old_key as usize;
636            if old_atom == removed {
637                continue;
638            }
639            if let Some(Some(new_key)) = remap.get(old_atom) {
640                let new_order: Vec<u32> = order
641                    .iter()
642                    .filter_map(|&v| {
643                        if v == STEREO_H_SENTINEL {
644                            Some(STEREO_H_SENTINEL)
645                        } else if v as usize == removed {
646                            None
647                        } else {
648                            remap.get(v as usize).and_then(|r| r.map(|a| a.0))
649                        }
650                    })
651                    .collect();
652                self.stereo_neighbor_order.insert(new_key.0, new_order);
653            }
654        }
655
656        remap
657    }
658
659    /// Add a bond between `a` and `b` with the given `order`.
660    ///
661    /// Returns `Err` if `a == b` or the bond already exists.
662    pub fn add_bond(
663        &mut self,
664        a: AtomIdx,
665        b: AtomIdx,
666        order: BondOrder,
667    ) -> Result<BondIdx, MolError> {
668        let n = self.atoms.len() as u32;
669        if a.0 >= n {
670            return Err(MolError::InvalidAtomIdx(a));
671        }
672        if b.0 >= n {
673            return Err(MolError::InvalidAtomIdx(b));
674        }
675        if self.adjacency[a.0 as usize].iter().any(|&(nb, _)| nb == b) {
676            return Err(MolError::DuplicateBond(a, b));
677        }
678        let bidx = BondIdx(self.bonds.len() as u32);
679        self.bonds.push(BondEntry {
680            atom1: a,
681            atom2: b,
682            order,
683        });
684        self.adjacency[a.0 as usize].push((b, bidx));
685        self.adjacency[b.0 as usize].push((a, bidx));
686        Ok(bidx)
687    }
688
689    /// Remove bond `idx`.  Atom indices are unchanged; bond indices of
690    /// surviving bonds shift down past the removed slot.
691    pub fn remove_bond(&mut self, idx: BondIdx) {
692        let removed = idx.0 as usize;
693        if removed >= self.bonds.len() {
694            return;
695        }
696        self.bonds.remove(removed);
697        // Remap `bond_directions` (keyed by bond index) for the same index
698        // shift the bond list itself just underwent: entries below `removed`
699        // are unchanged, the removed bond's own entry (if any) is dropped,
700        // and entries above `removed` shift down by 1. A prior version of
701        // this method left `bond_directions` untouched, which silently
702        // MISATTRIBUTED a direction to whichever bond happened to shift into
703        // the vacated slot -- worse than losing it, since it looks like valid
704        // data pointing at the wrong physical bond.
705        let old_bond_directions = std::mem::take(&mut self.bond_directions);
706        for (old_key, direction) in old_bond_directions {
707            let old = old_key as usize;
708            match old.cmp(&removed) {
709                std::cmp::Ordering::Less => {
710                    self.bond_directions.insert(old_key, direction);
711                }
712                std::cmp::Ordering::Equal => {} // this bond itself was removed
713                std::cmp::Ordering::Greater => {
714                    self.bond_directions.insert(old_key - 1, direction);
715                }
716            }
717        }
718        let old_bond_anchors = std::mem::take(&mut self.bond_direction_anchors);
719        for (old_key, atom) in old_bond_anchors {
720            let old = old_key as usize;
721            let new_key = match old.cmp(&removed) {
722                std::cmp::Ordering::Less => old_key,
723                std::cmp::Ordering::Equal => continue,
724                std::cmp::Ordering::Greater => old_key - 1,
725            };
726            self.bond_direction_anchors.insert(new_key, atom);
727        }
728        // Rebuild adjacency with renumbered bond indices.
729        let n = self.atoms.len();
730        self.adjacency = vec![vec![]; n];
731        for (bidx, bond) in self.bonds.iter().enumerate() {
732            let bi = BondIdx(bidx as u32);
733            self.adjacency[bond.atom1.0 as usize].push((bond.atom2, bi));
734            self.adjacency[bond.atom2.0 as usize].push((bond.atom1, bi));
735        }
736    }
737
738    /// Set the formal charge of atom `idx` in-place.
739    pub fn set_charge(&mut self, idx: AtomIdx, charge: i8) {
740        self.atoms[idx.0 as usize].charge = charge;
741    }
742
743    /// Set the isotope label of atom `idx` in-place. `None` = natural
744    /// isotope abundance (no label).
745    pub fn set_isotope(&mut self, idx: AtomIdx, isotope: Option<u16>) {
746        self.atoms[idx.0 as usize].isotope = isotope;
747    }
748
749    /// Set the element of atom `idx` in-place.
750    ///
751    /// Chirality and hydrogen count are reset (element-specific properties).
752    pub fn set_element(&mut self, idx: AtomIdx, el: Element) {
753        let a = &mut self.atoms[idx.0 as usize];
754        a.element = el;
755        a.chirality = crate::atom::Chirality::None;
756        a.hydrogen_count = None;
757        a.aromatic = false;
758    }
759
760    /// Set the CIP stereo code of atom `idx` in-place.
761    pub fn set_cip_code(&mut self, idx: AtomIdx, code: Option<crate::atom::CipCode>) {
762        self.atoms[idx.0 as usize].cip_code = code;
763    }
764
765    /// Set the tetrahedral chirality (`@`/`@@`) of atom `idx` in-place.
766    pub fn set_chirality(&mut self, idx: AtomIdx, chirality: crate::atom::Chirality) {
767        self.atoms[idx.0 as usize].chirality = chirality;
768    }
769
770    /// Set the bond order of bond `idx` in-place. Endpoints (`atom1`/
771    /// `atom2`) and adjacency are untouched -- order alone doesn't affect
772    /// connectivity, so unlike [`Self::remove_bond`] + [`Self::add_bond`],
773    /// this never perturbs any atom's `neighbors()` iteration order (some
774    /// callers, e.g. 2D-wedge tetrahedral-parity perception, rely on that
775    /// order to pick an "apex" neighbor -- a remove+re-add would silently
776    /// change which neighbor that is).
777    pub fn set_bond_order(&mut self, idx: BondIdx, order: BondOrder) {
778        self.bonds[idx.0 as usize].order = order;
779    }
780
781    /// Return the enhanced stereo groups attached to this molecule.
782    pub fn stereo_groups(&self) -> &[StereoGroup] {
783        &self.stereo_groups
784    }
785
786    /// Replace the stereo group list in-place.
787    pub fn set_stereo_groups(&mut self, groups: Vec<StereoGroup>) {
788        self.stereo_groups = groups;
789    }
790
791    /// Add a single stereo group in-place.
792    pub fn add_stereo_group(&mut self, group: StereoGroup) {
793        self.stereo_groups.push(group);
794    }
795
796    /// SMILES-text-order neighbor sequence for a chiral atom.
797    ///
798    /// Returns `None` for atoms not parsed from SMILES or without stereo.
799    /// The slice contains neighbor atom indices in SMILES text order;
800    /// [`STEREO_H_SENTINEL`] (`u32::MAX`) marks the implicit bracket-H slot.
801    ///
802    /// # Invariant
803    ///
804    /// For any atom with `chirality != Chirality::None`, once this table is
805    /// populated it must stay populated and correct relative to that atom's
806    /// *current* neighbor set for as long as the chirality flag is set. A
807    /// function that rebuilds a `Molecule` while keeping the same surviving
808    /// atom/bond set (even if nothing actually changes) must carry this
809    /// table forward explicitly (`MoleculeBuilder::copy_stereo_from`, plus
810    /// `copy_bond_directions_from`/`copy_stereo_groups_from` for the other
811    /// two stereo side tables) rather than leaving a downstream consumer to
812    /// reconstruct it from raw adjacency — that reconstruction is only a
813    /// best-effort fallback and is provably wrong for ring-opening
814    /// stereocenters (see `chematic-chem`'s `hydrogen::declared_neighbor_order`
815    /// and issue #399). A function that genuinely removes atoms or bonds
816    /// must use the index-remap-with-sentinel-substitution pattern in
817    /// [`Self::with_atom_removed`]/[`Self::with_bond_removed`], not a bare
818    /// rebuild.
819    pub fn stereo_neighbor_order(&self, idx: AtomIdx) -> Option<&[u32]> {
820        self.stereo_neighbor_order.get(&idx.0).map(|v| v.as_slice())
821    }
822
823    /// Set the SMILES stereo neighbor order for atom `idx`.
824    pub fn set_stereo_neighbor_order(&mut self, idx: AtomIdx, order: Vec<u32>) {
825        self.stereo_neighbor_order.insert(idx.0, order);
826    }
827
828    /// Directional (`/`, `\`) marker stashed for bond `idx`, if its `order`
829    /// was overwritten to `Aromatic` while it still carried E/Z direction.
830    /// Returns `BondOrder::Up` or `BondOrder::Down` when present.
831    pub fn bond_direction(&self, idx: BondIdx) -> Option<BondOrder> {
832        self.bond_directions.get(&idx.0).copied()
833    }
834
835    /// Return the endpoint that anchored a parser-side aromatic direction
836    /// stash, if one was recorded.
837    pub fn bond_direction_anchor(&self, idx: BondIdx) -> Option<AtomIdx> {
838        self.bond_direction_anchors.get(&idx.0).copied()
839    }
840
841    /// Stash a directional marker for bond `idx` (see [`Self::bond_direction`]).
842    pub fn set_bond_direction(&mut self, idx: BondIdx, direction: BondOrder) {
843        self.bond_directions.insert(idx.0, direction);
844    }
845
846    /// Set the endpoint that anchored a parser-side aromatic direction stash.
847    pub fn set_bond_direction_anchor(&mut self, idx: BondIdx, atom: AtomIdx) {
848        self.bond_direction_anchors.insert(idx.0, atom);
849    }
850}
851
852// ---------------------------------------------------------------------------
853// Connectivity utilities
854// ---------------------------------------------------------------------------
855
856impl Molecule {
857    /// Return `true` if the molecule has exactly one connected component
858    /// (i.e. every atom can be reached from every other atom).
859    pub fn is_connected(&self) -> bool {
860        let n = self.atoms.len();
861        if n == 0 {
862            return true;
863        }
864        let mut visited = vec![false; n];
865        let mut stack = vec![AtomIdx(0)];
866        visited[0] = true;
867        let mut count = 1;
868        while let Some(cur) = stack.pop() {
869            for (nb, _) in self.neighbors(cur) {
870                if !visited[nb.0 as usize] {
871                    visited[nb.0 as usize] = true;
872                    count += 1;
873                    stack.push(nb);
874                }
875            }
876        }
877        count == n
878    }
879
880    /// Split the molecule into its connected components.
881    ///
882    /// Returns a `Vec` of sub-molecules, one per component.  Atoms are
883    /// renumbered within each sub-molecule starting at index 0.
884    pub fn fragments(&self) -> Vec<Molecule> {
885        let n = self.atoms.len();
886        if n == 0 {
887            return vec![];
888        }
889
890        let mut component: Vec<usize> = vec![usize::MAX; n];
891        let mut comp_id = 0;
892
893        for start in 0..n {
894            if component[start] != usize::MAX {
895                continue;
896            }
897            let mut stack = vec![start];
898            component[start] = comp_id;
899            while let Some(cur) = stack.pop() {
900                for (nb, _) in self.neighbors(AtomIdx(cur as u32)) {
901                    let ni = nb.0 as usize;
902                    if component[ni] == usize::MAX {
903                        component[ni] = comp_id;
904                        stack.push(ni);
905                    }
906                }
907            }
908            comp_id += 1;
909        }
910
911        (0..comp_id)
912            .map(|cid| {
913                let mut builder = MoleculeBuilder::new();
914                let mut old_to_new: std::collections::HashMap<AtomIdx, AtomIdx> =
915                    std::collections::HashMap::new();
916                for (aidx, atom) in self.atoms() {
917                    if component[aidx.0 as usize] == cid {
918                        let new_idx = builder.add_atom(atom.clone());
919                        old_to_new.insert(aidx, new_idx);
920                    }
921                }
922                for (_, bond) in self.bonds() {
923                    if let (Some(&a1), Some(&a2)) =
924                        (old_to_new.get(&bond.atom1), old_to_new.get(&bond.atom2))
925                    {
926                        let _ = builder.add_bond(a1, a2, bond.order);
927                    }
928                }
929                builder.build()
930            })
931            .collect()
932    }
933}
934
935/// Builder for constructing a [`Molecule`] incrementally.
936///
937/// Usage: add atoms, add bonds, then call `build()`.
938#[derive(Default)]
939pub struct MoleculeBuilder {
940    atoms: Vec<Atom>,
941    bonds: Vec<BondEntry>,
942    adjacency: Vec<Vec<(AtomIdx, BondIdx)>>,
943    stereo_groups: Vec<StereoGroup>,
944    stereo_neighbor_order: std::collections::HashMap<u32, Vec<u32>>,
945    bond_directions: std::collections::HashMap<u32, BondOrder>,
946    bond_direction_anchors: std::collections::HashMap<u32, AtomIdx>,
947}
948
949impl MoleculeBuilder {
950    pub fn new() -> Self {
951        Self::default()
952    }
953
954    /// Create an empty builder with storage reserved for a known graph size.
955    ///
956    /// Parsers commonly learn atom and bond counts before constructing the
957    /// graph. Reserving those primary arrays avoids repeated growth while
958    /// preserving exactly the same builder semantics as [`Self::new`].
959    pub fn with_capacity(atom_count: usize, bond_count: usize) -> Self {
960        Self {
961            atoms: Vec::with_capacity(atom_count),
962            bonds: Vec::with_capacity(bond_count),
963            adjacency: Vec::with_capacity(atom_count),
964            stereo_groups: Vec::new(),
965            stereo_neighbor_order: std::collections::HashMap::new(),
966            bond_directions: std::collections::HashMap::new(),
967            bond_direction_anchors: std::collections::HashMap::new(),
968        }
969    }
970
971    /// Create a builder pre-populated with all atoms and bonds from `mol`.
972    ///
973    /// Use this to make incremental edits to an existing molecule instead of
974    /// reconstructing it from scratch.
975    pub fn from_molecule(mol: &Molecule) -> Self {
976        let mut b = Self::new();
977        for (_, atom) in mol.atoms() {
978            b.add_atom(atom.clone());
979        }
980        for (_, bond) in mol.bonds() {
981            let _ = b.add_bond(bond.atom1, bond.atom2, bond.order);
982        }
983        b.stereo_groups = mol.stereo_groups.clone();
984        b.stereo_neighbor_order = mol.stereo_neighbor_order.clone();
985        b.bond_directions = mol.bond_directions.clone();
986        b.bond_direction_anchors = mol.bond_direction_anchors.clone();
987        b
988    }
989
990    /// Set the SMILES stereo neighbor order for atom `idx`.
991    pub fn set_stereo_neighbor_order(&mut self, idx: AtomIdx, order: Vec<u32>) {
992        self.stereo_neighbor_order.insert(idx.0, order);
993    }
994
995    /// Remove the stereo neighbor order entry for atom `idx`.
996    pub fn clear_stereo_neighbor_order(&mut self, idx: AtomIdx) {
997        self.stereo_neighbor_order.remove(&idx.0);
998    }
999
1000    /// Append a stereo group to this builder.
1001    pub fn add_stereo_group(&mut self, group: StereoGroup) {
1002        self.stereo_groups.push(group);
1003    }
1004
1005    /// Copy all enhanced stereo groups from `mol` into this builder verbatim.
1006    ///
1007    /// Only valid when atom indices are unchanged from `mol` (atoms re-added
1008    /// in the same order, none removed) — same caveat as
1009    /// [`Self::copy_bond_directions_from`].
1010    pub fn copy_stereo_groups_from(&mut self, mol: &Molecule) {
1011        self.stereo_groups = mol.stereo_groups.clone();
1012    }
1013
1014    /// Copy all stereo neighbor order entries from `mol` into this builder.
1015    pub fn copy_stereo_from(&mut self, mol: &Molecule) {
1016        self.stereo_neighbor_order = mol.stereo_neighbor_order.clone();
1017    }
1018
1019    /// Stash a directional marker for bond `idx` (see [`Molecule::bond_direction`]).
1020    pub fn set_bond_direction(&mut self, idx: BondIdx, direction: BondOrder) {
1021        self.bond_directions.insert(idx.0, direction);
1022    }
1023
1024    /// Set the parser-side endpoint that anchored a stashed direction.
1025    pub fn set_bond_direction_anchor(&mut self, idx: BondIdx, atom: AtomIdx) {
1026        self.bond_direction_anchors.insert(idx.0, atom);
1027    }
1028
1029    /// Copy all bond-direction entries from `mol` into this builder verbatim.
1030    ///
1031    /// Only valid when bond indices are unchanged from `mol` (atoms/bonds
1032    /// re-added in the same order, none skipped) — e.g. a rebuild that only
1033    /// touches atom fields or promotes bond order to `Aromatic`. A rebuild
1034    /// that removes or reorders bonds must remap directions bond-by-bond
1035    /// instead (see `Molecule::with_bond_removed`).
1036    pub fn copy_bond_directions_from(&mut self, mol: &Molecule) {
1037        self.bond_directions = mol.bond_directions.clone();
1038        self.bond_direction_anchors = mol.bond_direction_anchors.clone();
1039    }
1040
1041    /// Read-only reference to an atom already added to the builder.
1042    ///
1043    /// Used by the SMILES parser to infer implicit bond types without
1044    /// consuming the builder (e.g. aromatic-aromatic → Aromatic bond).
1045    ///
1046    /// # Panics
1047    /// Panics if `idx` is out of range.
1048    pub fn atom_at(&self, idx: AtomIdx) -> &Atom {
1049        &self.atoms[idx.0 as usize]
1050    }
1051
1052    /// Number of atoms added so far.
1053    pub fn atom_count(&self) -> usize {
1054        self.atoms.len()
1055    }
1056
1057    /// Iterate over already-added neighbors of `idx` as `(bond_idx, neighbor_atom_idx)`.
1058    /// Used by kekulization tests to check whether a bond already exists in the builder.
1059    pub fn atom_neighbors(&self, idx: AtomIdx) -> impl Iterator<Item = (BondIdx, AtomIdx)> + '_ {
1060        self.adjacency[idx.0 as usize]
1061            .iter()
1062            .map(|&(nb, bidx)| (bidx, nb))
1063    }
1064
1065    /// Add an atom and return its index.
1066    pub fn add_atom(&mut self, atom: Atom) -> AtomIdx {
1067        let idx = AtomIdx(self.atoms.len() as u32);
1068        self.atoms.push(atom);
1069        self.adjacency.push(Vec::new());
1070        idx
1071    }
1072
1073    /// Add a bond between two existing atoms.
1074    ///
1075    /// Returns an error if either atom index is invalid or if the bond already exists.
1076    pub fn add_bond(
1077        &mut self,
1078        a: AtomIdx,
1079        b: AtomIdx,
1080        order: BondOrder,
1081    ) -> Result<BondIdx, MolError> {
1082        let n = self.atoms.len() as u32;
1083        if a.0 >= n {
1084            return Err(MolError::InvalidAtomIdx(a));
1085        }
1086        if b.0 >= n {
1087            return Err(MolError::InvalidAtomIdx(b));
1088        }
1089
1090        // Check for duplicate
1091        for &(nb, _) in &self.adjacency[a.0 as usize] {
1092            if nb == b {
1093                return Err(MolError::DuplicateBond(a, b));
1094            }
1095        }
1096
1097        let bidx = BondIdx(self.bonds.len() as u32);
1098        self.bonds.push(BondEntry {
1099            atom1: a,
1100            atom2: b,
1101            order,
1102        });
1103        self.adjacency[a.0 as usize].push((b, bidx));
1104        self.adjacency[b.0 as usize].push((a, bidx));
1105        Ok(bidx)
1106    }
1107
1108    /// Consume the builder and return an immutable [`Molecule`].
1109    pub fn build(self) -> Molecule {
1110        Molecule {
1111            atoms: self.atoms,
1112            bonds: self.bonds,
1113            adjacency: self.adjacency,
1114            stereo_groups: self.stereo_groups,
1115            stereo_neighbor_order: self.stereo_neighbor_order,
1116            bond_directions: self.bond_directions,
1117            bond_direction_anchors: self.bond_direction_anchors,
1118        }
1119    }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125    use crate::atom::Atom;
1126    use crate::element::Element;
1127
1128    fn ethane() -> Molecule {
1129        let mut b = MoleculeBuilder::new();
1130        let c1 = b.add_atom(Atom::new(Element::C));
1131        let c2 = b.add_atom(Atom::new(Element::C));
1132        b.add_bond(c1, c2, BondOrder::Single).unwrap();
1133        b.build()
1134    }
1135
1136    #[test]
1137    fn test_basic_molecule() {
1138        let mol = ethane();
1139        assert_eq!(mol.atom_count(), 2);
1140        assert_eq!(mol.bond_count(), 1);
1141    }
1142
1143    #[test]
1144    fn test_adjacency() {
1145        let mol = ethane();
1146        let neighbors: Vec<_> = mol.neighbors(AtomIdx(0)).collect();
1147        assert_eq!(neighbors.len(), 1);
1148        assert_eq!(neighbors[0].0, AtomIdx(1));
1149    }
1150
1151    #[test]
1152    fn test_bond_between() {
1153        let mol = ethane();
1154        assert!(mol.bond_between(AtomIdx(0), AtomIdx(1)).is_some());
1155        assert!(mol.bond_between(AtomIdx(1), AtomIdx(0)).is_some());
1156    }
1157
1158    #[test]
1159    fn test_duplicate_bond_error() {
1160        let mut b = MoleculeBuilder::new();
1161        let c1 = b.add_atom(Atom::new(Element::C));
1162        let c2 = b.add_atom(Atom::new(Element::C));
1163        b.add_bond(c1, c2, BondOrder::Single).unwrap();
1164        let err = b.add_bond(c1, c2, BondOrder::Double);
1165        assert!(matches!(err, Err(MolError::DuplicateBond(_, _))));
1166    }
1167
1168    #[test]
1169    fn test_formula() {
1170        let mut b = MoleculeBuilder::new();
1171        let c = b.add_atom(Atom::new(Element::C));
1172        let n = b.add_atom(Atom::new(Element::N));
1173        b.add_bond(c, n, BondOrder::Single).unwrap();
1174        let mol = b.build();
1175        assert_eq!(mol.formula(), "CN");
1176    }
1177
1178    #[test]
1179    fn test_implicit_hydrogen_count() {
1180        // Isolated C atom (sp3, 4 bonds available): 4 implicit H
1181        let mut b = MoleculeBuilder::new();
1182        b.add_atom(Atom::organic(Element::C));
1183        let mol = b.build();
1184        assert_eq!(mol.implicit_hydrogen_count(AtomIdx(0)), 4);
1185    }
1186
1187    #[test]
1188    fn test_total_formula_methane() {
1189        // Organic C atom with 0 explicit bonds → 4 implicit H → CH4
1190        let mut b = MoleculeBuilder::new();
1191        b.add_atom(Atom::organic(Element::C));
1192        let mol = b.build();
1193        assert_eq!(mol.total_formula(), "CH4");
1194    }
1195
1196    #[test]
1197    fn test_total_formula_no_hydrogen() {
1198        // NaCl — neither Na nor Cl is in the organic subset, no implicit H
1199        let mut b = MoleculeBuilder::new();
1200        let na = b.add_atom(Atom::new(Element::NA));
1201        let cl = b.add_atom(Atom::new(Element::CL));
1202        b.add_bond(na, cl, BondOrder::Single).unwrap();
1203        let mol = b.build();
1204        assert_eq!(mol.total_formula(), "ClNa");
1205    }
1206
1207    #[test]
1208    fn test_with_atom_aromatic() {
1209        let mol = ethane();
1210        let updated = mol.with_atom_aromatic(AtomIdx(0), true);
1211        assert!(updated.atom(AtomIdx(0)).aromatic);
1212        assert!(!updated.atom(AtomIdx(1)).aromatic);
1213    }
1214
1215    #[test]
1216    fn test_with_bond_order() {
1217        let mol = ethane();
1218        let updated = mol.with_bond_order(BondIdx(0), BondOrder::Double);
1219        assert_eq!(updated.bond(BondIdx(0)).order, BondOrder::Double);
1220    }
1221
1222    // --- bond_directions remap correctness (not just presence) ---
1223
1224    /// 4-atom chain A-B-C-D with a `bond_direction` stash on the LAST bond
1225    /// (C-D, index 2). Removing the FIRST bond (A-B, index 0) shifts every
1226    /// surviving bond's index down by one; the stash must follow the C-D
1227    /// bond to its new index (1), not stay pinned to numeric index 2 (which
1228    /// would now point at a different physical bond) and not vanish.
1229    fn chain_with_direction_on_last_bond() -> (Molecule, BondIdx) {
1230        let mut b = MoleculeBuilder::new();
1231        let a = b.add_atom(Atom::new(Element::C));
1232        let bb = b.add_atom(Atom::new(Element::C));
1233        let c = b.add_atom(Atom::new(Element::C));
1234        let d = b.add_atom(Atom::new(Element::C));
1235        b.add_bond(a, bb, BondOrder::Single).unwrap(); // bond 0 (to be removed)
1236        b.add_bond(bb, c, BondOrder::Single).unwrap(); // bond 1
1237        let cd = b.add_bond(c, d, BondOrder::Single).unwrap(); // bond 2
1238        b.set_bond_direction(cd, BondOrder::Up);
1239        b.set_bond_direction_anchor(cd, c);
1240        (b.build(), cd)
1241    }
1242
1243    #[test]
1244    fn test_remove_bond_remaps_bond_direction_not_misattributes() {
1245        let (mut mol, _cd) = chain_with_direction_on_last_bond();
1246        assert_eq!(mol.bond_count(), 3);
1247        mol.remove_bond(BondIdx(0)); // remove A-B; C-D shifts from index 2 to 1
1248        assert_eq!(mol.bond_count(), 2);
1249        // The stash must have followed C-D to its new index...
1250        assert_eq!(mol.bond_direction(BondIdx(1)), Some(BondOrder::Up));
1251        assert_eq!(mol.bond_direction_anchor(BondIdx(1)), Some(AtomIdx(2)));
1252        // ...and must NOT have leaked onto the bond that shifted into the
1253        // old numeric slot 2 (which no longer exists) or onto B-C (index 0
1254        // after the shift), which never had a direction.
1255        assert_eq!(mol.bond_direction(BondIdx(0)), None);
1256        assert_eq!(mol.bond_opt(BondIdx(2)), None);
1257    }
1258
1259    #[test]
1260    fn test_remove_bond_drops_direction_for_the_removed_bond_itself() {
1261        let (mut mol, _cd) = chain_with_direction_on_last_bond();
1262        mol.remove_bond(BondIdx(2)); // remove C-D itself — its stash must go with it
1263        assert_eq!(mol.bond_count(), 2);
1264        assert!(mol.bond_direction(BondIdx(0)).is_none());
1265        assert!(mol.bond_direction(BondIdx(1)).is_none());
1266    }
1267
1268    #[test]
1269    fn test_with_atom_removed_remaps_bond_direction() {
1270        let (mol, _cd) = chain_with_direction_on_last_bond();
1271        // Remove atom A (index 0), unrelated to the C-D bond carrying the
1272        // stash. Bonds incident to A (A-B) disappear; B-C and C-D survive,
1273        // renumbered 0 and 1 respectively — the direction must follow C-D.
1274        let (updated, _atom_remap) = mol.with_atom_removed(AtomIdx(0));
1275        assert_eq!(updated.bond_count(), 2);
1276        // Find the surviving C-D bond by scanning for the stash directly,
1277        // rather than assuming a specific bond index, so this test doesn't
1278        // depend on internal re-numbering order.
1279        let has_direction = (0..updated.bond_count())
1280            .map(|i| BondIdx(i as u32))
1281            .any(|bidx| updated.bond_direction(bidx) == Some(BondOrder::Up));
1282        assert!(
1283            has_direction,
1284            "bond_direction on C-D must survive atom removal, remapped to its new bond index"
1285        );
1286    }
1287
1288    // --- mutable API ---
1289
1290    #[test]
1291    fn test_add_remove_atom() {
1292        let mut mol = ethane();
1293        let n_idx = mol.add_atom(Atom::new(Element::N));
1294        assert_eq!(mol.atom_count(), 3);
1295        assert_eq!(mol.atom(n_idx).element.atomic_number(), 7);
1296
1297        let remap = mol.remove_atom(n_idx);
1298        assert_eq!(mol.atom_count(), 2);
1299        assert!(remap[n_idx.0 as usize].is_none());
1300    }
1301
1302    #[test]
1303    fn test_remove_atom_preserves_survivor_order() {
1304        let mut mol = MoleculeBuilder::new().build();
1305        mol.add_atom(Atom::new(Element::C));
1306        let removed = mol.add_atom(Atom::new(Element::N));
1307        mol.add_atom(Atom::new(Element::O));
1308
1309        let remap = mol.remove_atom(removed);
1310
1311        assert_eq!(
1312            mol.atoms()
1313                .map(|(_, atom)| atom.element)
1314                .collect::<Vec<_>>(),
1315            vec![Element::C, Element::O]
1316        );
1317        assert_eq!(remap, vec![Some(AtomIdx(0)), None, Some(AtomIdx(1))]);
1318    }
1319
1320    #[test]
1321    fn test_add_remove_bond() {
1322        let mut mol = ethane();
1323        let n_idx = mol.add_atom(Atom::new(Element::N));
1324        let bidx = mol.add_bond(AtomIdx(0), n_idx, BondOrder::Single).unwrap();
1325        assert_eq!(mol.bond_count(), 2);
1326        mol.remove_bond(bidx);
1327        assert_eq!(mol.bond_count(), 1);
1328    }
1329
1330    #[test]
1331    fn test_set_charge_element() {
1332        let mut mol = ethane();
1333        mol.set_charge(AtomIdx(0), 1);
1334        assert_eq!(mol.atom(AtomIdx(0)).charge, 1);
1335        mol.set_element(AtomIdx(0), Element::N);
1336        assert_eq!(mol.atom(AtomIdx(0)).element.atomic_number(), 7);
1337    }
1338
1339    #[test]
1340    fn test_is_connected() {
1341        let mol = ethane();
1342        assert!(mol.is_connected());
1343
1344        // Two separate atoms — disconnected
1345        let mut b = MoleculeBuilder::new();
1346        b.add_atom(Atom::new(Element::C));
1347        b.add_atom(Atom::new(Element::N));
1348        let disconnected = b.build();
1349        assert!(!disconnected.is_connected());
1350    }
1351
1352    #[test]
1353    fn test_fragments() {
1354        // "CC.N" — two components
1355        let mut b = MoleculeBuilder::new();
1356        let c1 = b.add_atom(Atom::organic(Element::C));
1357        let c2 = b.add_atom(Atom::organic(Element::C));
1358        b.add_bond(c1, c2, BondOrder::Single).unwrap();
1359        b.add_atom(Atom::new(Element::N)); // disconnected N
1360        let mol = b.build();
1361        let frags = mol.fragments();
1362        assert_eq!(frags.len(), 2);
1363        let sizes: std::collections::HashSet<usize> =
1364            frags.iter().map(|f| f.atom_count()).collect();
1365        assert!(sizes.contains(&2));
1366        assert!(sizes.contains(&1));
1367    }
1368
1369    #[test]
1370    fn test_builder_from_molecule() {
1371        let mol = ethane();
1372        let mut b = MoleculeBuilder::from_molecule(&mol);
1373        b.add_atom(Atom::new(Element::O));
1374        let mol2 = b.build();
1375        assert_eq!(mol2.atom_count(), 3);
1376        assert_eq!(mol2.bond_count(), 1); // original bond preserved
1377    }
1378
1379    // --- safe Option-returning variants ---
1380
1381    #[test]
1382    fn test_atom_opt_valid() {
1383        let mol = ethane();
1384        assert!(mol.atom_opt(AtomIdx(0)).is_some());
1385        assert!(mol.atom_opt(AtomIdx(1)).is_some());
1386        let atom = mol.atom_opt(AtomIdx(0)).unwrap();
1387        assert_eq!(atom.element.atomic_number(), 6);
1388    }
1389
1390    #[test]
1391    fn test_atom_opt_invalid() {
1392        let mol = ethane();
1393        assert!(mol.atom_opt(AtomIdx(2)).is_none());
1394        assert!(mol.atom_opt(AtomIdx(1000)).is_none());
1395    }
1396
1397    #[test]
1398    fn test_bond_opt_valid() {
1399        let mol = ethane();
1400        assert!(mol.bond_opt(BondIdx(0)).is_some());
1401        let bond = mol.bond_opt(BondIdx(0)).unwrap();
1402        assert_eq!(bond.order, BondOrder::Single);
1403    }
1404
1405    #[test]
1406    fn test_bond_opt_invalid() {
1407        let mol = ethane();
1408        assert!(mol.bond_opt(BondIdx(1)).is_none());
1409        assert!(mol.bond_opt(BondIdx(1000)).is_none());
1410    }
1411
1412    #[test]
1413    fn test_neighbors_opt_valid() {
1414        let mol = ethane();
1415        let neighbors = mol.neighbors_opt(AtomIdx(0)).unwrap();
1416        assert_eq!(neighbors.len(), 1);
1417        assert_eq!(neighbors[0].0, AtomIdx(1));
1418    }
1419
1420    #[test]
1421    fn test_neighbors_opt_isolated_atom() {
1422        let mut b = MoleculeBuilder::new();
1423        b.add_atom(Atom::new(Element::C));
1424        b.add_atom(Atom::new(Element::N));
1425        let mol = b.build();
1426        let neighbors = mol.neighbors_opt(AtomIdx(0)).unwrap();
1427        assert_eq!(neighbors.len(), 0);
1428    }
1429
1430    #[test]
1431    fn test_neighbors_opt_invalid() {
1432        let mol = ethane();
1433        assert!(mol.neighbors_opt(AtomIdx(2)).is_none());
1434        assert!(mol.neighbors_opt(AtomIdx(1000)).is_none());
1435    }
1436
1437    #[test]
1438    fn test_degree_opt_valid() {
1439        let mol = ethane();
1440        assert_eq!(mol.degree_opt(AtomIdx(0)), Some(1));
1441        assert_eq!(mol.degree_opt(AtomIdx(1)), Some(1));
1442    }
1443
1444    #[test]
1445    fn test_degree_opt_isolated_atom() {
1446        let mut b = MoleculeBuilder::new();
1447        b.add_atom(Atom::new(Element::C));
1448        b.add_atom(Atom::new(Element::N));
1449        let mol = b.build();
1450        assert_eq!(mol.degree_opt(AtomIdx(0)), Some(0));
1451        assert_eq!(mol.degree_opt(AtomIdx(1)), Some(0));
1452    }
1453
1454    #[test]
1455    fn test_degree_opt_invalid() {
1456        let mol = ethane();
1457        assert!(mol.degree_opt(AtomIdx(2)).is_none());
1458        assert!(mol.degree_opt(AtomIdx(1000)).is_none());
1459    }
1460
1461    #[test]
1462    fn test_degree_opt_multiple_bonds() {
1463        // Create a central atom with 3 neighbors
1464        let mut b = MoleculeBuilder::new();
1465        let center = b.add_atom(Atom::new(Element::C));
1466        let n1 = b.add_atom(Atom::new(Element::C));
1467        let n2 = b.add_atom(Atom::new(Element::N));
1468        let n3 = b.add_atom(Atom::new(Element::O));
1469        b.add_bond(center, n1, BondOrder::Single).unwrap();
1470        b.add_bond(center, n2, BondOrder::Double).unwrap();
1471        b.add_bond(center, n3, BondOrder::Single).unwrap();
1472        let mol = b.build();
1473        assert_eq!(mol.degree_opt(center), Some(3));
1474        assert_eq!(mol.degree_opt(n1), Some(1));
1475        assert_eq!(mol.degree_opt(n2), Some(1));
1476        assert_eq!(mol.degree_opt(n3), Some(1));
1477    }
1478}