Skip to main content

chematic_mol/
ket.rs

1//! KET (Ketcher JSON) format parser and writer.
2//!
3//! KET is the native JSON format used by [Ketcher](https://github.com/epam/ketcher),
4//! an open-source web-based chemical structure editor.  It is also accepted by EPAM's
5//! Indigo toolkit.
6//!
7//! ## Format overview
8//!
9//! ```json
10//! {
11//!   "root": { "nodes": [{ "$ref": "mol0" }] },
12//!   "mol0": {
13//!     "type": "molecule",
14//!     "atoms": [
15//!       { "label": "C", "location": [0.0, 0.0, 0.0], "charge": 0 }
16//!     ],
17//!     "bonds": [
18//!       { "type": 1, "atoms": [0, 1] }
19//!     ]
20//!   }
21//! }
22//! ```
23//!
24//! A simpler flat variant (no `root`/`molN` nesting) is also accepted:
25//! ```json
26//! { "atoms": [...], "bonds": [...] }
27//! ```
28//!
29//! ## Bond type codes
30//! 1=single, 2=double, 3=triple, 4=aromatic, 5=single-or-double,
31//! 6=single-or-aromatic, 7=double-or-aromatic, 8=any
32//!
33//! ## Stereo bond stereo codes
34//! 1=up (wedge), 6=down (hash/dash), 4=either (wavy)
35
36#![forbid(unsafe_code)]
37
38use chematic_core::{Atom, BondOrder, Element, Molecule, MoleculeBuilder};
39
40// ─── Error ───────────────────────────────────────────────────────────────────
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum KetError {
44    InvalidJson(String),
45    UnknownElement(String),
46    InvalidAtomIndex {
47        bond_idx: usize,
48        atom_idx: usize,
49        natoms: usize,
50    },
51    MissingField(&'static str),
52}
53
54impl std::fmt::Display for KetError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            KetError::InvalidJson(s) => write!(f, "KET: invalid JSON: {s}"),
58            KetError::UnknownElement(sym) => write!(f, "KET: unknown element '{sym}'"),
59            KetError::InvalidAtomIndex {
60                bond_idx,
61                atom_idx,
62                natoms,
63            } => write!(
64                f,
65                "KET: bond {bond_idx} references atom {atom_idx} (only {natoms} atoms)"
66            ),
67            KetError::MissingField(fld) => write!(f, "KET: missing required field '{fld}'"),
68        }
69    }
70}
71impl std::error::Error for KetError {}
72
73// ─── Parser ──────────────────────────────────────────────────────────────────
74
75/// Parse a KET (Ketcher JSON) string.
76///
77/// Returns `(molecule, coords_2d)` where `coords_2d` is the XY plane projection
78/// of the KET `location` field (Z is discarded for 2D use).
79/// For 3D use, call [`parse_ket_3d`] instead.
80pub fn parse_ket(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), KetError> {
81    let (mol, coords3) = parse_ket_3d(input)?;
82    let coords2 = coords3.iter().map(|&(x, y, _z)| (x, y)).collect();
83    Ok((mol, coords2))
84}
85
86/// Parse a KET string and return 3D coordinates `(x, y, z)`.
87#[allow(clippy::type_complexity)]
88pub fn parse_ket_3d(input: &str) -> Result<(Molecule, Vec<(f64, f64, f64)>), KetError> {
89    let v: serde_json::Value =
90        serde_json::from_str(input).map_err(|e| KetError::InvalidJson(e.to_string()))?;
91
92    // Locate the molecule node.  Two supported layouts:
93    // 1. Flat:   { "atoms": [...], "bonds": [...] }
94    // 2. Nested: { "root": { "nodes": [{"$ref":"mol0"}] }, "mol0": { "atoms":[...], "bonds":[...] } }
95    let mol_node: &serde_json::Value = if v.get("atoms").is_some() {
96        &v
97    } else if let Some(root) = v.get("root") {
98        // Find first $ref under root.nodes
99        let mol_ref = root
100            .get("nodes")
101            .and_then(|n| n.as_array())
102            .and_then(|arr| arr.first())
103            .and_then(|n| n.get("$ref"))
104            .and_then(|r| r.as_str())
105            .ok_or(KetError::MissingField("root.nodes[0].$ref"))?;
106        v.get(mol_ref)
107            .ok_or(KetError::MissingField("molecule node referenced by $ref"))?
108    } else {
109        return Err(KetError::MissingField("atoms"));
110    };
111
112    let atoms_json = mol_node
113        .get("atoms")
114        .and_then(|a| a.as_array())
115        .ok_or(KetError::MissingField("atoms"))?;
116
117    // Guard against memory-DoS from enormous KET payloads.
118    const MAX_ATOMS: usize = 10_000;
119    const MAX_BONDS: usize = 20_000;
120    if atoms_json.len() > MAX_ATOMS {
121        return Err(KetError::InvalidJson(format!(
122            "KET file exceeds atom limit ({} > {MAX_ATOMS})",
123            atoms_json.len()
124        )));
125    }
126
127    let empty_bonds = vec![];
128    let bonds_json = mol_node
129        .get("bonds")
130        .and_then(|b| b.as_array())
131        .unwrap_or(&empty_bonds); // bonds is optional (single-atom molecules)
132
133    if bonds_json.len() > MAX_BONDS {
134        return Err(KetError::InvalidJson(format!(
135            "KET file exceeds bond limit ({} > {MAX_BONDS})",
136            bonds_json.len()
137        )));
138    }
139
140    // ── Atoms ─────────────────────────────────────────────────────────────────
141    let mut builder = MoleculeBuilder::new();
142    let mut coords: Vec<(f64, f64, f64)> = Vec::with_capacity(atoms_json.len());
143
144    for atom_v in atoms_json {
145        let label = atom_v
146            .get("label")
147            .and_then(|l| l.as_str())
148            .ok_or(KetError::MissingField("atom.label"))?;
149
150        // Handle special labels:
151        // "*" = any-atom wildcard
152        // "R", "R#", "R1"–"R9"… = R-group placeholders (Ketcher uses "R#" or "R1" etc.)
153        // NOTE: must NOT catch real elements that start with 'R' (Ru, Rh, Re, Rn, Ra, Rb, Rf, Rg).
154        let is_rgroup = label == "*"
155            || label == "R"
156            || (label.starts_with('R')
157                && label
158                    .chars()
159                    .nth(1)
160                    .is_some_and(|c| c.is_ascii_digit() || c == '#'));
161        let element = if is_rgroup {
162            Element::from_symbol("C").unwrap() // fallback wildcard → carbon placeholder
163        } else {
164            Element::from_symbol(label)
165                .ok_or_else(|| KetError::UnknownElement(label.to_string()))?
166        };
167
168        let charge = atom_v.get("charge").and_then(|c| c.as_i64()).unwrap_or(0) as i8;
169
170        let isotope = atom_v
171            .get("isotope")
172            .and_then(|i| i.as_u64())
173            .filter(|&i| i != 0 && i <= u16::MAX as u64) // guard against silent u64→u16 truncation
174            .map(|i| i as u16);
175
176        let explicit_h = atom_v
177            .get("explicitHydrogens")
178            .and_then(|h| h.as_i64())
179            .filter(|&h| h >= 0)
180            .map(|h| h as u8);
181
182        let wildcard = label == "*";
183
184        let mut atom = Atom::new(element);
185        atom.charge = charge;
186        atom.isotope = isotope;
187        atom.hydrogen_count = explicit_h;
188        atom.wildcard = wildcard;
189        builder.add_atom(atom);
190
191        let loc = atom_v.get("location").and_then(|l| l.as_array());
192        let (x, y, z) = if let Some(loc) = loc {
193            let x = loc.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
194            let y = loc.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0);
195            let z = loc.get(2).and_then(|v| v.as_f64()).unwrap_or(0.0);
196            (x, y, z)
197        } else {
198            (0.0, 0.0, 0.0)
199        };
200        coords.push((x, y, z));
201    }
202
203    let natoms = atoms_json.len();
204
205    // ── Bonds ─────────────────────────────────────────────────────────────────
206    for (bidx, bond_v) in bonds_json.iter().enumerate() {
207        let bond_type = bond_v.get("type").and_then(|t| t.as_u64()).unwrap_or(1) as u8;
208
209        let atom_refs = bond_v
210            .get("atoms")
211            .and_then(|a| a.as_array())
212            .ok_or(KetError::MissingField("bond.atoms"))?;
213
214        let a = atom_refs
215            .first()
216            .and_then(|v| v.as_u64())
217            .ok_or(KetError::MissingField("bond.atoms[0]"))? as usize;
218        let b = atom_refs
219            .get(1)
220            .and_then(|v| v.as_u64())
221            .ok_or(KetError::MissingField("bond.atoms[1]"))? as usize;
222
223        if a >= natoms {
224            return Err(KetError::InvalidAtomIndex {
225                bond_idx: bidx,
226                atom_idx: a,
227                natoms,
228            });
229        }
230        if b >= natoms {
231            return Err(KetError::InvalidAtomIndex {
232                bond_idx: bidx,
233                atom_idx: b,
234                natoms,
235            });
236        }
237
238        // Stereo from bond.stereo field (optional)
239        let stereo = bond_v.get("stereo").and_then(|s| s.as_u64()).unwrap_or(0);
240
241        let order = match bond_type {
242            1 => match stereo {
243                1 => BondOrder::Up,
244                6 => BondOrder::Down,
245                _ => BondOrder::Single,
246            },
247            2 => BondOrder::Double,
248            3 => BondOrder::Triple,
249            4 => BondOrder::Aromatic,
250            _ => BondOrder::Single, // query / any bond types → single
251        };
252
253        use chematic_core::AtomIdx;
254        let _ = builder.add_bond(AtomIdx(a as u32), AtomIdx(b as u32), order);
255    }
256
257    Ok((builder.build(), coords))
258}
259
260// ─── Writer ───────────────────────────────────────────────────────────────────
261
262/// Write a molecule to KET (Ketcher JSON) format with 2D coordinates.
263///
264/// Produces a compact KET string using the nested `root`/`mol0` layout.
265pub fn write_ket(mol: &Molecule, coords: &[(f64, f64)]) -> String {
266    let coords3: Vec<(f64, f64, f64)> = coords.iter().map(|&(x, y)| (x, y, 0.0)).collect();
267    write_ket_3d(mol, &coords3)
268}
269
270/// Write a molecule to KET format with 3D coordinates.
271pub fn write_ket_3d(mol: &Molecule, coords: &[(f64, f64, f64)]) -> String {
272    use serde_json::{Value, json};
273
274    let atoms: Vec<Value> = (0..mol.atom_count())
275        .map(|i| {
276            use chematic_core::AtomIdx;
277            let atom = mol.atom(AtomIdx(i as u32));
278            let (x, y, z) = coords.get(i).copied().unwrap_or((0.0, 0.0, 0.0));
279            let mut a = json!({
280                "label": atom.element.symbol(),
281                "location": [x, y, z],
282            });
283            if atom.charge != 0 {
284                a["charge"] = json!(atom.charge);
285            }
286            if let Some(iso) = atom.isotope {
287                a["isotope"] = json!(iso);
288            }
289            a
290        })
291        .collect();
292
293    let bonds: Vec<Value> = (0..mol.bond_count())
294        .map(|i| {
295            use chematic_core::BondIdx;
296            let bond = mol.bond(BondIdx(i as u32));
297            let (bond_type, stereo) = match bond.order {
298                BondOrder::Single => (1u8, 0u8),
299                BondOrder::Up => (1, 1),
300                BondOrder::Down => (1, 6),
301                BondOrder::Double => (2, 0),
302                BondOrder::Triple => (3, 0),
303                BondOrder::Aromatic => (4, 0),
304                _ => (1, 0),
305            };
306            let mut b = json!({
307                "type": bond_type,
308                "atoms": [bond.atom1.0, bond.atom2.0],
309            });
310            if stereo != 0 {
311                b["stereo"] = json!(stereo);
312            }
313            b
314        })
315        .collect();
316
317    let mol_obj = json!({
318        "type": "molecule",
319        "atoms": atoms,
320        "bonds": bonds,
321    });
322
323    let root = json!({
324        "root": {
325            "nodes": [{ "$ref": "mol0" }]
326        },
327        "mol0": mol_obj
328    });
329
330    serde_json::to_string(&root).unwrap_or_default()
331}
332
333// ─── Tests ────────────────────────────────────────────────────────────────────
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use chematic_core::BondOrder;
339
340    const ETHANOL_KET: &str = r#"
341    {
342      "root": { "nodes": [{ "$ref": "mol0" }] },
343      "mol0": {
344        "type": "molecule",
345        "atoms": [
346          { "label": "C", "location": [0.0, 0.0, 0.0] },
347          { "label": "C", "location": [1.5, 0.0, 0.0] },
348          { "label": "O", "location": [3.0, 0.0, 0.0] }
349        ],
350        "bonds": [
351          { "type": 1, "atoms": [0, 1] },
352          { "type": 1, "atoms": [1, 2] }
353        ]
354      }
355    }"#;
356
357    const FLAT_BENZENE: &str = r#"
358    {
359      "atoms": [
360        {"label": "C", "location": [0.0, 1.0, 0.0]},
361        {"label": "C", "location": [0.866, 0.5, 0.0]},
362        {"label": "C", "location": [0.866, -0.5, 0.0]},
363        {"label": "C", "location": [0.0, -1.0, 0.0]},
364        {"label": "C", "location": [-0.866, -0.5, 0.0]},
365        {"label": "C", "location": [-0.866, 0.5, 0.0]}
366      ],
367      "bonds": [
368        {"type": 4, "atoms": [0, 1]},
369        {"type": 4, "atoms": [1, 2]},
370        {"type": 4, "atoms": [2, 3]},
371        {"type": 4, "atoms": [3, 4]},
372        {"type": 4, "atoms": [4, 5]},
373        {"type": 4, "atoms": [5, 0]}
374      ]
375    }"#;
376
377    #[test]
378    fn parse_ket_ethanol_nested() {
379        let (mol, coords) = parse_ket(ETHANOL_KET).unwrap();
380        assert_eq!(mol.atom_count(), 3);
381        assert_eq!(mol.bond_count(), 2);
382        assert_eq!(coords.len(), 3);
383        assert_eq!(mol.atom(chematic_core::AtomIdx(2)).element.symbol(), "O");
384    }
385
386    #[test]
387    fn parse_ket_benzene_flat() {
388        let (mol, _) = parse_ket(FLAT_BENZENE).unwrap();
389        assert_eq!(mol.atom_count(), 6);
390        assert_eq!(mol.bond_count(), 6);
391        // All bonds should be aromatic
392        for i in 0..mol.bond_count() {
393            assert_eq!(
394                mol.bond(chematic_core::BondIdx(i as u32)).order,
395                BondOrder::Aromatic
396            );
397        }
398    }
399
400    #[test]
401    fn ket_round_trip_ethanol() {
402        let (mol, coords) = parse_ket(ETHANOL_KET).unwrap();
403        let out = write_ket(&mol, &coords);
404        let (mol2, coords2) = parse_ket(&out).unwrap();
405        assert_eq!(mol.atom_count(), mol2.atom_count());
406        assert_eq!(mol.bond_count(), mol2.bond_count());
407        for (i, ((x1, y1), (x2, y2))) in coords.iter().zip(coords2.iter()).enumerate() {
408            assert!((x1 - x2).abs() < 1e-9, "coord x mismatch at {i}");
409            assert!((y1 - y2).abs() < 1e-9, "coord y mismatch at {i}");
410        }
411    }
412
413    #[test]
414    fn parse_ket_charged_atom() {
415        let ket = r#"{"atoms":[{"label":"N","location":[0,0,0],"charge":1}],"bonds":[]}"#;
416        let (mol, _) = parse_ket(ket).unwrap();
417        assert_eq!(mol.atom(chematic_core::AtomIdx(0)).charge, 1);
418    }
419
420    #[test]
421    fn parse_ket_stereo_bonds() {
422        let ket = r#"{"atoms":[
423            {"label":"C","location":[0,0,0]},
424            {"label":"F","location":[1,0,0]},
425            {"label":"Cl","location":[0,1,0]}
426        ],"bonds":[
427            {"type":1,"atoms":[0,1],"stereo":1},
428            {"type":1,"atoms":[0,2],"stereo":6}
429        ]}"#;
430        let (mol, _) = parse_ket(ket).unwrap();
431        assert_eq!(mol.bond(chematic_core::BondIdx(0)).order, BondOrder::Up);
432        assert_eq!(mol.bond(chematic_core::BondIdx(1)).order, BondOrder::Down);
433    }
434
435    #[test]
436    fn write_ket_produces_valid_json() {
437        use chematic_smiles::parse;
438        let mol = parse("CCO").unwrap();
439        let coords = vec![(0.0, 0.0), (1.5, 0.0), (3.0, 0.0)];
440        let out = write_ket(&mol, &coords);
441        // Must parse as JSON
442        let _v: serde_json::Value = serde_json::from_str(&out).unwrap();
443        // Must round-trip
444        let (mol2, _) = parse_ket(&out).unwrap();
445        assert_eq!(mol.atom_count(), mol2.atom_count());
446        assert_eq!(mol.bond_count(), mol2.bond_count());
447    }
448
449    #[test]
450    fn parse_ket_unknown_element_errors() {
451        let ket = r#"{"atoms":[{"label":"Xx","location":[0,0,0]}],"bonds":[]}"#;
452        assert!(matches!(parse_ket(ket), Err(KetError::UnknownElement(_))));
453    }
454
455    #[test]
456    fn parse_ket_bad_bond_index_errors() {
457        let ket =
458            r#"{"atoms":[{"label":"C","location":[0,0,0]}],"bonds":[{"type":1,"atoms":[0,99]}]}"#;
459        assert!(matches!(
460            parse_ket(ket),
461            Err(KetError::InvalidAtomIndex { .. })
462        ));
463    }
464}