1#![forbid(unsafe_code)]
30
31use chematic_core::{
32 Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder, implicit_hcount,
33};
34use std::collections::HashMap;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum MolJsonError {
40 InvalidJson(String),
41 UnknownElement(String),
42 InvalidBondRef { bond_id: String, ref_id: String },
43 MissingField(&'static str),
44}
45
46impl std::fmt::Display for MolJsonError {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 MolJsonError::InvalidJson(s) => write!(f, "MolJSON: invalid JSON: {s}"),
50 MolJsonError::UnknownElement(s) => write!(f, "MolJSON: unknown element symbol '{s}'"),
51 MolJsonError::InvalidBondRef { bond_id, ref_id } => write!(
52 f,
53 "MolJSON: bond '{bond_id}' references unknown atom id '{ref_id}'"
54 ),
55 MolJsonError::MissingField(fld) => {
56 write!(f, "MolJSON: missing required field '{fld}'")
57 }
58 }
59 }
60}
61
62impl std::error::Error for MolJsonError {}
63
64pub fn parse_moljson(input: &str) -> Result<Molecule, MolJsonError> {
73 let v: serde_json::Value =
74 serde_json::from_str(input).map_err(|e| MolJsonError::InvalidJson(e.to_string()))?;
75
76 let atoms_arr = v
77 .get("atoms")
78 .and_then(|a| a.as_array())
79 .ok_or(MolJsonError::MissingField("atoms"))?;
80
81 let mut builder = MoleculeBuilder::new();
83 let mut id_to_idx: HashMap<String, AtomIdx> = HashMap::new();
84
85 for atom_val in atoms_arr {
86 let id = atom_val
87 .get("id")
88 .and_then(|v| v.as_str())
89 .ok_or(MolJsonError::MissingField("atoms[].id"))?
90 .to_owned();
91
92 let element_sym = atom_val
93 .get("element")
94 .and_then(|v| v.as_str())
95 .ok_or(MolJsonError::MissingField("atoms[].element"))?;
96
97 let element = Element::from_symbol(element_sym)
98 .ok_or_else(|| MolJsonError::UnknownElement(element_sym.to_owned()))?;
99
100 let charge = atom_val.get("charge").and_then(|v| v.as_i64()).unwrap_or(0) as i8;
101
102 let isotope = atom_val.get("isotope").and_then(|v| {
103 if v.is_null() {
104 None
105 } else {
106 v.as_u64().map(|n| n as u16)
107 }
108 });
109
110 let aromatic = atom_val
111 .get("aromatic")
112 .and_then(|v| v.as_bool())
113 .unwrap_or(false);
114
115 let mut atom = Atom::new(element);
116 atom.charge = charge;
117 atom.isotope = isotope;
118 atom.aromatic = aromatic;
119
120 let idx = builder.add_atom(atom);
121 id_to_idx.insert(id, idx);
122 }
123
124 if let Some(bonds_arr) = v.get("bonds").and_then(|b| b.as_array()) {
126 for bond_val in bonds_arr {
127 let bond_id = bond_val
128 .get("id")
129 .and_then(|v| v.as_str())
130 .unwrap_or("?")
131 .to_owned();
132
133 let src = bond_val
134 .get("source_id")
135 .and_then(|v| v.as_str())
136 .ok_or(MolJsonError::MissingField("bonds[].source_id"))?;
137 let tgt = bond_val
138 .get("target_id")
139 .and_then(|v| v.as_str())
140 .ok_or(MolJsonError::MissingField("bonds[].target_id"))?;
141
142 let src_idx =
143 id_to_idx
144 .get(src)
145 .copied()
146 .ok_or_else(|| MolJsonError::InvalidBondRef {
147 bond_id: bond_id.clone(),
148 ref_id: src.to_owned(),
149 })?;
150 let tgt_idx =
151 id_to_idx
152 .get(tgt)
153 .copied()
154 .ok_or_else(|| MolJsonError::InvalidBondRef {
155 bond_id: bond_id.clone(),
156 ref_id: tgt.to_owned(),
157 })?;
158
159 let aromatic = bond_val
160 .get("aromatic")
161 .and_then(|v| v.as_bool())
162 .unwrap_or(false);
163 let order_f = bond_val
164 .get("order")
165 .and_then(|v| v.as_f64())
166 .unwrap_or(1.0);
167 let order = if aromatic {
168 BondOrder::Aromatic
169 } else {
170 float_to_bond_order(order_f)
171 };
172
173 let _ = builder.add_bond(src_idx, tgt_idx, order);
174 }
175 }
176
177 Ok(builder.build())
178}
179
180fn float_to_bond_order(v: f64) -> BondOrder {
181 if (v - 1.5).abs() < 0.1 || (v - 4.0).abs() < 0.1 {
182 return BondOrder::Aromatic;
183 }
184 match v.round() as i64 {
185 2 => BondOrder::Double,
186 3 => BondOrder::Triple,
187 _ => BondOrder::Single,
188 }
189}
190
191pub fn write_moljson(mol: &Molecule) -> String {
199 use serde_json::{Value, json};
200
201 let n = mol.atom_count();
202
203 let atoms: Vec<Value> = (0..n)
204 .map(|i| {
205 let idx = AtomIdx(i as u32);
206 let atom = mol.atom(idx);
207 let h = implicit_hcount(mol, idx);
208 let isotope: Value = match atom.isotope {
209 Some(iso) => json!(iso as u64),
210 None => Value::Null,
211 };
212 json!({
213 "id": format!("a{}", i + 1),
214 "element": atom.element.symbol(),
215 "charge": atom.charge as i64,
216 "isotope": isotope,
217 "hydrogens": h as u64,
218 "aromatic": atom.aromatic
219 })
220 })
221 .collect();
222
223 let bonds: Vec<Value> = mol
224 .bonds()
225 .enumerate()
226 .map(|(i, (_, bond))| {
227 let aromatic = bond.order == BondOrder::Aromatic;
228 let order = bond_order_to_float(bond.order);
229 json!({
230 "id": format!("b{}", i + 1),
231 "source_id": format!("a{}", bond.atom1.0 + 1),
232 "target_id": format!("a{}", bond.atom2.0 + 1),
233 "order": order,
234 "aromatic": aromatic
235 })
236 })
237 .collect();
238
239 let root = json!({
240 "atoms": atoms,
241 "bonds": bonds
242 });
243
244 serde_json::to_string_pretty(&root).unwrap_or_default()
245}
246
247fn bond_order_to_float(order: BondOrder) -> f64 {
248 match order {
249 BondOrder::Single
250 | BondOrder::Up
251 | BondOrder::Down
252 | BondOrder::Zero
253 | BondOrder::Dative => 1.0,
254 BondOrder::Double => 2.0,
255 BondOrder::Triple => 3.0,
256 BondOrder::Quadruple => 4.0,
257 BondOrder::Aromatic => 1.5,
258 BondOrder::QueryAny
259 | BondOrder::QuerySingleOrDouble
260 | BondOrder::QuerySingleOrAromatic
261 | BondOrder::QueryDoubleOrAromatic => 1.0,
262 }
263}
264
265#[cfg(test)]
268mod tests {
269 use super::*;
270 use chematic_smiles::{canonical_smiles, parse};
271
272 fn smiles_roundtrip(smi: &str) -> String {
273 let mol = parse(smi).expect("parse SMILES");
274 let json = write_moljson(&mol);
275 let mol2 = parse_moljson(&json).expect("parse MolJSON");
276 canonical_smiles(&mol2)
277 }
278
279 #[test]
280 fn roundtrip_ethanol() {
281 let cs = smiles_roundtrip("CCO");
282 assert_eq!(cs, canonical_smiles(&parse("CCO").unwrap()));
284 }
285
286 #[test]
287 fn roundtrip_benzene() {
288 let cs = smiles_roundtrip("c1ccccc1");
289 assert_eq!(cs, canonical_smiles(&parse("c1ccccc1").unwrap()));
290 }
291
292 #[test]
293 fn roundtrip_charged() {
294 let cs = smiles_roundtrip("[Cl-]");
296 assert_eq!(cs, canonical_smiles(&parse("[Cl-]").unwrap()));
297 }
298
299 #[test]
300 fn roundtrip_charged_carbon() {
301 let mol = parse("CC[CH2-]").unwrap();
303 let json_str = write_moljson(&mol);
304 let mol2 = parse_moljson(&json_str).unwrap();
305 assert_eq!(mol2.atom_count(), 3);
306 assert_eq!(mol2.atom(AtomIdx(2)).charge, -1);
307 }
308
309 #[test]
310 fn roundtrip_isotope() {
311 let mol = parse("[13C]").unwrap();
312 let json = write_moljson(&mol);
313 let mol2 = parse_moljson(&json).unwrap();
314 assert_eq!(mol2.atom(AtomIdx(0)).isotope, Some(13));
315 }
316
317 #[test]
318 fn roundtrip_aromatic_nh() {
319 let mol = parse("c1cc[nH]c1").unwrap();
323 let json_str = write_moljson(&mol);
324 let mol2 = parse_moljson(&json_str).unwrap();
325 assert_eq!(mol2.atom_count(), 5);
326 for (idx, atom) in mol.atoms() {
328 assert_eq!(
329 atom.aromatic,
330 mol2.atom(idx).aromatic,
331 "atom {idx:?} aromatic mismatch"
332 );
333 }
334 }
335
336 #[test]
337 fn hydrogens_count_in_output() {
338 let mol = parse("CCO").unwrap();
339 let json_str = write_moljson(&mol);
340 let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
341 assert_eq!(v["atoms"][0]["hydrogens"].as_u64(), Some(3));
343 assert_eq!(v["atoms"][2]["hydrogens"].as_u64(), Some(1));
345 }
346
347 #[test]
348 fn atom_ids_sequential() {
349 let mol = parse("CC").unwrap();
350 let json_str = write_moljson(&mol);
351 let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
352 assert_eq!(v["atoms"][0]["id"].as_str(), Some("a1"));
353 assert_eq!(v["atoms"][1]["id"].as_str(), Some("a2"));
354 assert_eq!(v["bonds"][0]["id"].as_str(), Some("b1"));
355 assert_eq!(v["bonds"][0]["source_id"].as_str(), Some("a1"));
356 assert_eq!(v["bonds"][0]["target_id"].as_str(), Some("a2"));
357 }
358
359 #[test]
360 fn parse_rejects_invalid_json() {
361 assert!(matches!(
362 parse_moljson("not json"),
363 Err(MolJsonError::InvalidJson(_))
364 ));
365 }
366
367 #[test]
368 fn parse_rejects_bad_bond_ref() {
369 let bad = r#"{"atoms":[{"id":"a1","element":"C","charge":0,"isotope":null,"hydrogens":4,"aromatic":false}],"bonds":[{"id":"b1","source_id":"a1","target_id":"a99","order":1.0,"aromatic":false}]}"#;
370 assert!(matches!(
371 parse_moljson(bad),
372 Err(MolJsonError::InvalidBondRef { .. })
373 ));
374 }
375
376 #[test]
377 fn parse_rejects_unknown_element() {
378 let bad = r#"{"atoms":[{"id":"a1","element":"Xx","charge":0,"isotope":null,"hydrogens":0,"aromatic":false}],"bonds":[]}"#;
379 assert!(matches!(
380 parse_moljson(bad),
381 Err(MolJsonError::UnknownElement(_))
382 ));
383 }
384
385 #[test]
386 fn roundtrip_aspirin() {
387 let cs = smiles_roundtrip("CC(=O)Oc1ccccc1C(=O)O");
388 assert_eq!(
389 cs,
390 canonical_smiles(&parse("CC(=O)Oc1ccccc1C(=O)O").unwrap())
391 );
392 }
393}