1use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
13
14use crate::error::MolParseError;
15
16#[derive(Debug, Clone, PartialEq, Eq, Default)]
18pub struct MolMetadata {
19 pub name: String,
21 pub comment: String,
23}
24
25fn decode_charge(code: i8) -> i8 {
31 match code {
32 1 => 3,
33 2 => 2,
34 3 => 1,
35 4 => 0, 5 => -1,
37 6 => -2,
38 7 => -3,
39 _ => 0,
40 }
41}
42
43fn encode_charge(charge: i8) -> u8 {
45 match charge {
46 3 => 1,
47 2 => 2,
48 1 => 3,
49 -1 => 5,
50 -2 => 6,
51 -3 => 7,
52 _ => 0,
53 }
54}
55
56fn parse_field3(
65 line: &str,
66 start: usize,
67 line_num: usize,
68 make_err: impl Fn(usize, String) -> MolParseError,
69) -> Result<usize, MolParseError> {
70 let field = line.get(start..start + 3).ok_or_else(|| {
71 make_err(line_num, format!("line too short at column {start}"))
72 })?;
73 field.trim().parse::<usize>().map_err(|_| {
74 make_err(line_num, format!("cannot parse integer from '{field}'"))
75 })
76}
77
78pub fn parse_mol_with_coords(input: &str) -> Result<(Molecule, MolMetadata, Vec<(f64, f64)>), MolParseError> {
84 let mut lines = input
86 .lines()
87 .enumerate()
88 .map(|(i, l)| (i + 1, l));
89 let mut next_line = || lines.next().ok_or(MolParseError::UnexpectedEnd);
90
91 let name = next_line()?.1.to_string();
94 next_line()?; let comment = next_line()?.1.to_string();
96
97 let metadata = MolMetadata { name, comment };
98
99 let (counts_lineno, counts_line) = next_line()?;
102
103 if !counts_line.contains("V2000") {
105 return Err(MolParseError::InvalidCountLine {
106 line: counts_lineno,
107 detail: "missing V2000 version tag".to_string(),
108 });
109 }
110
111 let make_count_err = |ln: usize, d: String| MolParseError::InvalidCountLine { line: ln, detail: d };
112
113 let natoms = parse_field3(counts_line, 0, counts_lineno, make_count_err)?;
114 let nbonds = parse_field3(counts_line, 3, counts_lineno, make_count_err)?;
115
116 let mut builder = MoleculeBuilder::new();
119 let mut coords: Vec<(f64, f64)> = Vec::with_capacity(natoms);
120 let make_atom_err = |ln: usize, d: String| MolParseError::InvalidAtomLine { line: ln, detail: d };
121
122 for atom_i in 0..natoms {
123 let (raw_lineno, atom_line) = next_line()?;
124
125 let x: f64 = atom_line.get(0..10).and_then(|s| s.trim().parse().ok()).unwrap_or(0.0);
127 let y: f64 = atom_line.get(10..20).and_then(|s| s.trim().parse().ok()).unwrap_or(0.0);
128 coords.push((x, y));
129
130 let sym = atom_line.get(31..34).ok_or_else(|| {
133 make_atom_err(raw_lineno, format!("atom line {atom_i} too short for element field"))
134 })?.trim();
135
136 let element = Element::from_symbol(sym).ok_or_else(|| MolParseError::UnknownElement {
137 symbol: sym.to_string(),
138 line: raw_lineno,
139 })?;
140
141 let charge = atom_line
143 .get(36..39)
144 .map(|ccc| decode_charge(ccc.trim().parse().unwrap_or(0)))
145 .unwrap_or(0);
146
147 let mut atom = Atom::new(element);
148 atom.charge = charge;
149 builder.add_atom(atom);
150 }
151
152 let make_bond_err = |ln: usize, d: String| MolParseError::InvalidBondLine { line: ln, detail: d };
155
156 for bond_i in 0..nbonds {
157 let (raw_lineno, bond_line) = next_line()?;
158
159 let a1_raw = parse_field3(bond_line, 0, raw_lineno, make_bond_err)?;
160 let a2_raw = parse_field3(bond_line, 3, raw_lineno, make_bond_err)?;
161 let btype_raw = parse_field3(bond_line, 6, raw_lineno, make_bond_err)?;
162
163 if a1_raw == 0 || a2_raw == 0 {
164 return Err(MolParseError::InvalidBondLine {
165 line: raw_lineno,
166 detail: format!("bond {bond_i}: atom indices are 1-based; got {a1_raw}/{a2_raw}"),
167 });
168 }
169
170 let a1 = AtomIdx((a1_raw - 1) as u32);
171 let a2 = AtomIdx((a2_raw - 1) as u32);
172
173 let stereo_raw: usize = if bond_line.len() >= 12 {
175 parse_field3(bond_line, 9, raw_lineno, make_bond_err).unwrap_or(0)
176 } else {
177 0
178 };
179
180 let order = match btype_raw {
183 1 => match stereo_raw {
184 1 | 4 => BondOrder::Up,
185 6 => BondOrder::Down,
186 _ => BondOrder::Single,
187 },
188 2 => BondOrder::Double,
189 3 => BondOrder::Triple,
190 4 => BondOrder::Aromatic,
191 _ => BondOrder::Single,
192 };
193
194 builder.add_bond(a1, a2, order).map_err(|e| MolParseError::InvalidBondLine {
195 line: raw_lineno,
196 detail: format!("bond {bond_i}: {e}"),
197 })?;
198 }
199
200 for (_, l) in lines.by_ref() {
202 if l.trim_start().starts_with("M END") {
203 break;
204 }
205 }
206
207 Ok((builder.build(), metadata, coords))
208}
209
210pub fn parse_mol(input: &str) -> Result<(Molecule, MolMetadata), MolParseError> {
215 parse_mol_with_coords(input).map(|(mol, meta, _coords)| (mol, meta))
216}
217
218pub fn parse_sdf_with_coords(
225 input: &str,
226) -> Result<Vec<(Molecule, MolMetadata, Vec<(f64, f64)>)>, MolParseError> {
227 use crate::sdf::SdfReader;
228 let mut result = Vec::new();
231 let mut remaining = input;
232 loop {
233 while let Some(rest) = remaining.strip_prefix("\r\n").or_else(|| remaining.strip_prefix('\n')) {
235 remaining = rest;
236 }
237 if remaining.is_empty() { break; }
238
239 let mut byte_offset = 0usize;
241 let (end_byte, after_delim) = loop {
242 let rest = &remaining[byte_offset..];
243 match rest.find('\n') {
244 Some(nl) => {
245 let line = rest[..nl].trim_end_matches('\r');
246 if line == "$$$$" {
247 break (byte_offset, &remaining[byte_offset + nl + 1..]);
248 }
249 byte_offset += nl + 1;
250 }
251 None => {
252 if rest.trim_end_matches('\r') == "$$$$" {
253 break (byte_offset, "");
254 }
255 break (remaining.len(), "");
256 }
257 }
258 };
259
260 let block = &remaining[..end_byte];
261 remaining = after_delim;
262 if block.trim().is_empty() { continue; }
263
264 let (mol, meta, coords) = parse_mol_with_coords(block)?;
265 result.push((mol, meta, coords));
266 }
267 Ok(result)
268}
269
270pub fn write_mol(mol: &Molecule, metadata: &MolMetadata) -> String {
280 write_mol_with_coords(mol, metadata, &[])
281}
282
283pub fn write_mol_with_coords(
288 mol: &Molecule,
289 metadata: &MolMetadata,
290 coords: &[(f64, f64)],
291) -> String {
292 let mut out = String::new();
293
294 out.push_str(&metadata.name);
296 out.push('\n');
297 out.push_str(" chematic\n");
298 out.push_str(&metadata.comment);
299 out.push('\n');
300
301 let natoms = mol.atom_count();
303 let nbonds = mol.bond_count();
304 out.push_str(&format!(
305 "{:>3}{:>3} 0 0 0 0 0 0 0 0999 V2000\n",
306 natoms, nbonds
307 ));
308
309 for (idx, atom) in mol.atoms() {
311 let sym = atom.element.symbol();
312 let charge_code = encode_charge(atom.charge);
313 let (x, y) = coords.get(idx.0 as usize).copied().unwrap_or((0.0, 0.0));
314 out.push_str(&format!(
315 "{:>10.4}{:>10.4}{:>10.4} {:<3} 0{:>3} 0 0 0 0 0 0 0 0 0\n",
316 x, y, 0.0_f64,
317 sym,
318 charge_code,
319 ));
320 }
321
322 for (_idx, bond) in mol.bonds() {
324 let a1 = bond.atom1.0 + 1; let a2 = bond.atom2.0 + 1;
326 let btype = match bond.order {
327 BondOrder::Aromatic => 4,
328 _ => bond.order.order_int(),
329 };
330 out.push_str(&format!(
331 "{:>3}{:>3}{:>3} 0\n",
332 a1, a2, btype
333 ));
334 }
335
336 out.push_str("M END\n");
338
339 out
340}
341
342pub fn write_sdf(records: &[(&Molecule, &MolMetadata, &[(f64, f64)])]) -> String {
352 let mut out = String::new();
353 for (mol, meta, coords) in records {
354 out.push_str(&write_mol_with_coords(mol, meta, coords));
355 out.push_str("$$$$\n");
356 }
357 out
358}
359
360#[cfg(test)]
365mod tests {
366 use super::*;
367
368 const ETHANOL_MOL: &str = "\
370ethanol
371 chematic
372
373 3 2 0 0 0 0 0 0 0 0 0 V2000
374 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
375 1.5000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
376 3.0000 0.0000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0
377 1 2 1 0
378 2 3 1 0
379M END
380";
381
382 #[test]
383 fn test_parse_ethanol_counts() {
384 let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse should succeed");
385 assert_eq!(mol.atom_count(), 3);
386 assert_eq!(mol.bond_count(), 2);
387 assert_eq!(meta.name, "ethanol");
388 }
389
390 #[test]
391 fn test_parse_elements() {
392 let (mol, _) = parse_mol(ETHANOL_MOL).expect("parse should succeed");
393 let atoms: Vec<_> = mol.atoms().collect();
394 assert_eq!(atoms[0].1.element, Element::C);
395 assert_eq!(atoms[1].1.element, Element::C);
396 assert_eq!(atoms[2].1.element, Element::O);
397 }
398
399 #[test]
400 fn test_parse_bond_types() {
401 let mol_str = "\
403test
404 chematic
405
406 8 4 0 0 0 0 0 0 0 0 0 V2000
407 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
408 1.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
409 2.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
410 3.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
411 4.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
412 5.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
413 6.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
414 7.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
415 1 2 1 0
416 3 4 2 0
417 5 6 3 0
418 7 8 4 0
419M END
420";
421 let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
422 let bonds: Vec<_> = mol.bonds().collect();
423 assert_eq!(bonds[0].1.order, BondOrder::Single);
424 assert_eq!(bonds[1].1.order, BondOrder::Double);
425 assert_eq!(bonds[2].1.order, BondOrder::Triple);
426 assert_eq!(bonds[3].1.order, BondOrder::Aromatic);
427 }
428
429 #[test]
430 fn test_parse_charge() {
431 let mol_str = "\
433charged
434 chematic
435
436 1 0 0 0 0 0 0 0 0 0 0 V2000
437 0.0000 0.0000 0.0000 N 0 3 0 0 0 0 0 0 0 0 0 0
438M END
439";
440 let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
441 assert_eq!(mol.atom(AtomIdx(0)).charge, 1);
442 }
443
444 #[test]
445 fn test_parse_negative_charge() {
446 let mol_str = "\
448negcharge
449 chematic
450
451 1 0 0 0 0 0 0 0 0 0 0 V2000
452 0.0000 0.0000 0.0000 O 0 5 0 0 0 0 0 0 0 0 0 0
453M END
454";
455 let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
456 assert_eq!(mol.atom(AtomIdx(0)).charge, -1);
457 }
458
459 #[test]
460 fn test_round_trip() {
461 let (mol1, meta1) = parse_mol(ETHANOL_MOL).expect("first parse");
463 let written = write_mol(&mol1, &meta1);
464 let (mol2, _meta2) = parse_mol(&written).expect("second parse");
465 assert_eq!(mol1.atom_count(), mol2.atom_count());
466 assert_eq!(mol1.bond_count(), mol2.bond_count());
467 }
468
469 #[test]
470 fn test_round_trip_elements_preserved() {
471 let (mol1, meta1) = parse_mol(ETHANOL_MOL).expect("first parse");
472 let written = write_mol(&mol1, &meta1);
473 let (mol2, _) = parse_mol(&written).expect("second parse");
474 for ((_, a1), (_, a2)) in mol1.atoms().zip(mol2.atoms()) {
475 assert_eq!(a1.element, a2.element);
476 }
477 }
478
479 #[test]
480 fn test_error_missing_v2000() {
481 let bad = "\
482bad
483 prog
484
485 3 2 0 0 0 0 0 0 0 0 0 V3000
486 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
487M END
488";
489 assert!(matches!(
490 parse_mol(bad),
491 Err(MolParseError::InvalidCountLine { .. })
492 ));
493 }
494
495 #[test]
496 fn test_error_truncated_input() {
497 let bad = "\
499trunc
500 prog
501
502 3 0 0 0 0 0 0 0 0 0 0 V2000
503 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
504";
505 assert!(matches!(parse_mol(bad), Err(MolParseError::UnexpectedEnd)));
506 }
507
508 #[test]
509 fn test_error_invalid_counts_line() {
510 let bad = "\
512mol
513 prog
514
515 X Y
516M END
517";
518 assert!(matches!(
519 parse_mol(bad),
520 Err(MolParseError::InvalidCountLine { .. })
521 ));
522 }
523
524 #[test]
525 fn test_write_contains_m_end() {
526 let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse");
527 let written = write_mol(&mol, &meta);
528 assert!(written.contains("M END"));
529 }
530
531 #[test]
532 fn test_write_contains_v2000() {
533 let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse");
534 let written = write_mol(&mol, &meta);
535 assert!(written.contains("V2000"));
536 }
537
538 #[test]
539 fn test_parse_stereo_up_bond() {
540 let mol_str = "\n\n\n 2 1 0 0 0 0 999 V2000\n 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 1 1 0 0 0\nM END\n";
542 let (mol, _) = crate::parse_mol(mol_str).unwrap();
543 let bond = mol.bond(chematic_core::BondIdx(0));
544 assert_eq!(bond.order, chematic_core::BondOrder::Up);
545 }
546
547 #[test]
548 fn test_parse_stereo_down_bond() {
549 let mol_str = "\n\n\n 2 1 0 0 0 0 999 V2000\n 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 1 6 0 0 0\nM END\n";
550 let (mol, _) = crate::parse_mol(mol_str).unwrap();
551 let bond = mol.bond(chematic_core::BondIdx(0));
552 assert_eq!(bond.order, chematic_core::BondOrder::Down);
553 }
554}