1use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
13
14use crate::error::MolParseError;
15
16const MAX_ATOMS: usize = 100_000;
18
19const MAX_BONDS: usize = 200_000;
21
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
24pub struct MolMetadata {
25 pub name: String,
27 pub comment: String,
29}
30
31impl MolMetadata {
32 pub fn with_name(mut self, name: &str) -> Self {
34 self.name = name.to_owned();
35 self
36 }
37
38 pub fn with_comment(mut self, comment: &str) -> Self {
40 self.comment = comment.to_owned();
41 self
42 }
43}
44
45fn decode_charge(code: i8) -> i8 {
51 match code {
52 1 => 3,
53 2 => 2,
54 3 => 1,
55 4 => 0, 5 => -1,
57 6 => -2,
58 7 => -3,
59 _ => 0,
60 }
61}
62
63fn encode_charge(charge: i8) -> u8 {
65 match charge {
66 3 => 1,
67 2 => 2,
68 1 => 3,
69 -1 => 5,
70 -2 => 6,
71 -3 => 7,
72 _ => 0,
73 }
74}
75
76fn parse_field3(
85 line: &str,
86 start: usize,
87 line_num: usize,
88 make_err: impl Fn(usize, String) -> MolParseError,
89) -> Result<usize, MolParseError> {
90 let field = line
91 .get(start..start + 3)
92 .ok_or_else(|| make_err(line_num, format!("line too short at column {start}")))?;
93 field
94 .trim()
95 .parse::<usize>()
96 .map_err(|_| make_err(line_num, format!("cannot parse integer from '{field}'")))
97}
98
99#[allow(clippy::type_complexity)]
105pub fn parse_mol_with_coords(
106 input: &str,
107) -> Result<(Molecule, MolMetadata, Vec<(f64, f64)>), MolParseError> {
108 let mut lines = input.lines().enumerate().map(|(i, l)| (i + 1, l));
110 let mut next_line = || lines.next().ok_or(MolParseError::UnexpectedEnd);
111
112 let name = next_line()?.1.to_string();
115 next_line()?; let comment = next_line()?.1.to_string();
117
118 let metadata = MolMetadata { name, comment };
119
120 let (counts_lineno, counts_line) = next_line()?;
123
124 if !counts_line.contains("V2000") {
126 return Err(MolParseError::InvalidCountLine {
127 line: counts_lineno,
128 detail: "missing V2000 version tag".to_string(),
129 });
130 }
131
132 let make_count_err = |ln: usize, d: String| MolParseError::InvalidCountLine {
133 line: ln,
134 detail: d,
135 };
136
137 let natoms = parse_field3(counts_line, 0, counts_lineno, make_count_err)?;
138 let nbonds = parse_field3(counts_line, 3, counts_lineno, make_count_err)?;
139
140 if natoms > MAX_ATOMS {
141 return Err(MolParseError::InvalidCountLine {
142 line: counts_lineno,
143 detail: format!(
144 "atom count {} exceeds maximum allowed {}",
145 natoms, MAX_ATOMS
146 ),
147 });
148 }
149
150 if nbonds > MAX_BONDS {
151 return Err(MolParseError::InvalidCountLine {
152 line: counts_lineno,
153 detail: format!(
154 "bond count {} exceeds maximum allowed {}",
155 nbonds, MAX_BONDS
156 ),
157 });
158 }
159
160 let mut builder = MoleculeBuilder::new();
163 let mut coords: Vec<(f64, f64)> = Vec::with_capacity(natoms);
164 let make_atom_err = |ln: usize, d: String| MolParseError::InvalidAtomLine {
165 line: ln,
166 detail: d,
167 };
168
169 for atom_i in 0..natoms {
170 let (raw_lineno, atom_line) = next_line()?;
171
172 let x: f64 = atom_line
174 .get(0..10)
175 .and_then(|s| s.trim().parse().ok())
176 .unwrap_or(0.0);
177 let y: f64 = atom_line
178 .get(10..20)
179 .and_then(|s| s.trim().parse().ok())
180 .unwrap_or(0.0);
181 coords.push((x, y));
182
183 let sym = atom_line
186 .get(31..34)
187 .ok_or_else(|| {
188 make_atom_err(
189 raw_lineno,
190 format!("atom line {atom_i} too short for element field"),
191 )
192 })?
193 .trim();
194
195 let element = Element::from_symbol(sym).ok_or_else(|| MolParseError::UnknownElement {
196 symbol: sym.to_string(),
197 line: raw_lineno,
198 })?;
199
200 let charge = atom_line
202 .get(36..39)
203 .map(|ccc| decode_charge(ccc.trim().parse().unwrap_or(0)))
204 .unwrap_or(0);
205
206 let mut atom = Atom::new(element);
207 atom.charge = charge;
208 builder.add_atom(atom);
209 }
210
211 let make_bond_err = |ln: usize, d: String| MolParseError::InvalidBondLine {
214 line: ln,
215 detail: d,
216 };
217
218 for bond_i in 0..nbonds {
219 let (raw_lineno, bond_line) = next_line()?;
220
221 let a1_raw = parse_field3(bond_line, 0, raw_lineno, make_bond_err)?;
222 let a2_raw = parse_field3(bond_line, 3, raw_lineno, make_bond_err)?;
223 let btype_raw = parse_field3(bond_line, 6, raw_lineno, make_bond_err)?;
224
225 if a1_raw == 0 || a2_raw == 0 {
226 return Err(MolParseError::InvalidBondLine {
227 line: raw_lineno,
228 detail: format!("bond {bond_i}: atom indices are 1-based; got {a1_raw}/{a2_raw}"),
229 });
230 }
231
232 let a1 = AtomIdx((a1_raw - 1) as u32);
233 let a2 = AtomIdx((a2_raw - 1) as u32);
234
235 let stereo_raw: usize = if bond_line.len() >= 12 {
237 parse_field3(bond_line, 9, raw_lineno, make_bond_err).unwrap_or(0)
238 } else {
239 0
240 };
241
242 let order = match btype_raw {
243 1 => match stereo_raw {
244 1 | 4 => BondOrder::Up,
245 6 => BondOrder::Down,
246 _ => BondOrder::Single,
247 },
248 2 => BondOrder::Double,
249 3 => BondOrder::Triple,
250 4 => BondOrder::Aromatic,
251 5 => BondOrder::QuerySingleOrDouble,
252 6 => BondOrder::QuerySingleOrAromatic,
253 7 => BondOrder::QueryDoubleOrAromatic,
254 8 => BondOrder::QueryAny,
255 _ => BondOrder::Single,
256 };
257
258 builder
259 .add_bond(a1, a2, order)
260 .map_err(|e| MolParseError::InvalidBondLine {
261 line: raw_lineno,
262 detail: format!("bond {bond_i}: {e}"),
263 })?;
264 }
265
266 for (_, l) in lines.by_ref() {
268 if l.trim_start().starts_with("M END") {
269 break;
270 }
271 }
272
273 Ok((builder.build(), metadata, coords))
274}
275
276pub fn parse_mol(input: &str) -> Result<(Molecule, MolMetadata), MolParseError> {
281 parse_mol_with_coords(input).map(|(mol, meta, _coords)| (mol, meta))
282}
283
284#[allow(clippy::type_complexity)]
291pub fn parse_sdf_with_coords(
292 input: &str,
293) -> Result<Vec<(Molecule, MolMetadata, Vec<(f64, f64)>)>, MolParseError> {
294 let mut result = Vec::new();
297 let mut remaining = input;
298 loop {
299 while let Some(rest) = remaining
301 .strip_prefix("\r\n")
302 .or_else(|| remaining.strip_prefix('\n'))
303 {
304 remaining = rest;
305 }
306 if remaining.is_empty() {
307 break;
308 }
309
310 let mut byte_offset = 0usize;
312 let (end_byte, after_delim) = loop {
313 let rest = &remaining[byte_offset..];
314 match rest.find('\n') {
315 Some(nl) => {
316 let line = rest[..nl].trim_end_matches('\r');
317 if line == "$$$$" {
318 break (byte_offset, &remaining[byte_offset + nl + 1..]);
319 }
320 byte_offset += nl + 1;
321 }
322 None => {
323 if rest.trim_end_matches('\r') == "$$$$" {
324 break (byte_offset, "");
325 }
326 break (remaining.len(), "");
327 }
328 }
329 };
330
331 let block = &remaining[..end_byte];
332 remaining = after_delim;
333 if block.trim().is_empty() {
334 continue;
335 }
336
337 let (mol, meta, coords) = parse_mol_with_coords(block)?;
338 result.push((mol, meta, coords));
339 }
340 Ok(result)
341}
342
343pub fn write_mol(mol: &Molecule, metadata: &MolMetadata) -> String {
353 write_mol_with_coords(mol, metadata, &[])
354}
355
356pub fn write_mol_with_coords(
361 mol: &Molecule,
362 metadata: &MolMetadata,
363 coords: &[(f64, f64)],
364) -> String {
365 let mut out = String::new();
366
367 out.push_str(&metadata.name);
369 out.push('\n');
370 out.push_str(" chematic\n");
371 out.push_str(&metadata.comment);
372 out.push('\n');
373
374 let natoms = mol.atom_count();
376 let nbonds = mol.bond_count();
377 out.push_str(&format!(
378 "{:>3}{:>3} 0 0 0 0 0 0 0 0999 V2000\n",
379 natoms, nbonds
380 ));
381
382 for (idx, atom) in mol.atoms() {
384 let sym = atom.element.symbol();
385 let charge_code = encode_charge(atom.charge);
386 let (x, y) = coords.get(idx.0 as usize).copied().unwrap_or((0.0, 0.0));
387 out.push_str(&format!(
388 "{:>10.4}{:>10.4}{:>10.4} {:<3} 0{:>3} 0 0 0 0 0 0 0 0 0\n",
389 x, y, 0.0_f64, sym, charge_code,
390 ));
391 }
392
393 for (_idx, bond) in mol.bonds() {
395 let a1 = bond.atom1.0 + 1; let a2 = bond.atom2.0 + 1;
397 let btype = match bond.order {
398 BondOrder::Single | BondOrder::Up | BondOrder::Down | BondOrder::Dative => 1,
399 BondOrder::Double => 2,
400 BondOrder::Triple => 3,
401 BondOrder::Aromatic => 4,
402 BondOrder::QuerySingleOrDouble => 5,
403 BondOrder::QuerySingleOrAromatic => 6,
404 BondOrder::QueryDoubleOrAromatic => 7,
405 BondOrder::QueryAny | BondOrder::Zero => 8,
406 BondOrder::Quadruple => 4,
407 };
408 out.push_str(&format!("{:>3}{:>3}{:>3} 0\n", a1, a2, btype));
409 }
410
411 out.push_str("M END\n");
413
414 out
415}
416
417#[allow(clippy::type_complexity)]
427pub fn write_sdf(records: &[(&Molecule, &MolMetadata, &[(f64, f64)])]) -> String {
428 let mut out = String::new();
429 for (mol, meta, coords) in records {
430 out.push_str(&write_mol_with_coords(mol, meta, coords));
431 out.push_str("$$$$\n");
432 }
433 out
434}
435
436#[allow(clippy::type_complexity)]
451pub fn write_sdf_with_charges(
452 records: &[(&Molecule, &MolMetadata, &[(f64, f64)], &[f64])],
453) -> String {
454 let mut out = String::new();
455 for (mol, meta, coords, charges) in records {
456 out.push_str(&write_mol_with_coords(mol, meta, coords));
457 if !charges.is_empty() {
458 out.push_str("> <PARTIAL_CHARGES>\n");
459 let vals: Vec<String> = charges.iter().map(|q| format!("{q:.4}")).collect();
460 out.push_str(&vals.join(" "));
461 out.push_str("\n\n");
462 }
463 out.push_str("$$$$\n");
464 }
465 out
466}
467
468pub fn write_sdf_record(
474 mol: &Molecule,
475 meta: &MolMetadata,
476 coords: &[(f64, f64)],
477 props: &std::collections::HashMap<String, String>,
478) -> String {
479 let mut out = write_mol_with_coords(mol, meta, coords);
480 for (k, v) in props {
481 if !k.starts_with('_') {
482 out.push_str(&format!("> <{k}>\n{v}\n\n"));
483 }
484 }
485 out.push_str("$$$$\n");
486 out
487}
488
489pub fn write_sdf_record_v3000(
493 mol: &Molecule,
494 meta: &MolMetadata,
495 coords: &[(f64, f64)],
496 props: &std::collections::HashMap<String, String>,
497) -> String {
498 let mut out = crate::mol3000::write_mol_v3000(mol, meta, coords);
499 for (k, v) in props {
500 if !k.starts_with('_') {
501 out.push_str(&format!("> <{k}>\n{v}\n\n"));
502 }
503 }
504 out.push_str("$$$$\n");
505 out
506}
507
508#[cfg(test)]
513mod tests {
514 use super::*;
515
516 const ETHANOL_MOL: &str = "\
518ethanol
519 chematic
520
521 3 2 0 0 0 0 0 0 0 0 0 V2000
522 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
523 1.5000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
524 3.0000 0.0000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0
525 1 2 1 0
526 2 3 1 0
527M END
528";
529
530 #[test]
531 fn test_parse_ethanol_counts() {
532 let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse should succeed");
533 assert_eq!(mol.atom_count(), 3);
534 assert_eq!(mol.bond_count(), 2);
535 assert_eq!(meta.name, "ethanol");
536 }
537
538 #[test]
539 fn test_parse_elements() {
540 let (mol, _) = parse_mol(ETHANOL_MOL).expect("parse should succeed");
541 let atoms: Vec<_> = mol.atoms().collect();
542 assert_eq!(atoms[0].1.element, Element::C);
543 assert_eq!(atoms[1].1.element, Element::C);
544 assert_eq!(atoms[2].1.element, Element::O);
545 }
546
547 #[test]
548 fn test_parse_bond_types() {
549 let mol_str = "\
551test
552 chematic
553
554 8 4 0 0 0 0 0 0 0 0 0 V2000
555 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
556 1.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
557 2.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
558 3.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
559 4.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
560 5.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
561 6.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
562 7.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
563 1 2 1 0
564 3 4 2 0
565 5 6 3 0
566 7 8 4 0
567M END
568";
569 let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
570 let bonds: Vec<_> = mol.bonds().collect();
571 assert_eq!(bonds[0].1.order, BondOrder::Single);
572 assert_eq!(bonds[1].1.order, BondOrder::Double);
573 assert_eq!(bonds[2].1.order, BondOrder::Triple);
574 assert_eq!(bonds[3].1.order, BondOrder::Aromatic);
575 }
576
577 #[test]
578 fn test_parse_query_bond_types_preserved() {
579 let mol_str = "\
580query_bonds
581 chematic
582
583 8 4 0 0 0 0 0 0 0 0 0 V2000
584 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
585 1.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
586 2.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
587 3.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
588 4.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
589 5.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
590 6.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
591 7.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
592 1 2 5 0
593 3 4 6 0
594 5 6 7 0
595 7 8 8 0
596M END
597";
598 let (mol, meta) = parse_mol(mol_str).expect("parse query bonds");
599 let bonds: Vec<_> = mol.bonds().collect();
600 assert_eq!(bonds[0].1.order, BondOrder::QuerySingleOrDouble);
601 assert_eq!(bonds[1].1.order, BondOrder::QuerySingleOrAromatic);
602 assert_eq!(bonds[2].1.order, BondOrder::QueryDoubleOrAromatic);
603 assert_eq!(bonds[3].1.order, BondOrder::QueryAny);
604
605 let written = write_mol(&mol, &meta);
606 assert!(written.contains(" 1 2 5 0"), "{written}");
607 assert!(written.contains(" 7 8 8 0"), "{written}");
608 }
609
610 #[test]
611 fn test_parse_charge() {
612 let mol_str = "\
614charged
615 chematic
616
617 1 0 0 0 0 0 0 0 0 0 0 V2000
618 0.0000 0.0000 0.0000 N 0 3 0 0 0 0 0 0 0 0 0 0
619M END
620";
621 let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
622 assert_eq!(mol.atom(AtomIdx(0)).charge, 1);
623 }
624
625 #[test]
626 fn test_parse_negative_charge() {
627 let mol_str = "\
629negcharge
630 chematic
631
632 1 0 0 0 0 0 0 0 0 0 0 V2000
633 0.0000 0.0000 0.0000 O 0 5 0 0 0 0 0 0 0 0 0 0
634M END
635";
636 let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
637 assert_eq!(mol.atom(AtomIdx(0)).charge, -1);
638 }
639
640 #[test]
641 fn test_round_trip() {
642 let (mol1, meta1) = parse_mol(ETHANOL_MOL).expect("first parse");
644 let written = write_mol(&mol1, &meta1);
645 let (mol2, _meta2) = parse_mol(&written).expect("second parse");
646 assert_eq!(mol1.atom_count(), mol2.atom_count());
647 assert_eq!(mol1.bond_count(), mol2.bond_count());
648 }
649
650 #[test]
651 fn test_round_trip_elements_preserved() {
652 let (mol1, meta1) = parse_mol(ETHANOL_MOL).expect("first parse");
653 let written = write_mol(&mol1, &meta1);
654 let (mol2, _) = parse_mol(&written).expect("second parse");
655 for ((_, a1), (_, a2)) in mol1.atoms().zip(mol2.atoms()) {
656 assert_eq!(a1.element, a2.element);
657 }
658 }
659
660 #[test]
661 fn test_error_missing_v2000() {
662 let bad = "\
663bad
664 prog
665
666 3 2 0 0 0 0 0 0 0 0 0 V3000
667 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
668M END
669";
670 assert!(matches!(
671 parse_mol(bad),
672 Err(MolParseError::InvalidCountLine { .. })
673 ));
674 }
675
676 #[test]
677 fn test_error_truncated_input() {
678 let bad = "\
680trunc
681 prog
682
683 3 0 0 0 0 0 0 0 0 0 0 V2000
684 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
685";
686 assert!(matches!(parse_mol(bad), Err(MolParseError::UnexpectedEnd)));
687 }
688
689 #[test]
690 fn test_error_invalid_counts_line() {
691 let bad = "\
693mol
694 prog
695
696 X Y
697M END
698";
699 assert!(matches!(
700 parse_mol(bad),
701 Err(MolParseError::InvalidCountLine { .. })
702 ));
703 }
704
705 #[test]
706 fn test_write_contains_m_end() {
707 let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse");
708 let written = write_mol(&mol, &meta);
709 assert!(written.contains("M END"));
710 }
711
712 #[test]
713 fn test_write_contains_v2000() {
714 let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse");
715 let written = write_mol(&mol, &meta);
716 assert!(written.contains("V2000"));
717 }
718
719 #[test]
720 fn test_parse_stereo_up_bond() {
721 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";
723 let (mol, _) = crate::parse_mol(mol_str).unwrap();
724 let bond = mol.bond(chematic_core::BondIdx(0));
725 assert_eq!(bond.order, chematic_core::BondOrder::Up);
726 }
727
728 #[test]
729 fn test_parse_stereo_down_bond() {
730 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";
731 let (mol, _) = crate::parse_mol(mol_str).unwrap();
732 let bond = mol.bond(chematic_core::BondIdx(0));
733 assert_eq!(bond.order, chematic_core::BondOrder::Down);
734 }
735
736 #[test]
737 fn test_molmetadata_builder() {
738 let meta = MolMetadata::default()
739 .with_name("aspirin")
740 .with_comment("test molecule");
741 assert_eq!(meta.name, "aspirin");
742 assert_eq!(meta.comment, "test molecule");
743 }
744
745 #[test]
746 fn test_molmetadata_with_name_roundtrip() {
747 use chematic_core::{Atom, BondOrder, Element, MoleculeBuilder};
749 let mut b = MoleculeBuilder::new();
750 let c1 = b.add_atom(Atom::new(Element::C));
751 let c2 = b.add_atom(Atom::new(Element::C));
752 b.add_bond(c1, c2, BondOrder::Single).unwrap();
753 let mol = b.build();
754
755 let meta = MolMetadata::default().with_name("acetic acid");
756 let molblock = crate::write_mol(&mol, &meta);
757 assert!(
758 molblock.starts_with("acetic acid"),
759 "MOL block must start with the molecule name"
760 );
761 }
762
763 #[test]
764 fn test_declared_max_atom_count_truncated_input_errors() {
765 let bad = "\
766max_atoms
767 chematic
768
769999 0 0 0 0 0 0 0 0 0 0 V2000
770";
771 assert!(matches!(parse_mol(bad), Err(MolParseError::UnexpectedEnd)));
772 }
773
774 #[test]
775 fn test_declared_large_bond_count_truncated_input_errors() {
776 let bad = "\
777many_bonds
778 chematic
779
780 1 999 0 0 0 0 0 0 0 0 0 V2000
781 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
782";
783 assert!(matches!(parse_mol(bad), Err(MolParseError::UnexpectedEnd)));
784 }
785
786 #[test]
787 fn test_write_sdf_record_props_roundtrip() {
788 use crate::sdf::SdfRecordReader;
789 use std::collections::HashMap;
790
791 let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse");
792 let coords = vec![(0.0, 0.0), (1.5, 0.0), (3.0, 0.0)];
793 let mut props = HashMap::new();
794 props.insert("Activity".to_string(), "7.2".to_string());
795 props.insert("Source".to_string(), "test".to_string());
796 props.insert("_Name".to_string(), "ethanol".to_string()); let sdf = write_sdf_record(&mol, &meta, &coords, &props);
799
800 assert!(
802 !sdf.contains("> <_Name>"),
803 "internal prop leaked to SD block"
804 );
805 assert!(sdf.contains("> <Activity>\n7.2"), "Activity missing");
807 assert!(sdf.contains("> <Source>\ntest"), "Source missing");
808 assert!(sdf.ends_with("$$$$\n"), "missing delimiter");
809
810 let rec = SdfRecordReader::new(&sdf)
812 .next()
813 .expect("should have record")
814 .expect("should parse");
815 assert_eq!(rec.mol.atom_count(), mol.atom_count());
816 assert_eq!(
817 rec.properties.get("Activity").map(|s| s.as_str()),
818 Some("7.2")
819 );
820 assert_eq!(
821 rec.properties.get("Source").map(|s| s.as_str()),
822 Some("test")
823 );
824 assert!(!rec.properties.contains_key("_Name"));
825 }
826}