1#![forbid(unsafe_code)]
37
38use chematic_core::{Atom, BondOrder, Element, Molecule, MoleculeBuilder};
39
40#[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
73pub 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#[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 let mol_node: &serde_json::Value = if v.get("atoms").is_some() {
96 &v
97 } else if let Some(root) = v.get("root") {
98 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 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); 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 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 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() } 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) .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 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 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, };
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
260pub 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
270pub 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#[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 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 let _v: serde_json::Value = serde_json::from_str(&out).unwrap();
443 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}