Skip to main content

chematic_core/
atom.rs

1//! Atom type: a single atom in a molecule.
2
3use crate::element::Element;
4
5/// Tetrahedral chirality as specified in OpenSMILES.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
7pub enum Chirality {
8    /// No chirality specified.
9    #[default]
10    None,
11    /// `@` — counterclockwise (looking from the first neighbor).
12    CounterClockwise,
13    /// `@@` — clockwise.
14    Clockwise,
15}
16
17/// Assigned CIP (Cahn–Ingold–Prelog) stereodescriptor.
18///
19/// Stored on [`Atom`] after running [`chematic_chem::assign_cip`] or
20/// [`chematic_chem::cip::assign_cip`].
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum CipCode {
23    /// Tetrahedral center with *rectus* (right-handed) configuration.
24    R,
25    /// Tetrahedral center with *sinister* (left-handed) configuration.
26    S,
27    /// Double-bond *entgegen* (opposite, trans) geometry.
28    E,
29    /// Double-bond *zusammen* (together, cis) geometry.
30    Z,
31    /// Pseudoasymmetric center, *rectus*-like (Rule 5, lowercase `r`). Emitted only by
32    /// `chematic_cip::assign_cip_accurate_experimental`'s Rule 5 pass; the default
33    /// `chematic_chem::assign_cip` never produces this variant.
34    LowerR,
35    /// Pseudoasymmetric center, *sinister*-like (Rule 5, lowercase `s`). See [`Self::LowerR`].
36    LowerS,
37}
38
39/// A single atom in a molecular graph.
40///
41/// - `isotope`: mass number (e.g. 13 for ¹³C). `None` = natural isotope abundance.
42/// - `charge`: formal charge.
43/// - `hydrogen_count`: explicit H count from a bracket atom `[...]`.
44///   `None` for organic-subset atoms whose H count is inferred from valence.
45/// - `aromatic`: set when the atom is written as a lowercase letter (c, n, …)
46///   or connected via `:` bonds.
47/// - `wildcard`: `true` for the SMILES `*` atom (any element, query context).
48/// - `atom_map`: atom-mapping number used in reaction SMILES.
49/// - `cip_code`: CIP stereodescriptor (R/S/E/Z). Populated by
50///   `chematic_chem::assign_cip`; `None` until then.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Atom {
53    pub element: Element,
54    pub isotope: Option<u16>,
55    pub charge: i8,
56    /// Explicit H count (bracket atoms only). `None` for organic-subset atoms.
57    pub hydrogen_count: Option<u8>,
58    pub aromatic: bool,
59    pub chirality: Chirality,
60    /// True for the wildcard atom `*` or `[*]`.
61    pub wildcard: bool,
62    pub atom_map: Option<u16>,
63    /// CIP stereodescriptor assigned by `chematic_chem::assign_cip`.
64    /// `None` until explicitly computed.
65    pub cip_code: Option<CipCode>,
66}
67
68impl Atom {
69    /// Create a plain, neutral, non-aromatic atom.
70    pub fn new(element: Element) -> Self {
71        Self {
72            element,
73            isotope: None,
74            charge: 0,
75            hydrogen_count: None,
76            aromatic: false,
77            chirality: Chirality::None,
78            wildcard: false,
79            atom_map: None,
80            cip_code: None,
81        }
82    }
83
84    /// Organic-subset atom (charge=0, non-aromatic, implicit H from valence).
85    pub fn organic(element: Element) -> Self {
86        Self::new(element)
87    }
88
89    /// Aromatic organic atom (lowercase SMILES notation).
90    pub fn aromatic(element: Element) -> Self {
91        Self {
92            aromatic: true,
93            ..Self::new(element)
94        }
95    }
96
97    /// Bracket atom with explicit properties.
98    pub fn bracket(
99        element: Element,
100        isotope: Option<u16>,
101        chirality: Chirality,
102        hydrogen_count: u8,
103        charge: i8,
104        atom_map: Option<u16>,
105    ) -> Self {
106        Self {
107            element,
108            isotope,
109            charge,
110            hydrogen_count: Some(hydrogen_count),
111            aromatic: false,
112            chirality,
113            wildcard: false,
114            atom_map,
115            cip_code: None,
116        }
117    }
118
119    /// Wildcard atom `*` / `[*]` (matches any element in query contexts).
120    pub fn wildcard() -> Self {
121        Self {
122            // Element is a placeholder; callers should check `wildcard` first.
123            element: Element::C,
124            wildcard: true,
125            hydrogen_count: Some(0),
126            ..Self::new(Element::C)
127        }
128    }
129
130    /// Return the explicit H count for bracket atoms; `None` for organic-subset atoms.
131    pub fn explicit_hcount(&self) -> Option<u8> {
132        self.hydrogen_count
133    }
134}
135
136impl core::fmt::Display for Atom {
137    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
138        if self.wildcard {
139            return write!(f, "*");
140        }
141        let symbol = if self.aromatic {
142            self.element.symbol().to_lowercase()
143        } else {
144            self.element.symbol().to_string()
145        };
146        match self.isotope {
147            Some(iso) => write!(f, "[{iso}{symbol}]"),
148            None => write!(f, "{symbol}"),
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_atom_new() {
159        let a = Atom::new(Element::C);
160        assert_eq!(a.element, Element::C);
161        assert_eq!(a.charge, 0);
162        assert!(!a.aromatic);
163        assert!(!a.wildcard);
164        assert_eq!(a.hydrogen_count, None);
165    }
166
167    #[test]
168    fn test_aromatic_atom() {
169        let a = Atom::aromatic(Element::C);
170        assert!(a.aromatic);
171    }
172
173    #[test]
174    fn test_wildcard_atom() {
175        let a = Atom::wildcard();
176        assert!(a.wildcard);
177        assert_eq!(format!("{a}"), "*");
178    }
179}