use std::collections::HashMap;
use std::io::BufRead;
use crate::atom::Atom;
use crate::bond::{Bond, BondOrder, BondStereo};
use crate::error::{Result, SdfError};
use crate::molecule::{Molecule, SdfFormat};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
SdfV2000,
SdfV3000,
Mol2,
Xyz,
}
impl std::fmt::Display for FileFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileFormat::SdfV2000 => write!(f, "sdf_v2000"),
FileFormat::SdfV3000 => write!(f, "sdf_v3000"),
FileFormat::Mol2 => write!(f, "mol2"),
FileFormat::Xyz => write!(f, "xyz"),
}
}
}
pub struct SdfParser<R> {
reader: R,
line_number: usize,
}
impl<R: BufRead> SdfParser<R> {
pub fn new(reader: R) -> Self {
Self {
reader,
line_number: 0,
}
}
fn read_line(&mut self, buf: &mut String) -> Result<bool> {
buf.clear();
let bytes_read = self.reader.read_line(buf)?;
if bytes_read > 0 {
self.line_number += 1;
if buf.ends_with('\n') {
buf.pop();
if buf.ends_with('\r') {
buf.pop();
}
}
Ok(true)
} else {
Ok(false)
}
}
pub fn parse_molecule(&mut self) -> Result<Option<Molecule>> {
let mut line = String::new();
if !self.read_line(&mut line)? {
return Ok(None);
}
let name = line.trim().to_string();
if !self.read_line(&mut line)? {
return Err(SdfError::MissingSection("header".to_string()));
}
let program_line = if line.trim().is_empty() {
None
} else {
Some(line.clone())
};
if !self.read_line(&mut line)? {
return Err(SdfError::MissingSection("header".to_string()));
}
let comment = if line.trim().is_empty() {
None
} else {
Some(line.clone())
};
if !self.read_line(&mut line)? {
return Err(SdfError::MissingSection("counts line".to_string()));
}
let (atom_count, bond_count) = self.parse_counts_line(&line)?;
let mut atoms = Vec::with_capacity(atom_count);
for i in 0..atom_count {
if !self.read_line(&mut line)? {
return Err(SdfError::AtomCountMismatch {
expected: atom_count,
found: i,
});
}
let atom = self.parse_atom_line(&line, i)?;
atoms.push(atom);
}
let mut bonds = Vec::with_capacity(bond_count);
for i in 0..bond_count {
if !self.read_line(&mut line)? {
return Err(SdfError::BondCountMismatch {
expected: bond_count,
found: i,
});
}
let bond = self.parse_bond_line(&line, atom_count)?;
bonds.push(bond);
}
let mut properties = HashMap::new();
loop {
if !self.read_line(&mut line)? {
break;
}
if line.starts_with("M END") {
break;
}
if line.starts_with("M CHG") {
self.parse_charge_line(&line, &mut atoms)?;
}
if line.starts_with("M ISO") {
self.parse_isotope_line(&line, &mut atoms)?;
}
}
let mut current_property_name: Option<String> = None;
let mut current_property_value = String::new();
loop {
if !self.read_line(&mut line)? {
break;
}
if line.starts_with("$$$$") {
if let Some(prop_name) = current_property_name.take() {
properties.insert(prop_name, current_property_value.trim().to_string());
}
break;
}
if line.starts_with("> ") || line.starts_with("> ") {
if let Some(prop_name) = current_property_name.take() {
properties.insert(prop_name, current_property_value.trim().to_string());
}
current_property_value.clear();
if let Some(start) = line.find('<') {
if let Some(end) = line[start + 1..].find('>') {
let prop_name = line[start + 1..start + 1 + end].to_string();
current_property_name = Some(prop_name);
}
}
} else if current_property_name.is_some() && !line.is_empty() {
if !current_property_value.is_empty() {
current_property_value.push('\n');
}
current_property_value.push_str(&line);
}
}
Ok(Some(Molecule {
name,
program_line,
comment,
atoms,
bonds,
properties,
format_version: SdfFormat::V2000,
stereogroups: Vec::new(),
sgroups: Vec::new(),
collections: Vec::new(),
}))
}
fn parse_counts_line(&self, line: &str) -> Result<(usize, usize)> {
if line.len() < 6 {
return Err(SdfError::InvalidCountsLine(line.to_string()));
}
let atom_count: usize = line[0..3]
.trim()
.parse()
.map_err(|_| SdfError::InvalidCountsLine(line.to_string()))?;
let bond_count: usize = line[3..6]
.trim()
.parse()
.map_err(|_| SdfError::InvalidCountsLine(line.to_string()))?;
Ok((atom_count, bond_count))
}
fn parse_atom_line(&self, line: &str, index: usize) -> Result<Atom> {
if line.len() < 34 {
return Err(SdfError::Parse {
line: self.line_number,
message: format!("Atom line too short: {}", line),
});
}
let x: f64 = line[0..10]
.trim()
.parse()
.map_err(|_| SdfError::InvalidCoordinate(line[0..10].to_string()))?;
let y: f64 = line[10..20]
.trim()
.parse()
.map_err(|_| SdfError::InvalidCoordinate(line[10..20].to_string()))?;
let z: f64 = line[20..30]
.trim()
.parse()
.map_err(|_| SdfError::InvalidCoordinate(line[20..30].to_string()))?;
let element = line[31..34].trim().to_string();
let mass_difference: i8 = if line.len() >= 36 {
line[34..36].trim().parse().unwrap_or(0)
} else {
0
};
let formal_charge: i8 = if line.len() >= 39 {
let charge_code: u8 = line[36..39].trim().parse().unwrap_or(0);
match charge_code {
0 => 0,
1 => 3,
2 => 2,
3 => 1,
4 => 0, 5 => -1,
6 => -2,
7 => -3,
_ => 0,
}
} else {
0
};
let stereo_parity: Option<u8> = if line.len() >= 42 {
let parity: u8 = line[39..42].trim().parse().unwrap_or(0);
if parity > 0 { Some(parity) } else { None }
} else {
None
};
let hydrogen_count: Option<u8> = if line.len() >= 45 {
let hcount: u8 = line[42..45].trim().parse().unwrap_or(0);
if hcount > 0 { Some(hcount) } else { None }
} else {
None
};
let valence: Option<u8> = if line.len() >= 51 {
let val: u8 = line[48..51].trim().parse().unwrap_or(0);
if val > 0 { Some(val) } else { None }
} else {
None
};
Ok(Atom {
index,
element,
x,
y,
z,
formal_charge,
mass_difference,
stereo_parity,
hydrogen_count,
valence,
v3000_id: None,
atom_atom_mapping: None,
rgroup_label: None,
radical: None,
})
}
fn parse_bond_line(&self, line: &str, atom_count: usize) -> Result<Bond> {
if line.len() < 9 {
return Err(SdfError::Parse {
line: self.line_number,
message: format!("Bond line too short: {}", line),
});
}
let atom1: usize = line[0..3]
.trim()
.parse::<usize>()
.map_err(|_| SdfError::Parse {
line: self.line_number,
message: "Invalid atom1 index".to_string(),
})?;
let atom2: usize = line[3..6]
.trim()
.parse::<usize>()
.map_err(|_| SdfError::Parse {
line: self.line_number,
message: "Invalid atom2 index".to_string(),
})?;
let atom1 = atom1.checked_sub(1).ok_or(SdfError::InvalidAtomIndex {
index: atom1,
atom_count,
})?;
let atom2 = atom2.checked_sub(1).ok_or(SdfError::InvalidAtomIndex {
index: atom2,
atom_count,
})?;
if atom1 >= atom_count {
return Err(SdfError::InvalidAtomIndex {
index: atom1 + 1,
atom_count,
});
}
if atom2 >= atom_count {
return Err(SdfError::InvalidAtomIndex {
index: atom2 + 1,
atom_count,
});
}
let bond_type: u8 = line[6..9].trim().parse().map_err(|_| SdfError::Parse {
line: self.line_number,
message: "Invalid bond type".to_string(),
})?;
let order = BondOrder::from_sdf(bond_type).ok_or(SdfError::InvalidBondOrder(bond_type))?;
let stereo = if line.len() >= 12 {
let stereo_code: u8 = line[9..12].trim().parse().unwrap_or(0);
BondStereo::from_sdf(stereo_code)
} else {
BondStereo::None
};
let topology = if line.len() >= 18 {
let topo: u8 = line[15..18].trim().parse().unwrap_or(0);
if topo > 0 { Some(topo) } else { None }
} else {
None
};
Ok(Bond {
atom1,
atom2,
order,
stereo,
topology,
v3000_id: None,
reacting_center: None,
})
}
fn parse_charge_line(&self, line: &str, atoms: &mut [Atom]) -> Result<()> {
if line.len() < 9 {
return Ok(());
}
let count: usize = line[6..9].trim().parse().unwrap_or(0);
let mut pos = 9;
for _ in 0..count {
if pos + 8 > line.len() {
break;
}
let atom_num: usize = line[pos..pos + 4].trim().parse().unwrap_or(0);
let charge: i8 = line[pos + 4..pos + 8].trim().parse().unwrap_or(0);
if atom_num > 0 && atom_num <= atoms.len() {
atoms[atom_num - 1].formal_charge = charge;
}
pos += 8;
}
Ok(())
}
fn parse_isotope_line(&self, line: &str, atoms: &mut [Atom]) -> Result<()> {
if line.len() < 9 {
return Ok(());
}
let count: usize = line[6..9].trim().parse().unwrap_or(0);
let mut pos = 9;
for _ in 0..count {
if pos + 8 > line.len() {
break;
}
let atom_num: usize = line[pos..pos + 4].trim().parse().unwrap_or(0);
let mass_diff: i8 = line[pos + 4..pos + 8].trim().parse().unwrap_or(0);
if atom_num > 0 && atom_num <= atoms.len() {
atoms[atom_num - 1].mass_difference = mass_diff;
}
pos += 8;
}
Ok(())
}
}
pub struct SdfIterator<R> {
parser: SdfParser<R>,
finished: bool,
}
impl<R: BufRead> SdfIterator<R> {
pub fn new(reader: R) -> Self {
Self {
parser: SdfParser::new(reader),
finished: false,
}
}
}
impl<R: BufRead> Iterator for SdfIterator<R> {
type Item = Result<Molecule>;
fn next(&mut self) -> Option<Self::Item> {
if self.finished {
return None;
}
match self.parser.parse_molecule() {
Ok(Some(mol)) => Some(Ok(mol)),
Ok(None) => {
self.finished = true;
None
}
Err(e) => {
self.finished = true;
Some(Err(e))
}
}
}
}
pub fn parse_sdf_string(content: &str) -> Result<Molecule> {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let mut parser = SdfParser::new(reader);
parser.parse_molecule()?.ok_or(SdfError::EmptyFile)
}
pub fn parse_sdf_string_multi(content: &str) -> Result<Vec<Molecule>> {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let iter = SdfIterator::new(reader);
iter.collect()
}
pub fn parse_sdf_file<P: AsRef<std::path::Path>>(path: P) -> Result<Molecule> {
#[cfg(feature = "gzip")]
{
let reader = super::compression::open_maybe_gz(&path)?;
let mut parser = SdfParser::new(reader);
parser.parse_molecule()?.ok_or(SdfError::EmptyFile)
}
#[cfg(not(feature = "gzip"))]
{
if path
.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
{
return Err(SdfError::GzipNotEnabled);
}
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let mut parser = SdfParser::new(reader);
parser.parse_molecule()?.ok_or(SdfError::EmptyFile)
}
}
pub fn parse_sdf_file_multi<P: AsRef<std::path::Path>>(path: P) -> Result<Vec<Molecule>> {
#[cfg(feature = "gzip")]
{
let reader = super::compression::open_maybe_gz(&path)?;
let iter = SdfIterator::new(reader);
iter.collect()
}
#[cfg(not(feature = "gzip"))]
{
if path
.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
{
return Err(SdfError::GzipNotEnabled);
}
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let iter = SdfIterator::new(reader);
iter.collect()
}
}
#[cfg(feature = "gzip")]
pub fn iter_sdf_file<P: AsRef<std::path::Path>>(
path: P,
) -> Result<SdfIterator<super::compression::MaybeGzReader>> {
let reader = super::compression::open_maybe_gz(&path)?;
Ok(SdfIterator::new(reader))
}
#[cfg(not(feature = "gzip"))]
pub fn iter_sdf_file<P: AsRef<std::path::Path>>(
path: P,
) -> Result<SdfIterator<std::io::BufReader<std::fs::File>>> {
if path
.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
{
return Err(SdfError::GzipNotEnabled);
}
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
Ok(SdfIterator::new(reader))
}
pub fn detect_sdf_format(content: &str) -> SdfFormat {
let lines: Vec<&str> = content.lines().take(5).collect();
if lines.len() >= 4 && lines[3].contains("V3000") {
SdfFormat::V3000
} else {
SdfFormat::V2000
}
}
pub fn parse_sdf_auto_string(content: &str) -> Result<Molecule> {
match detect_sdf_format(content) {
SdfFormat::V2000 => parse_sdf_string(content),
SdfFormat::V3000 => super::sdf_v3000::parse_sdf_v3000_string(content),
}
}
pub fn parse_sdf_auto_string_multi(content: &str) -> Result<Vec<Molecule>> {
match detect_sdf_format(content) {
SdfFormat::V2000 => parse_sdf_string_multi(content),
SdfFormat::V3000 => super::sdf_v3000::parse_sdf_v3000_string_multi(content),
}
}
pub fn parse_sdf_auto_file<P: AsRef<std::path::Path>>(path: P) -> Result<Molecule> {
#[cfg(feature = "gzip")]
let content = super::compression::read_maybe_gz_to_string(&path)?;
#[cfg(not(feature = "gzip"))]
let content = {
if path
.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
{
return Err(SdfError::GzipNotEnabled);
}
std::fs::read_to_string(&path)?
};
match detect_sdf_format(&content) {
SdfFormat::V2000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let mut parser = SdfParser::new(reader);
parser.parse_molecule()?.ok_or(SdfError::EmptyFile)
}
SdfFormat::V3000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let mut iter = super::sdf_v3000::SdfV3000Iterator::new(reader);
iter.next().ok_or(SdfError::EmptyFile)?
}
}
}
pub fn parse_sdf_auto_file_multi<P: AsRef<std::path::Path>>(path: P) -> Result<Vec<Molecule>> {
#[cfg(feature = "gzip")]
let content = super::compression::read_maybe_gz_to_string(&path)?;
#[cfg(not(feature = "gzip"))]
let content = {
if path
.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
{
return Err(SdfError::GzipNotEnabled);
}
std::fs::read_to_string(&path)?
};
match detect_sdf_format(&content) {
SdfFormat::V2000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let iter = SdfIterator::new(reader);
iter.collect()
}
SdfFormat::V3000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let iter = super::sdf_v3000::SdfV3000Iterator::new(reader);
iter.collect()
}
}
}
pub fn detect_format(content: &str) -> FileFormat {
for line in content.lines().take(100) {
if line.starts_with("@<TRIPOS>") {
return FileFormat::Mol2;
}
}
let lines: Vec<&str> = content.lines().take(5).collect();
if lines.len() >= 3 {
let first_line_is_int = lines[0].trim().parse::<usize>().is_ok();
if first_line_is_int {
let parts: Vec<&str> = lines[2].split_whitespace().collect();
if parts.len() >= 4 {
let looks_like_element =
parts[0].chars().all(|c| c.is_alphabetic()) || parts[0].parse::<u8>().is_ok();
let has_three_coords = parts[1].parse::<f64>().is_ok()
&& parts[2].parse::<f64>().is_ok()
&& parts[3].parse::<f64>().is_ok();
if looks_like_element && has_three_coords {
return FileFormat::Xyz;
}
}
}
}
if lines.len() >= 4 && lines[3].contains("V3000") {
return FileFormat::SdfV3000;
}
FileFormat::SdfV2000
}
pub fn parse_auto_string(content: &str) -> Result<Molecule> {
match detect_format(content) {
FileFormat::SdfV2000 => parse_sdf_string(content),
FileFormat::SdfV3000 => super::sdf_v3000::parse_sdf_v3000_string(content),
FileFormat::Mol2 => super::mol2::parse_mol2_string(content),
FileFormat::Xyz => super::xyz::parse_xyz_string(content),
}
}
pub fn parse_auto_string_multi(content: &str) -> Result<Vec<Molecule>> {
match detect_format(content) {
FileFormat::SdfV2000 => parse_sdf_string_multi(content),
FileFormat::SdfV3000 => super::sdf_v3000::parse_sdf_v3000_string_multi(content),
FileFormat::Mol2 => super::mol2::parse_mol2_string_multi(content),
FileFormat::Xyz => super::xyz::parse_xyz_string_multi(content),
}
}
pub fn parse_auto_file<P: AsRef<std::path::Path>>(path: P) -> Result<Molecule> {
#[cfg(feature = "gzip")]
let content = super::compression::read_maybe_gz_to_string(&path)?;
#[cfg(not(feature = "gzip"))]
let content = {
if path
.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
{
return Err(SdfError::GzipNotEnabled);
}
std::fs::read_to_string(&path)?
};
match detect_format(&content) {
FileFormat::SdfV2000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let mut parser = SdfParser::new(reader);
parser.parse_molecule()?.ok_or(SdfError::EmptyFile)
}
FileFormat::SdfV3000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let mut iter = super::sdf_v3000::SdfV3000Iterator::new(reader);
iter.next().ok_or(SdfError::EmptyFile)?
}
FileFormat::Mol2 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let mut parser = super::mol2::Mol2Parser::new(reader);
parser.parse_molecule()?.ok_or(SdfError::EmptyFile)
}
FileFormat::Xyz => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let mut parser = super::xyz::XyzParser::new(reader);
parser.parse_molecule()?.ok_or(SdfError::EmptyFile)
}
}
}
pub fn parse_auto_file_multi<P: AsRef<std::path::Path>>(path: P) -> Result<Vec<Molecule>> {
#[cfg(feature = "gzip")]
let content = super::compression::read_maybe_gz_to_string(&path)?;
#[cfg(not(feature = "gzip"))]
let content = {
if path
.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
{
return Err(SdfError::GzipNotEnabled);
}
std::fs::read_to_string(&path)?
};
match detect_format(&content) {
FileFormat::SdfV2000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let iter = SdfIterator::new(reader);
iter.collect()
}
FileFormat::SdfV3000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let iter = super::sdf_v3000::SdfV3000Iterator::new(reader);
iter.collect()
}
FileFormat::Mol2 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let iter = super::mol2::Mol2Iterator::new(reader);
iter.collect()
}
FileFormat::Xyz => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
let iter = super::xyz::XyzIterator::new(reader);
iter.collect()
}
}
}
pub type AutoIterator = Box<dyn Iterator<Item = Result<Molecule>>>;
pub fn iter_auto_file<P: AsRef<std::path::Path>>(path: P) -> Result<AutoIterator> {
#[cfg(feature = "gzip")]
let content = super::compression::read_maybe_gz_to_string(&path)?;
#[cfg(not(feature = "gzip"))]
let content = {
if path
.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
{
return Err(SdfError::GzipNotEnabled);
}
std::fs::read_to_string(&path)?
};
let format = detect_format(&content);
match format {
FileFormat::SdfV2000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
Ok(Box::new(SdfIterator::new(reader)))
}
FileFormat::SdfV3000 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
Ok(Box::new(super::sdf_v3000::SdfV3000Iterator::new(reader)))
}
FileFormat::Mol2 => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
Ok(Box::new(super::mol2::Mol2Iterator::new(reader)))
}
FileFormat::Xyz => {
let cursor = std::io::Cursor::new(content);
let reader = std::io::BufReader::new(cursor);
Ok(Box::new(super::xyz::XyzIterator::new(reader)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SIMPLE_MOL: &str = r#"methane
test 3D
5 4 0 0 0 0 0 0 0 0999 V2000
0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
0.6289 0.6289 0.6289 H 0 0 0 0 0 0 0 0 0 0 0 0
-0.6289 -0.6289 0.6289 H 0 0 0 0 0 0 0 0 0 0 0 0
-0.6289 0.6289 -0.6289 H 0 0 0 0 0 0 0 0 0 0 0 0
0.6289 -0.6289 -0.6289 H 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 0 0 0 0
1 3 1 0 0 0 0
1 4 1 0 0 0 0
1 5 1 0 0 0 0
M END
$$$$
"#;
#[test]
fn test_parse_simple_molecule() {
let mol = parse_sdf_string(SIMPLE_MOL).unwrap();
assert_eq!(mol.name, "methane");
assert_eq!(mol.atom_count(), 5);
assert_eq!(mol.bond_count(), 4);
assert_eq!(mol.formula(), "CH4");
let carbon = &mol.atoms[0];
assert_eq!(carbon.element, "C");
assert_eq!(carbon.x, 0.0);
assert_eq!(carbon.y, 0.0);
assert_eq!(carbon.z, 0.0);
for bond in &mol.bonds {
assert_eq!(bond.order, BondOrder::Single);
}
}
#[test]
fn test_parse_with_properties() {
let mol_with_props = r#"aspirin
test 3D
2 1 0 0 0 0 0 0 0 0999 V2000
0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1.5000 0.0000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0
1 2 2 0 0 0 0
M END
> <MW>
180.16
> <SMILES>
CC(=O)OC1=CC=CC=C1C(=O)O
$$$$
"#;
let mol = parse_sdf_string(mol_with_props).unwrap();
assert_eq!(mol.name, "aspirin");
assert_eq!(mol.get_property("MW"), Some("180.16"));
assert_eq!(mol.get_property("SMILES"), Some("CC(=O)OC1=CC=CC=C1C(=O)O"));
}
#[test]
fn test_multi_molecule_parsing() {
let multi_mol = format!("{}{}", SIMPLE_MOL, SIMPLE_MOL);
let mols = parse_sdf_string_multi(&multi_mol).unwrap();
assert_eq!(mols.len(), 2);
assert_eq!(mols[0].name, "methane");
assert_eq!(mols[1].name, "methane");
}
}