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 { 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#[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 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 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 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 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 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 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
216fn float_to_bond_order(v: f64) -> BondOrder {
218 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
229pub 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 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 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 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 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 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
306fn 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 BondOrder::QueryAny
320 | BondOrder::QuerySingleOrDouble
321 | BondOrder::QuerySingleOrAromatic
322 | BondOrder::QueryDoubleOrAromatic => 1.0,
323 }
324}
325
326#[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 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 assert!(coords.is_empty());
381 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 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}