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