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