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    pub fn stereo_neighbor_order(&self, idx: AtomIdx) -> Option<&[u32]> {
788        self.stereo_neighbor_order.get(&idx.0).map(|v| v.as_slice())
789    }
790
791    /// Set the SMILES stereo neighbor order for atom `idx`.
792    pub fn set_stereo_neighbor_order(&mut self, idx: AtomIdx, order: Vec<u32>) {
793        self.stereo_neighbor_order.insert(idx.0, order);
794    }
795
796    /// Directional (`/`, `\`) marker stashed for bond `idx`, if its `order`
797    /// was overwritten to `Aromatic` while it still carried E/Z direction.
798    /// Returns `BondOrder::Up` or `BondOrder::Down` when present.
799    pub fn bond_direction(&self, idx: BondIdx) -> Option<BondOrder> {
800        self.bond_directions.get(&idx.0).copied()
801    }
802
803    /// Stash a directional marker for bond `idx` (see [`Self::bond_direction`]).
804    pub fn set_bond_direction(&mut self, idx: BondIdx, direction: BondOrder) {
805        self.bond_directions.insert(idx.0, direction);
806    }
807}
808
809// ---------------------------------------------------------------------------
810// Connectivity utilities
811// ---------------------------------------------------------------------------
812
813impl Molecule {
814    /// Return `true` if the molecule has exactly one connected component
815    /// (i.e. every atom can be reached from every other atom).
816    pub fn is_connected(&self) -> bool {
817        let n = self.atoms.len();
818        if n == 0 {
819            return true;
820        }
821        let mut visited = vec![false; n];
822        let mut stack = vec![AtomIdx(0)];
823        visited[0] = true;
824        let mut count = 1;
825        while let Some(cur) = stack.pop() {
826            for (nb, _) in self.neighbors(cur) {
827                if !visited[nb.0 as usize] {
828                    visited[nb.0 as usize] = true;
829                    count += 1;
830                    stack.push(nb);
831                }
832            }
833        }
834        count == n
835    }
836
837    /// Split the molecule into its connected components.
838    ///
839    /// Returns a `Vec` of sub-molecules, one per component.  Atoms are
840    /// renumbered within each sub-molecule starting at index 0.
841    pub fn fragments(&self) -> Vec<Molecule> {
842        let n = self.atoms.len();
843        if n == 0 {
844            return vec![];
845        }
846
847        let mut component: Vec<usize> = vec![usize::MAX; n];
848        let mut comp_id = 0;
849
850        for start in 0..n {
851            if component[start] != usize::MAX {
852                continue;
853            }
854            let mut stack = vec![start];
855            component[start] = comp_id;
856            while let Some(cur) = stack.pop() {
857                for (nb, _) in self.neighbors(AtomIdx(cur as u32)) {
858                    let ni = nb.0 as usize;
859                    if component[ni] == usize::MAX {
860                        component[ni] = comp_id;
861                        stack.push(ni);
862                    }
863                }
864            }
865            comp_id += 1;
866        }
867
868        (0..comp_id)
869            .map(|cid| {
870                let mut builder = MoleculeBuilder::new();
871                let mut old_to_new: std::collections::HashMap<AtomIdx, AtomIdx> =
872                    std::collections::HashMap::new();
873                for (aidx, atom) in self.atoms() {
874                    if component[aidx.0 as usize] == cid {
875                        let new_idx = builder.add_atom(atom.clone());
876                        old_to_new.insert(aidx, new_idx);
877                    }
878                }
879                for (_, bond) in self.bonds() {
880                    if let (Some(&a1), Some(&a2)) =
881                        (old_to_new.get(&bond.atom1), old_to_new.get(&bond.atom2))
882                    {
883                        let _ = builder.add_bond(a1, a2, bond.order);
884                    }
885                }
886                builder.build()
887            })
888            .collect()
889    }
890}
891
892/// Builder for constructing a [`Molecule`] incrementally.
893///
894/// Usage: add atoms, add bonds, then call `build()`.
895#[derive(Default)]
896pub struct MoleculeBuilder {
897    atoms: Vec<Atom>,
898    bonds: Vec<BondEntry>,
899    adjacency: Vec<Vec<(AtomIdx, BondIdx)>>,
900    stereo_groups: Vec<StereoGroup>,
901    stereo_neighbor_order: std::collections::HashMap<u32, Vec<u32>>,
902    bond_directions: std::collections::HashMap<u32, BondOrder>,
903}
904
905impl MoleculeBuilder {
906    pub fn new() -> Self {
907        Self::default()
908    }
909
910    /// Create a builder pre-populated with all atoms and bonds from `mol`.
911    ///
912    /// Use this to make incremental edits to an existing molecule instead of
913    /// reconstructing it from scratch.
914    pub fn from_molecule(mol: &Molecule) -> Self {
915        let mut b = Self::new();
916        for (_, atom) in mol.atoms() {
917            b.add_atom(atom.clone());
918        }
919        for (_, bond) in mol.bonds() {
920            let _ = b.add_bond(bond.atom1, bond.atom2, bond.order);
921        }
922        b.stereo_groups = mol.stereo_groups.clone();
923        b.stereo_neighbor_order = mol.stereo_neighbor_order.clone();
924        b.bond_directions = mol.bond_directions.clone();
925        b
926    }
927
928    /// Set the SMILES stereo neighbor order for atom `idx`.
929    pub fn set_stereo_neighbor_order(&mut self, idx: AtomIdx, order: Vec<u32>) {
930        self.stereo_neighbor_order.insert(idx.0, order);
931    }
932
933    /// Remove the stereo neighbor order entry for atom `idx`.
934    pub fn clear_stereo_neighbor_order(&mut self, idx: AtomIdx) {
935        self.stereo_neighbor_order.remove(&idx.0);
936    }
937
938    /// Append a stereo group to this builder.
939    pub fn add_stereo_group(&mut self, group: StereoGroup) {
940        self.stereo_groups.push(group);
941    }
942
943    /// Copy all enhanced stereo groups from `mol` into this builder verbatim.
944    ///
945    /// Only valid when atom indices are unchanged from `mol` (atoms re-added
946    /// in the same order, none removed) — same caveat as
947    /// [`Self::copy_bond_directions_from`].
948    pub fn copy_stereo_groups_from(&mut self, mol: &Molecule) {
949        self.stereo_groups = mol.stereo_groups.clone();
950    }
951
952    /// Copy all stereo neighbor order entries from `mol` into this builder.
953    pub fn copy_stereo_from(&mut self, mol: &Molecule) {
954        self.stereo_neighbor_order = mol.stereo_neighbor_order.clone();
955    }
956
957    /// Stash a directional marker for bond `idx` (see [`Molecule::bond_direction`]).
958    pub fn set_bond_direction(&mut self, idx: BondIdx, direction: BondOrder) {
959        self.bond_directions.insert(idx.0, direction);
960    }
961
962    /// Copy all bond-direction entries from `mol` into this builder verbatim.
963    ///
964    /// Only valid when bond indices are unchanged from `mol` (atoms/bonds
965    /// re-added in the same order, none skipped) — e.g. a rebuild that only
966    /// touches atom fields or promotes bond order to `Aromatic`. A rebuild
967    /// that removes or reorders bonds must remap directions bond-by-bond
968    /// instead (see `Molecule::with_bond_removed`).
969    pub fn copy_bond_directions_from(&mut self, mol: &Molecule) {
970        self.bond_directions = mol.bond_directions.clone();
971    }
972
973    /// Read-only reference to an atom already added to the builder.
974    ///
975    /// Used by the SMILES parser to infer implicit bond types without
976    /// consuming the builder (e.g. aromatic-aromatic → Aromatic bond).
977    ///
978    /// # Panics
979    /// Panics if `idx` is out of range.
980    pub fn atom_at(&self, idx: AtomIdx) -> &Atom {
981        &self.atoms[idx.0 as usize]
982    }
983
984    /// Number of atoms added so far.
985    pub fn atom_count(&self) -> usize {
986        self.atoms.len()
987    }
988
989    /// Iterate over already-added neighbors of `idx` as `(bond_idx, neighbor_atom_idx)`.
990    /// Used by kekulization tests to check whether a bond already exists in the builder.
991    pub fn atom_neighbors(&self, idx: AtomIdx) -> impl Iterator<Item = (BondIdx, AtomIdx)> + '_ {
992        self.adjacency[idx.0 as usize]
993            .iter()
994            .map(|&(nb, bidx)| (bidx, nb))
995    }
996
997    /// Add an atom and return its index.
998    pub fn add_atom(&mut self, atom: Atom) -> AtomIdx {
999        let idx = AtomIdx(self.atoms.len() as u32);
1000        self.atoms.push(atom);
1001        self.adjacency.push(Vec::new());
1002        idx
1003    }
1004
1005    /// Add a bond between two existing atoms.
1006    ///
1007    /// Returns an error if either atom index is invalid or if the bond already exists.
1008    pub fn add_bond(
1009        &mut self,
1010        a: AtomIdx,
1011        b: AtomIdx,
1012        order: BondOrder,
1013    ) -> Result<BondIdx, MolError> {
1014        let n = self.atoms.len() as u32;
1015        if a.0 >= n {
1016            return Err(MolError::InvalidAtomIdx(a));
1017        }
1018        if b.0 >= n {
1019            return Err(MolError::InvalidAtomIdx(b));
1020        }
1021
1022        // Check for duplicate
1023        for &(nb, _) in &self.adjacency[a.0 as usize] {
1024            if nb == b {
1025                return Err(MolError::DuplicateBond(a, b));
1026            }
1027        }
1028
1029        let bidx = BondIdx(self.bonds.len() as u32);
1030        self.bonds.push(BondEntry {
1031            atom1: a,
1032            atom2: b,
1033            order,
1034        });
1035        self.adjacency[a.0 as usize].push((b, bidx));
1036        self.adjacency[b.0 as usize].push((a, bidx));
1037        Ok(bidx)
1038    }
1039
1040    /// Consume the builder and return an immutable [`Molecule`].
1041    pub fn build(self) -> Molecule {
1042        Molecule {
1043            atoms: self.atoms,
1044            bonds: self.bonds,
1045            adjacency: self.adjacency,
1046            stereo_groups: self.stereo_groups,
1047            stereo_neighbor_order: self.stereo_neighbor_order,
1048            bond_directions: self.bond_directions,
1049        }
1050    }
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056    use crate::atom::Atom;
1057    use crate::element::Element;
1058
1059    fn ethane() -> Molecule {
1060        let mut b = MoleculeBuilder::new();
1061        let c1 = b.add_atom(Atom::new(Element::C));
1062        let c2 = b.add_atom(Atom::new(Element::C));
1063        b.add_bond(c1, c2, BondOrder::Single).unwrap();
1064        b.build()
1065    }
1066
1067    #[test]
1068    fn test_basic_molecule() {
1069        let mol = ethane();
1070        assert_eq!(mol.atom_count(), 2);
1071        assert_eq!(mol.bond_count(), 1);
1072    }
1073
1074    #[test]
1075    fn test_adjacency() {
1076        let mol = ethane();
1077        let neighbors: Vec<_> = mol.neighbors(AtomIdx(0)).collect();
1078        assert_eq!(neighbors.len(), 1);
1079        assert_eq!(neighbors[0].0, AtomIdx(1));
1080    }
1081
1082    #[test]
1083    fn test_bond_between() {
1084        let mol = ethane();
1085        assert!(mol.bond_between(AtomIdx(0), AtomIdx(1)).is_some());
1086        assert!(mol.bond_between(AtomIdx(1), AtomIdx(0)).is_some());
1087    }
1088
1089    #[test]
1090    fn test_duplicate_bond_error() {
1091        let mut b = MoleculeBuilder::new();
1092        let c1 = b.add_atom(Atom::new(Element::C));
1093        let c2 = b.add_atom(Atom::new(Element::C));
1094        b.add_bond(c1, c2, BondOrder::Single).unwrap();
1095        let err = b.add_bond(c1, c2, BondOrder::Double);
1096        assert!(matches!(err, Err(MolError::DuplicateBond(_, _))));
1097    }
1098
1099    #[test]
1100    fn test_formula() {
1101        let mut b = MoleculeBuilder::new();
1102        let c = b.add_atom(Atom::new(Element::C));
1103        let n = b.add_atom(Atom::new(Element::N));
1104        b.add_bond(c, n, BondOrder::Single).unwrap();
1105        let mol = b.build();
1106        assert_eq!(mol.formula(), "CN");
1107    }
1108
1109    #[test]
1110    fn test_implicit_hydrogen_count() {
1111        // Isolated C atom (sp3, 4 bonds available): 4 implicit H
1112        let mut b = MoleculeBuilder::new();
1113        b.add_atom(Atom::organic(Element::C));
1114        let mol = b.build();
1115        assert_eq!(mol.implicit_hydrogen_count(AtomIdx(0)), 4);
1116    }
1117
1118    #[test]
1119    fn test_total_formula_methane() {
1120        // Organic C atom with 0 explicit bonds → 4 implicit H → CH4
1121        let mut b = MoleculeBuilder::new();
1122        b.add_atom(Atom::organic(Element::C));
1123        let mol = b.build();
1124        assert_eq!(mol.total_formula(), "CH4");
1125    }
1126
1127    #[test]
1128    fn test_total_formula_no_hydrogen() {
1129        // NaCl — neither Na nor Cl is in the organic subset, no implicit H
1130        let mut b = MoleculeBuilder::new();
1131        let na = b.add_atom(Atom::new(Element::NA));
1132        let cl = b.add_atom(Atom::new(Element::CL));
1133        b.add_bond(na, cl, BondOrder::Single).unwrap();
1134        let mol = b.build();
1135        assert_eq!(mol.total_formula(), "ClNa");
1136    }
1137
1138    #[test]
1139    fn test_with_atom_aromatic() {
1140        let mol = ethane();
1141        let updated = mol.with_atom_aromatic(AtomIdx(0), true);
1142        assert!(updated.atom(AtomIdx(0)).aromatic);
1143        assert!(!updated.atom(AtomIdx(1)).aromatic);
1144    }
1145
1146    #[test]
1147    fn test_with_bond_order() {
1148        let mol = ethane();
1149        let updated = mol.with_bond_order(BondIdx(0), BondOrder::Double);
1150        assert_eq!(updated.bond(BondIdx(0)).order, BondOrder::Double);
1151    }
1152
1153    // --- bond_directions remap correctness (not just presence) ---
1154
1155    /// 4-atom chain A-B-C-D with a `bond_direction` stash on the LAST bond
1156    /// (C-D, index 2). Removing the FIRST bond (A-B, index 0) shifts every
1157    /// surviving bond's index down by one; the stash must follow the C-D
1158    /// bond to its new index (1), not stay pinned to numeric index 2 (which
1159    /// would now point at a different physical bond) and not vanish.
1160    fn chain_with_direction_on_last_bond() -> (Molecule, BondIdx) {
1161        let mut b = MoleculeBuilder::new();
1162        let a = b.add_atom(Atom::new(Element::C));
1163        let bb = b.add_atom(Atom::new(Element::C));
1164        let c = b.add_atom(Atom::new(Element::C));
1165        let d = b.add_atom(Atom::new(Element::C));
1166        b.add_bond(a, bb, BondOrder::Single).unwrap(); // bond 0 (to be removed)
1167        b.add_bond(bb, c, BondOrder::Single).unwrap(); // bond 1
1168        let cd = b.add_bond(c, d, BondOrder::Single).unwrap(); // bond 2
1169        b.set_bond_direction(cd, BondOrder::Up);
1170        (b.build(), cd)
1171    }
1172
1173    #[test]
1174    fn test_remove_bond_remaps_bond_direction_not_misattributes() {
1175        let (mut mol, _cd) = chain_with_direction_on_last_bond();
1176        assert_eq!(mol.bond_count(), 3);
1177        mol.remove_bond(BondIdx(0)); // remove A-B; C-D shifts from index 2 to 1
1178        assert_eq!(mol.bond_count(), 2);
1179        // The stash must have followed C-D to its new index...
1180        assert_eq!(mol.bond_direction(BondIdx(1)), Some(BondOrder::Up));
1181        // ...and must NOT have leaked onto the bond that shifted into the
1182        // old numeric slot 2 (which no longer exists) or onto B-C (index 0
1183        // after the shift), which never had a direction.
1184        assert_eq!(mol.bond_direction(BondIdx(0)), None);
1185        assert_eq!(mol.bond_opt(BondIdx(2)), None);
1186    }
1187
1188    #[test]
1189    fn test_remove_bond_drops_direction_for_the_removed_bond_itself() {
1190        let (mut mol, _cd) = chain_with_direction_on_last_bond();
1191        mol.remove_bond(BondIdx(2)); // remove C-D itself — its stash must go with it
1192        assert_eq!(mol.bond_count(), 2);
1193        assert!(mol.bond_direction(BondIdx(0)).is_none());
1194        assert!(mol.bond_direction(BondIdx(1)).is_none());
1195    }
1196
1197    #[test]
1198    fn test_with_atom_removed_remaps_bond_direction() {
1199        let (mol, _cd) = chain_with_direction_on_last_bond();
1200        // Remove atom A (index 0), unrelated to the C-D bond carrying the
1201        // stash. Bonds incident to A (A-B) disappear; B-C and C-D survive,
1202        // renumbered 0 and 1 respectively — the direction must follow C-D.
1203        let (updated, _atom_remap) = mol.with_atom_removed(AtomIdx(0));
1204        assert_eq!(updated.bond_count(), 2);
1205        // Find the surviving C-D bond by scanning for the stash directly,
1206        // rather than assuming a specific bond index, so this test doesn't
1207        // depend on internal re-numbering order.
1208        let has_direction = (0..updated.bond_count())
1209            .map(|i| BondIdx(i as u32))
1210            .any(|bidx| updated.bond_direction(bidx) == Some(BondOrder::Up));
1211        assert!(
1212            has_direction,
1213            "bond_direction on C-D must survive atom removal, remapped to its new bond index"
1214        );
1215    }
1216
1217    // --- mutable API ---
1218
1219    #[test]
1220    fn test_add_remove_atom() {
1221        let mut mol = ethane();
1222        let n_idx = mol.add_atom(Atom::new(Element::N));
1223        assert_eq!(mol.atom_count(), 3);
1224        assert_eq!(mol.atom(n_idx).element.atomic_number(), 7);
1225
1226        let remap = mol.remove_atom(n_idx);
1227        assert_eq!(mol.atom_count(), 2);
1228        assert!(remap[n_idx.0 as usize].is_none());
1229    }
1230
1231    #[test]
1232    fn test_add_remove_bond() {
1233        let mut mol = ethane();
1234        let n_idx = mol.add_atom(Atom::new(Element::N));
1235        let bidx = mol.add_bond(AtomIdx(0), n_idx, BondOrder::Single).unwrap();
1236        assert_eq!(mol.bond_count(), 2);
1237        mol.remove_bond(bidx);
1238        assert_eq!(mol.bond_count(), 1);
1239    }
1240
1241    #[test]
1242    fn test_set_charge_element() {
1243        let mut mol = ethane();
1244        mol.set_charge(AtomIdx(0), 1);
1245        assert_eq!(mol.atom(AtomIdx(0)).charge, 1);
1246        mol.set_element(AtomIdx(0), Element::N);
1247        assert_eq!(mol.atom(AtomIdx(0)).element.atomic_number(), 7);
1248    }
1249
1250    #[test]
1251    fn test_is_connected() {
1252        let mol = ethane();
1253        assert!(mol.is_connected());
1254
1255        // Two separate atoms — disconnected
1256        let mut b = MoleculeBuilder::new();
1257        b.add_atom(Atom::new(Element::C));
1258        b.add_atom(Atom::new(Element::N));
1259        let disconnected = b.build();
1260        assert!(!disconnected.is_connected());
1261    }
1262
1263    #[test]
1264    fn test_fragments() {
1265        // "CC.N" — two components
1266        let mut b = MoleculeBuilder::new();
1267        let c1 = b.add_atom(Atom::organic(Element::C));
1268        let c2 = b.add_atom(Atom::organic(Element::C));
1269        b.add_bond(c1, c2, BondOrder::Single).unwrap();
1270        b.add_atom(Atom::new(Element::N)); // disconnected N
1271        let mol = b.build();
1272        let frags = mol.fragments();
1273        assert_eq!(frags.len(), 2);
1274        let sizes: std::collections::HashSet<usize> =
1275            frags.iter().map(|f| f.atom_count()).collect();
1276        assert!(sizes.contains(&2));
1277        assert!(sizes.contains(&1));
1278    }
1279
1280    #[test]
1281    fn test_builder_from_molecule() {
1282        let mol = ethane();
1283        let mut b = MoleculeBuilder::from_molecule(&mol);
1284        b.add_atom(Atom::new(Element::O));
1285        let mol2 = b.build();
1286        assert_eq!(mol2.atom_count(), 3);
1287        assert_eq!(mol2.bond_count(), 1); // original bond preserved
1288    }
1289
1290    // --- safe Option-returning variants ---
1291
1292    #[test]
1293    fn test_atom_opt_valid() {
1294        let mol = ethane();
1295        assert!(mol.atom_opt(AtomIdx(0)).is_some());
1296        assert!(mol.atom_opt(AtomIdx(1)).is_some());
1297        let atom = mol.atom_opt(AtomIdx(0)).unwrap();
1298        assert_eq!(atom.element.atomic_number(), 6);
1299    }
1300
1301    #[test]
1302    fn test_atom_opt_invalid() {
1303        let mol = ethane();
1304        assert!(mol.atom_opt(AtomIdx(2)).is_none());
1305        assert!(mol.atom_opt(AtomIdx(1000)).is_none());
1306    }
1307
1308    #[test]
1309    fn test_bond_opt_valid() {
1310        let mol = ethane();
1311        assert!(mol.bond_opt(BondIdx(0)).is_some());
1312        let bond = mol.bond_opt(BondIdx(0)).unwrap();
1313        assert_eq!(bond.order, BondOrder::Single);
1314    }
1315
1316    #[test]
1317    fn test_bond_opt_invalid() {
1318        let mol = ethane();
1319        assert!(mol.bond_opt(BondIdx(1)).is_none());
1320        assert!(mol.bond_opt(BondIdx(1000)).is_none());
1321    }
1322
1323    #[test]
1324    fn test_neighbors_opt_valid() {
1325        let mol = ethane();
1326        let neighbors = mol.neighbors_opt(AtomIdx(0)).unwrap();
1327        assert_eq!(neighbors.len(), 1);
1328        assert_eq!(neighbors[0].0, AtomIdx(1));
1329    }
1330
1331    #[test]
1332    fn test_neighbors_opt_isolated_atom() {
1333        let mut b = MoleculeBuilder::new();
1334        b.add_atom(Atom::new(Element::C));
1335        b.add_atom(Atom::new(Element::N));
1336        let mol = b.build();
1337        let neighbors = mol.neighbors_opt(AtomIdx(0)).unwrap();
1338        assert_eq!(neighbors.len(), 0);
1339    }
1340
1341    #[test]
1342    fn test_neighbors_opt_invalid() {
1343        let mol = ethane();
1344        assert!(mol.neighbors_opt(AtomIdx(2)).is_none());
1345        assert!(mol.neighbors_opt(AtomIdx(1000)).is_none());
1346    }
1347
1348    #[test]
1349    fn test_degree_opt_valid() {
1350        let mol = ethane();
1351        assert_eq!(mol.degree_opt(AtomIdx(0)), Some(1));
1352        assert_eq!(mol.degree_opt(AtomIdx(1)), Some(1));
1353    }
1354
1355    #[test]
1356    fn test_degree_opt_isolated_atom() {
1357        let mut b = MoleculeBuilder::new();
1358        b.add_atom(Atom::new(Element::C));
1359        b.add_atom(Atom::new(Element::N));
1360        let mol = b.build();
1361        assert_eq!(mol.degree_opt(AtomIdx(0)), Some(0));
1362        assert_eq!(mol.degree_opt(AtomIdx(1)), Some(0));
1363    }
1364
1365    #[test]
1366    fn test_degree_opt_invalid() {
1367        let mol = ethane();
1368        assert!(mol.degree_opt(AtomIdx(2)).is_none());
1369        assert!(mol.degree_opt(AtomIdx(1000)).is_none());
1370    }
1371
1372    #[test]
1373    fn test_degree_opt_multiple_bonds() {
1374        // Create a central atom with 3 neighbors
1375        let mut b = MoleculeBuilder::new();
1376        let center = b.add_atom(Atom::new(Element::C));
1377        let n1 = b.add_atom(Atom::new(Element::C));
1378        let n2 = b.add_atom(Atom::new(Element::N));
1379        let n3 = b.add_atom(Atom::new(Element::O));
1380        b.add_bond(center, n1, BondOrder::Single).unwrap();
1381        b.add_bond(center, n2, BondOrder::Double).unwrap();
1382        b.add_bond(center, n3, BondOrder::Single).unwrap();
1383        let mol = b.build();
1384        assert_eq!(mol.degree_opt(center), Some(3));
1385        assert_eq!(mol.degree_opt(n1), Some(1));
1386        assert_eq!(mol.degree_opt(n2), Some(1));
1387        assert_eq!(mol.degree_opt(n3), Some(1));
1388    }
1389}