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