Skip to main content

chematic_mol/
moljson.rs

1//! MolJSON format parser and writer.
2//!
3//! MolJSON is a JSON-based molecular representation designed for LLM
4//! (large language model) compatibility.  Unlike SMILES, it makes atoms,
5//! bonds, and connectivity explicit without requiring domain-specific parsing
6//! rules, which makes it easier for LLMs to generate and interpret.
7//!
8//! ## Format overview
9//!
10//! ```json
11//! {
12//!   "atoms": [
13//!     {"id": "a1", "element": "C", "charge": 0, "isotope": null,
14//!      "hydrogens": 3, "aromatic": false},
15//!     {"id": "a2", "element": "O", "charge": 0, "isotope": null,
16//!      "hydrogens": 1, "aromatic": false}
17//!   ],
18//!   "bonds": [
19//!     {"id": "b1", "source_id": "a1", "target_id": "a2",
20//!      "order": 1.0, "aromatic": false}
21//!   ]
22//! }
23//! ```
24//!
25//! ## References
26//! - Guo et al. "Molecular Representations for Large Language Models" (2026)
27//! - oxpig/MolJSON: <https://github.com/oxpig/MolJSON>
28
29#![forbid(unsafe_code)]
30
31use chematic_core::{
32    Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder, implicit_hcount,
33};
34use std::collections::HashMap;
35
36// ─── Error ───────────────────────────────────────────────────────────────────
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum MolJsonError {
40    InvalidJson(String),
41    UnknownElement(String),
42    InvalidBondRef { bond_id: String, ref_id: String },
43    MissingField(&'static str),
44}
45
46impl std::fmt::Display for MolJsonError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            MolJsonError::InvalidJson(s) => write!(f, "MolJSON: invalid JSON: {s}"),
50            MolJsonError::UnknownElement(s) => write!(f, "MolJSON: unknown element symbol '{s}'"),
51            MolJsonError::InvalidBondRef { bond_id, ref_id } => write!(
52                f,
53                "MolJSON: bond '{bond_id}' references unknown atom id '{ref_id}'"
54            ),
55            MolJsonError::MissingField(fld) => {
56                write!(f, "MolJSON: missing required field '{fld}'")
57            }
58        }
59    }
60}
61
62impl std::error::Error for MolJsonError {}
63
64// ─── Parser ──────────────────────────────────────────────────────────────────
65
66/// Parse a MolJSON string into a [`Molecule`].
67///
68/// Atom IDs (`"a1"`, `"a2"`, …) are used only internally during parsing to
69/// resolve bond references; they are not stored on the returned molecule.
70/// The `hydrogens` field is informational and is ignored during parsing —
71/// implicit H counts are recomputed from valence rules by chematic-core.
72pub fn parse_moljson(input: &str) -> Result<Molecule, MolJsonError> {
73    let v: serde_json::Value =
74        serde_json::from_str(input).map_err(|e| MolJsonError::InvalidJson(e.to_string()))?;
75
76    let atoms_arr = v
77        .get("atoms")
78        .and_then(|a| a.as_array())
79        .ok_or(MolJsonError::MissingField("atoms"))?;
80
81    // ── Build atoms and id→AtomIdx map ──────────────────────────────────────
82    let mut builder = MoleculeBuilder::new();
83    let mut id_to_idx: HashMap<String, AtomIdx> = HashMap::new();
84
85    for atom_val in atoms_arr {
86        let id = atom_val
87            .get("id")
88            .and_then(|v| v.as_str())
89            .ok_or(MolJsonError::MissingField("atoms[].id"))?
90            .to_owned();
91
92        let element_sym = atom_val
93            .get("element")
94            .and_then(|v| v.as_str())
95            .ok_or(MolJsonError::MissingField("atoms[].element"))?;
96
97        let element = Element::from_symbol(element_sym)
98            .ok_or_else(|| MolJsonError::UnknownElement(element_sym.to_owned()))?;
99
100        let charge = atom_val.get("charge").and_then(|v| v.as_i64()).unwrap_or(0) as i8;
101
102        let isotope = atom_val.get("isotope").and_then(|v| {
103            if v.is_null() {
104                None
105            } else {
106                v.as_u64().map(|n| n as u16)
107            }
108        });
109
110        let aromatic = atom_val
111            .get("aromatic")
112            .and_then(|v| v.as_bool())
113            .unwrap_or(false);
114
115        let mut atom = Atom::new(element);
116        atom.charge = charge;
117        atom.isotope = isotope;
118        atom.aromatic = aromatic;
119
120        let idx = builder.add_atom(atom);
121        id_to_idx.insert(id, idx);
122    }
123
124    // ── Bonds ────────────────────────────────────────────────────────────────
125    if let Some(bonds_arr) = v.get("bonds").and_then(|b| b.as_array()) {
126        for bond_val in bonds_arr {
127            let bond_id = bond_val
128                .get("id")
129                .and_then(|v| v.as_str())
130                .unwrap_or("?")
131                .to_owned();
132
133            let src = bond_val
134                .get("source_id")
135                .and_then(|v| v.as_str())
136                .ok_or(MolJsonError::MissingField("bonds[].source_id"))?;
137            let tgt = bond_val
138                .get("target_id")
139                .and_then(|v| v.as_str())
140                .ok_or(MolJsonError::MissingField("bonds[].target_id"))?;
141
142            let src_idx =
143                id_to_idx
144                    .get(src)
145                    .copied()
146                    .ok_or_else(|| MolJsonError::InvalidBondRef {
147                        bond_id: bond_id.clone(),
148                        ref_id: src.to_owned(),
149                    })?;
150            let tgt_idx =
151                id_to_idx
152                    .get(tgt)
153                    .copied()
154                    .ok_or_else(|| MolJsonError::InvalidBondRef {
155                        bond_id: bond_id.clone(),
156                        ref_id: tgt.to_owned(),
157                    })?;
158
159            let aromatic = bond_val
160                .get("aromatic")
161                .and_then(|v| v.as_bool())
162                .unwrap_or(false);
163            let order_f = bond_val
164                .get("order")
165                .and_then(|v| v.as_f64())
166                .unwrap_or(1.0);
167            let order = if aromatic {
168                BondOrder::Aromatic
169            } else {
170                float_to_bond_order(order_f)
171            };
172
173            let _ = builder.add_bond(src_idx, tgt_idx, order);
174        }
175    }
176
177    Ok(builder.build())
178}
179
180fn float_to_bond_order(v: f64) -> BondOrder {
181    if (v - 1.5).abs() < 0.1 || (v - 4.0).abs() < 0.1 {
182        return BondOrder::Aromatic;
183    }
184    match v.round() as i64 {
185        2 => BondOrder::Double,
186        3 => BondOrder::Triple,
187        _ => BondOrder::Single,
188    }
189}
190
191// ─── Writer ──────────────────────────────────────────────────────────────────
192
193/// Serialize a [`Molecule`] to a MolJSON string (pretty-printed).
194///
195/// Atom IDs are assigned as `"a1"`, `"a2"`, … in molecule atom order.
196/// Bond IDs are assigned as `"b1"`, `"b2"`, … in bond iteration order.
197/// The `hydrogens` field reflects computed implicit H count.
198pub fn write_moljson(mol: &Molecule) -> String {
199    use serde_json::{Value, json};
200
201    let n = mol.atom_count();
202
203    let atoms: Vec<Value> = (0..n)
204        .map(|i| {
205            let idx = AtomIdx(i as u32);
206            let atom = mol.atom(idx);
207            let h = implicit_hcount(mol, idx);
208            let isotope: Value = match atom.isotope {
209                Some(iso) => json!(iso as u64),
210                None => Value::Null,
211            };
212            json!({
213                "id": format!("a{}", i + 1),
214                "element": atom.element.symbol(),
215                "charge": atom.charge as i64,
216                "isotope": isotope,
217                "hydrogens": h as u64,
218                "aromatic": atom.aromatic
219            })
220        })
221        .collect();
222
223    let bonds: Vec<Value> = mol
224        .bonds()
225        .enumerate()
226        .map(|(i, (_, bond))| {
227            let aromatic = bond.order == BondOrder::Aromatic;
228            let order = bond_order_to_float(bond.order);
229            json!({
230                "id": format!("b{}", i + 1),
231                "source_id": format!("a{}", bond.atom1.0 + 1),
232                "target_id": format!("a{}", bond.atom2.0 + 1),
233                "order": order,
234                "aromatic": aromatic
235            })
236        })
237        .collect();
238
239    let root = json!({
240        "atoms": atoms,
241        "bonds": bonds
242    });
243
244    serde_json::to_string_pretty(&root).unwrap_or_default()
245}
246
247fn bond_order_to_float(order: BondOrder) -> f64 {
248    match order {
249        BondOrder::Single
250        | BondOrder::Up
251        | BondOrder::Down
252        | BondOrder::Zero
253        | BondOrder::Dative => 1.0,
254        BondOrder::Double => 2.0,
255        BondOrder::Triple => 3.0,
256        BondOrder::Quadruple => 4.0,
257        BondOrder::Aromatic => 1.5,
258        BondOrder::QueryAny
259        | BondOrder::QuerySingleOrDouble
260        | BondOrder::QuerySingleOrAromatic
261        | BondOrder::QueryDoubleOrAromatic => 1.0,
262    }
263}
264
265// ─── Tests ───────────────────────────────────────────────────────────────────
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use chematic_smiles::{canonical_smiles, parse};
271
272    fn smiles_roundtrip(smi: &str) -> String {
273        let mol = parse(smi).expect("parse SMILES");
274        let json = write_moljson(&mol);
275        let mol2 = parse_moljson(&json).expect("parse MolJSON");
276        canonical_smiles(&mol2)
277    }
278
279    #[test]
280    fn roundtrip_ethanol() {
281        let cs = smiles_roundtrip("CCO");
282        // Canonical SMILES for ethanol
283        assert_eq!(cs, canonical_smiles(&parse("CCO").unwrap()));
284    }
285
286    #[test]
287    fn roundtrip_benzene() {
288        let cs = smiles_roundtrip("c1ccccc1");
289        assert_eq!(cs, canonical_smiles(&parse("c1ccccc1").unwrap()));
290    }
291
292    #[test]
293    fn roundtrip_charged() {
294        // [Cl-]: unambiguous H count (0), roundtrips cleanly
295        let cs = smiles_roundtrip("[Cl-]");
296        assert_eq!(cs, canonical_smiles(&parse("[Cl-]").unwrap()));
297    }
298
299    #[test]
300    fn roundtrip_charged_carbon() {
301        // Simple negatively charged carbon
302        let mol = parse("CC[CH2-]").unwrap();
303        let json_str = write_moljson(&mol);
304        let mol2 = parse_moljson(&json_str).unwrap();
305        assert_eq!(mol2.atom_count(), 3);
306        assert_eq!(mol2.atom(AtomIdx(2)).charge, -1);
307    }
308
309    #[test]
310    fn roundtrip_isotope() {
311        let mol = parse("[13C]").unwrap();
312        let json = write_moljson(&mol);
313        let mol2 = parse_moljson(&json).unwrap();
314        assert_eq!(mol2.atom(AtomIdx(0)).isotope, Some(13));
315    }
316
317    #[test]
318    fn roundtrip_aromatic_nh() {
319        // Verify aromaticity flag and atom count survive roundtrip.
320        // Note: bracket H notation ([nH]) is not stored explicitly and is
321        // recomputed from valence rules; SMILES string may differ.
322        let mol = parse("c1cc[nH]c1").unwrap();
323        let json_str = write_moljson(&mol);
324        let mol2 = parse_moljson(&json_str).unwrap();
325        assert_eq!(mol2.atom_count(), 5);
326        // All atoms should retain their aromatic flags
327        for (idx, atom) in mol.atoms() {
328            assert_eq!(
329                atom.aromatic,
330                mol2.atom(idx).aromatic,
331                "atom {idx:?} aromatic mismatch"
332            );
333        }
334    }
335
336    #[test]
337    fn hydrogens_count_in_output() {
338        let mol = parse("CCO").unwrap();
339        let json_str = write_moljson(&mol);
340        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
341        // First atom (C in CH3-): 3 implicit H
342        assert_eq!(v["atoms"][0]["hydrogens"].as_u64(), Some(3));
343        // Third atom (O in -OH): 1 implicit H
344        assert_eq!(v["atoms"][2]["hydrogens"].as_u64(), Some(1));
345    }
346
347    #[test]
348    fn atom_ids_sequential() {
349        let mol = parse("CC").unwrap();
350        let json_str = write_moljson(&mol);
351        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
352        assert_eq!(v["atoms"][0]["id"].as_str(), Some("a1"));
353        assert_eq!(v["atoms"][1]["id"].as_str(), Some("a2"));
354        assert_eq!(v["bonds"][0]["id"].as_str(), Some("b1"));
355        assert_eq!(v["bonds"][0]["source_id"].as_str(), Some("a1"));
356        assert_eq!(v["bonds"][0]["target_id"].as_str(), Some("a2"));
357    }
358
359    #[test]
360    fn parse_rejects_invalid_json() {
361        assert!(matches!(
362            parse_moljson("not json"),
363            Err(MolJsonError::InvalidJson(_))
364        ));
365    }
366
367    #[test]
368    fn parse_rejects_bad_bond_ref() {
369        let bad = r#"{"atoms":[{"id":"a1","element":"C","charge":0,"isotope":null,"hydrogens":4,"aromatic":false}],"bonds":[{"id":"b1","source_id":"a1","target_id":"a99","order":1.0,"aromatic":false}]}"#;
370        assert!(matches!(
371            parse_moljson(bad),
372            Err(MolJsonError::InvalidBondRef { .. })
373        ));
374    }
375
376    #[test]
377    fn parse_rejects_unknown_element() {
378        let bad = r#"{"atoms":[{"id":"a1","element":"Xx","charge":0,"isotope":null,"hydrogens":0,"aromatic":false}],"bonds":[]}"#;
379        assert!(matches!(
380            parse_moljson(bad),
381            Err(MolJsonError::UnknownElement(_))
382        ));
383    }
384
385    #[test]
386    fn roundtrip_aspirin() {
387        let cs = smiles_roundtrip("CC(=O)Oc1ccccc1C(=O)O");
388        assert_eq!(
389            cs,
390            canonical_smiles(&parse("CC(=O)Oc1ccccc1C(=O)O").unwrap())
391        );
392    }
393}