Skip to main content

chematic_mol/
cjson.rs

1//! ChemicalJSON (.cjson) format parser and writer.
2//!
3//! ChemicalJSON is an open JSON format used by Avogadro, Avogadro2, and the
4//! MolSSI Open Chemistry toolkit.  It stores atoms, bonds, and optionally
5//! 3D coordinates, formal charges, and isotope labels.
6//!
7//! ## Format overview
8//!
9//! ```json
10//! {
11//!   "chemicalJson": 1,
12//!   "atoms": {
13//!     "coords": { "3d": [x0,y0,z0, x1,y1,z1, ...] },
14//!     "elements": { "number": [6, 7, 8, ...] },
15//!     "formalCharges": [0, 1, -1, ...],
16//!     "isotopes": [0, 13, 0, ...]
17//!   },
18//!   "bonds": {
19//!     "connections": { "index": [0,1, 1,2, ...] },
20//!     "order": [1, 2, 1.5, ...]
21//!   }
22//! }
23//! ```
24//!
25//! ## Bond order values
26//! `1` = single, `2` = double, `3` = triple, `1.5` or `4` = aromatic.
27//!
28//! ## References
29//! Open Chemistry wiki: <https://wiki.openchemistry.org/Chemical_JSON>
30
31#![forbid(unsafe_code)]
32
33use chematic_core::{Atom, BondOrder, Element, Molecule, MoleculeBuilder};
34
35// ─── Error ───────────────────────────────────────────────────────────────────
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum CjsonError {
39    InvalidJson(String),
40    UnknownAtomicNumber(u64),
41    InvalidBondIndex {
42        pair: usize,
43        atom_idx: u64,
44        n_atoms: usize,
45    },
46    MissingField(&'static str),
47    OddBondIndexList,
48    InvalidCoordCount {
49        expected: usize,
50        got: usize,
51    },
52}
53
54impl std::fmt::Display for CjsonError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            CjsonError::InvalidJson(s) => write!(f, "CJSON: invalid JSON: {s}"),
58            CjsonError::UnknownAtomicNumber(n) => {
59                write!(f, "CJSON: unknown atomic number {n}")
60            }
61            CjsonError::InvalidBondIndex { pair, atom_idx, n_atoms } => write!(
62                f,
63                "CJSON: bond pair {pair} references atom {atom_idx} (only {n_atoms} atoms)"
64            ),
65            CjsonError::MissingField(fld) => {
66                write!(f, "CJSON: missing required field '{fld}'")
67            }
68            CjsonError::OddBondIndexList => {
69                write!(f, "CJSON: bonds.connections.index must have an even length (pairs)")
70            }
71            CjsonError::InvalidCoordCount { expected, got } => write!(
72                f,
73                "CJSON: coords.3d length {got} is not divisible by 3 \
74                 or does not match atom count (expected {expected} × 3)"
75            ),
76        }
77    }
78}
79
80impl std::error::Error for CjsonError {}
81
82// ─── Parser ──────────────────────────────────────────────────────────────────
83
84/// Parse a ChemicalJSON string.
85///
86/// Returns `(molecule, coords)` where `coords` is a `Vec<(x, y, z)>` in Å.
87/// `coords` is empty when the file has no `atoms.coords.3d` field.
88#[allow(clippy::type_complexity)]
89pub fn parse_cjson(
90    input: &str,
91) -> Result<(Molecule, Vec<(f64, f64, f64)>), CjsonError> {
92    let v: serde_json::Value =
93        serde_json::from_str(input).map_err(|e| CjsonError::InvalidJson(e.to_string()))?;
94
95    let atoms_obj = v
96        .get("atoms")
97        .ok_or(CjsonError::MissingField("atoms"))?;
98
99    // ── Atomic numbers ───────────────────────────────────────────────────────
100    let numbers: Vec<u64> = atoms_obj
101        .get("elements")
102        .and_then(|e| e.get("number"))
103        .and_then(|n| n.as_array())
104        .ok_or(CjsonError::MissingField("atoms.elements.number"))?
105        .iter()
106        .map(|v| v.as_u64().unwrap_or(6))
107        .collect();
108    let n_atoms = numbers.len();
109
110    // ── Optional: formal charges ─────────────────────────────────────────────
111    let formal_charges: Vec<i8> = atoms_obj
112        .get("formalCharges")
113        .and_then(|fc| fc.as_array())
114        .map(|arr| arr.iter().map(|v| v.as_i64().unwrap_or(0) as i8).collect())
115        .unwrap_or_else(|| vec![0i8; n_atoms]);
116
117    // ── Optional: isotopes (0 = natural abundance) ───────────────────────────
118    let isotopes: Vec<Option<u16>> = atoms_obj
119        .get("isotopes")
120        .and_then(|iso| iso.as_array())
121        .map(|arr| {
122            arr.iter()
123                .map(|v| {
124                    let n = v.as_u64().unwrap_or(0);
125                    if n == 0 { None } else { Some(n as u16) }
126                })
127                .collect()
128        })
129        .unwrap_or_else(|| vec![None; n_atoms]);
130
131    // ── 3D coordinates (optional) ────────────────────────────────────────────
132    let coords: Vec<(f64, f64, f64)> = {
133        let flat: Option<Vec<f64>> = atoms_obj
134            .get("coords")
135            .and_then(|c| c.get("3d"))
136            .and_then(|c| c.as_array())
137            .map(|arr| arr.iter().map(|v| v.as_f64().unwrap_or(0.0)).collect());
138
139        match flat {
140            None => Vec::new(),
141            Some(flat) => {
142                if flat.len() % 3 != 0 || flat.len() / 3 != n_atoms {
143                    return Err(CjsonError::InvalidCoordCount {
144                        expected: n_atoms,
145                        got: flat.len(),
146                    });
147                }
148                flat.chunks_exact(3)
149                    .map(|c| (c[0], c[1], c[2]))
150                    .collect()
151            }
152        }
153    };
154
155    // ── Build atoms ──────────────────────────────────────────────────────────
156    let mut builder = MoleculeBuilder::new();
157    for (i, &an) in numbers.iter().enumerate() {
158        let element = Element::from_atomic_number(an as u8)
159            .ok_or(CjsonError::UnknownAtomicNumber(an))?;
160        let mut atom = Atom::new(element);
161        atom.charge = *formal_charges.get(i).unwrap_or(&0);
162        atom.isotope = isotopes.get(i).copied().flatten();
163        builder.add_atom(atom);
164    }
165
166    // ── Bonds ────────────────────────────────────────────────────────────────
167    if let Some(bonds_obj) = v.get("bonds") {
168        let index: Vec<u64> = bonds_obj
169            .get("connections")
170            .and_then(|c| c.get("index"))
171            .and_then(|i| i.as_array())
172            .ok_or(CjsonError::MissingField("bonds.connections.index"))?
173            .iter()
174            .map(|v| v.as_u64().unwrap_or(0))
175            .collect();
176
177        if !index.len().is_multiple_of(2) {
178            return Err(CjsonError::OddBondIndexList);
179        }
180
181        let orders: Vec<f64> = bonds_obj
182            .get("order")
183            .and_then(|o| o.as_array())
184            .map(|arr| arr.iter().map(|v| v.as_f64().unwrap_or(1.0)).collect())
185            .unwrap_or_else(|| vec![1.0; index.len() / 2]);
186
187        for (pair_idx, chunk) in index.chunks_exact(2).enumerate() {
188            let a = chunk[0];
189            let b = chunk[1];
190            if a as usize >= n_atoms {
191                return Err(CjsonError::InvalidBondIndex {
192                    pair: pair_idx,
193                    atom_idx: a,
194                    n_atoms,
195                });
196            }
197            if b as usize >= n_atoms {
198                return Err(CjsonError::InvalidBondIndex {
199                    pair: pair_idx,
200                    atom_idx: b,
201                    n_atoms,
202                });
203            }
204            let order = float_to_bond_order(*orders.get(pair_idx).unwrap_or(&1.0));
205            let _ = builder.add_bond(
206                chematic_core::AtomIdx(a as u32),
207                chematic_core::AtomIdx(b as u32),
208                order,
209            );
210        }
211    }
212
213    Ok((builder.build(), coords))
214}
215
216/// Map a float bond-order value to [`BondOrder`].
217fn float_to_bond_order(v: f64) -> BondOrder {
218    // 1.5 or 4 → Aromatic (different implementations use different values)
219    if (v - 1.5).abs() < 0.1 || (v - 4.0).abs() < 0.1 {
220        return BondOrder::Aromatic;
221    }
222    match v.round() as i64 {
223        2 => BondOrder::Double,
224        3 => BondOrder::Triple,
225        _ => BondOrder::Single,
226    }
227}
228
229// ─── Writer ──────────────────────────────────────────────────────────────────
230
231/// Write a [`Molecule`] and optional 3D coordinates as a ChemicalJSON string.
232///
233/// `coords` may be empty; if so the `atoms.coords` section is omitted and
234/// Avogadro will open the file without 3D geometry.
235pub fn write_cjson(mol: &Molecule, coords: &[(f64, f64, f64)]) -> String {
236    use serde_json::{Value, json};
237
238    let n = mol.atom_count();
239
240    // Atomic numbers
241    let numbers: Vec<Value> = (0..n)
242        .map(|i| json!(mol.atom(chematic_core::AtomIdx(i as u32)).element.atomic_number()))
243        .collect();
244
245    // Formal charges (omit section if all zero)
246    let charges: Vec<i8> = (0..n)
247        .map(|i| mol.atom(chematic_core::AtomIdx(i as u32)).charge)
248        .collect();
249    let has_charges = charges.iter().any(|&c| c != 0);
250
251    // Isotopes (omit section if none set)
252    let isotopes: Vec<Option<u16>> = (0..n)
253        .map(|i| mol.atom(chematic_core::AtomIdx(i as u32)).isotope)
254        .collect();
255    let has_isotopes = isotopes.iter().any(|iso| iso.is_some());
256
257    // Build atoms object
258    let mut atoms_obj = json!({
259        "elements": { "number": numbers }
260    });
261
262    if !coords.is_empty() && coords.len() == n {
263        let flat: Vec<f64> = coords
264            .iter()
265            .flat_map(|&(x, y, z)| [x, y, z])
266            .collect();
267        atoms_obj["coords"] = json!({ "3d": flat });
268    }
269
270    if has_charges {
271        atoms_obj["formalCharges"] =
272            json!(charges.iter().map(|&c| c as i64).collect::<Vec<_>>());
273    }
274
275    if has_isotopes {
276        let iso_vals: Vec<u64> = isotopes
277            .iter()
278            .map(|iso| iso.map(|n| n as u64).unwrap_or(0))
279            .collect();
280        atoms_obj["isotopes"] = json!(iso_vals);
281    }
282
283    // Bonds
284    let mut bond_index: Vec<u64> = Vec::new();
285    let mut bond_orders: Vec<Value> = Vec::new();
286    for (_, bond) in mol.bonds() {
287        bond_index.push(bond.atom1.0 as u64);
288        bond_index.push(bond.atom2.0 as u64);
289        bond_orders.push(json!(bond_order_to_float(bond.order)));
290    }
291
292    let bonds_obj = json!({
293        "connections": { "index": bond_index },
294        "order": bond_orders
295    });
296
297    let root = json!({
298        "chemicalJson": 1,
299        "atoms": atoms_obj,
300        "bonds": bonds_obj,
301    });
302
303    serde_json::to_string(&root).unwrap_or_default()
304}
305
306/// Map a [`BondOrder`] to the float value used in ChemicalJSON.
307fn bond_order_to_float(order: BondOrder) -> f64 {
308    match order {
309        BondOrder::Single
310        | BondOrder::Up
311        | BondOrder::Down
312        | BondOrder::Zero
313        | BondOrder::Dative => 1.0,
314        BondOrder::Double => 2.0,
315        BondOrder::Triple => 3.0,
316        BondOrder::Quadruple => 4.0,
317        BondOrder::Aromatic => 1.5,
318        // Query bonds: fall back to single
319        BondOrder::QueryAny
320        | BondOrder::QuerySingleOrDouble
321        | BondOrder::QuerySingleOrAromatic
322        | BondOrder::QueryDoubleOrAromatic => 1.0,
323    }
324}
325
326// ─── Tests ───────────────────────────────────────────────────────────────────
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use chematic_core::BondOrder;
332
333    fn make_ethanol_cjson() -> &'static str {
334        r#"{
335  "chemicalJson": 1,
336  "atoms": {
337    "coords": { "3d": [0.0,0.0,0.0, 1.54,0.0,0.0, 2.5,1.0,0.0] },
338    "elements": { "number": [6, 6, 8] },
339    "formalCharges": [0, 0, 0]
340  },
341  "bonds": {
342    "connections": { "index": [0,1, 1,2] },
343    "order": [1, 1]
344  }
345}"#
346    }
347
348    fn make_benzene_cjson() -> &'static str {
349        r#"{
350  "chemicalJson": 1,
351  "atoms": {
352    "elements": { "number": [6,6,6,6,6,6] }
353  },
354  "bonds": {
355    "connections": { "index": [0,1, 1,2, 2,3, 3,4, 4,5, 5,0] },
356    "order": [1.5, 1.5, 1.5, 1.5, 1.5, 1.5]
357  }
358}"#
359    }
360
361    #[test]
362    fn parse_ethanol() {
363        let (mol, coords) = parse_cjson(make_ethanol_cjson()).unwrap();
364        assert_eq!(mol.atom_count(), 3);
365        assert_eq!(mol.bond_count(), 2);
366        assert_eq!(coords.len(), 3);
367        assert!((coords[0].0 - 0.0).abs() < 1e-9);
368        assert!((coords[1].0 - 1.54).abs() < 1e-9);
369        // O is atomic number 8
370        let o_idx = chematic_core::AtomIdx(2);
371        assert_eq!(mol.atom(o_idx).element.atomic_number(), 8);
372    }
373
374    #[test]
375    fn parse_benzene_aromatic_bonds() {
376        let (mol, coords) = parse_cjson(make_benzene_cjson()).unwrap();
377        assert_eq!(mol.atom_count(), 6);
378        assert_eq!(mol.bond_count(), 6);
379        // coords absent → empty
380        assert!(coords.is_empty());
381        // All bonds should be aromatic
382        for (_, bond) in mol.bonds() {
383            assert_eq!(
384                bond.order,
385                BondOrder::Aromatic,
386                "expected Aromatic, got {:?}",
387                bond.order
388            );
389        }
390    }
391
392    #[test]
393    fn parse_with_isotope() {
394        let cjson = r#"{
395  "chemicalJson": 1,
396  "atoms": {
397    "elements": { "number": [6, 1] },
398    "isotopes": [13, 2]
399  },
400  "bonds": {
401    "connections": { "index": [0,1] },
402    "order": [1]
403  }
404}"#;
405        let (mol, _) = parse_cjson(cjson).unwrap();
406        assert_eq!(mol.atom(chematic_core::AtomIdx(0)).isotope, Some(13));
407        assert_eq!(mol.atom(chematic_core::AtomIdx(1)).isotope, Some(2));
408    }
409
410    #[test]
411    fn parse_with_charge() {
412        let cjson = r#"{
413  "chemicalJson": 1,
414  "atoms": {
415    "elements": { "number": [7] },
416    "formalCharges": [1]
417  },
418  "bonds": { "connections": { "index": [] }, "order": [] }
419}"#;
420        let (mol, _) = parse_cjson(cjson).unwrap();
421        assert_eq!(mol.atom(chematic_core::AtomIdx(0)).charge, 1);
422    }
423
424    #[test]
425    fn roundtrip_with_coords() {
426        let coords = vec![(0.0, 0.0, 0.0), (1.54, 0.0, 0.0), (2.5, 1.0, 0.0)];
427        let (orig, _) = parse_cjson(make_ethanol_cjson()).unwrap();
428        let written = write_cjson(&orig, &coords);
429        let (parsed, coords2) = parse_cjson(&written).unwrap();
430        assert_eq!(orig.atom_count(), parsed.atom_count());
431        assert_eq!(orig.bond_count(), parsed.bond_count());
432        assert_eq!(coords2.len(), 3);
433        assert!((coords2[1].0 - 1.54).abs() < 1e-6);
434    }
435
436    #[test]
437    fn write_no_coords() {
438        let (mol, _) = parse_cjson(make_benzene_cjson()).unwrap();
439        let written = write_cjson(&mol, &[]);
440        let v: serde_json::Value = serde_json::from_str(&written).unwrap();
441        // No coords.3d field when no coords given
442        assert!(v["atoms"].get("coords").is_none());
443        assert_eq!(v["chemicalJson"].as_u64(), Some(1));
444    }
445
446    #[test]
447    fn error_unknown_atomic_number() {
448        let cjson = r#"{"chemicalJson":1,"atoms":{"elements":{"number":[999]}},"bonds":{"connections":{"index":[]},"order":[]}}"#;
449        assert!(matches!(
450            parse_cjson(cjson),
451            Err(CjsonError::UnknownAtomicNumber(999))
452        ));
453    }
454
455    #[test]
456    fn error_missing_atoms() {
457        let cjson = r#"{"chemicalJson":1}"#;
458        assert!(matches!(
459            parse_cjson(cjson),
460            Err(CjsonError::MissingField("atoms"))
461        ));
462    }
463
464    #[test]
465    fn bond_order_roundtrip() {
466        for &(order, expected_float) in &[
467            (BondOrder::Single, 1.0_f64),
468            (BondOrder::Double, 2.0),
469            (BondOrder::Triple, 3.0),
470            (BondOrder::Aromatic, 1.5),
471        ] {
472            let f = bond_order_to_float(order);
473            assert!((f - expected_float).abs() < 1e-9);
474            assert_eq!(float_to_bond_order(f), order);
475        }
476    }
477}