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