1#![forbid(unsafe_code)]
32
33use chematic_core::{Atom, BondOrder, Element, Molecule, MoleculeBuilder};
34
35#[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#[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 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 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 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 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 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 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
217fn float_to_bond_order(v: f64) -> BondOrder {
219 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
230pub 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 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 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 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 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 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
309fn 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 BondOrder::QueryAny
323 | BondOrder::QuerySingleOrDouble
324 | BondOrder::QuerySingleOrAromatic
325 | BondOrder::QueryDoubleOrAromatic => 1.0,
326 }
327}
328
329#[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 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 assert!(coords.is_empty());
384 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 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}