use chematic_core::{
Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder, StereoGroup, StereoGroupKind,
};
use crate::error::MolParseError;
use crate::mol2000::MolMetadata;
const V30_PREFIX: &str = "M V30 ";
#[inline]
fn v3k_err(line: usize, msg: impl Into<String>) -> MolParseError {
MolParseError::V3000ParseError {
line,
msg: msg.into(),
}
}
fn is_marker(tokens: &[&str], kw1: &str, kw2: &str) -> bool {
tokens.len() >= 2 && tokens[0] == kw1 && tokens[1] == kw2
}
struct LogicalLine {
line_num: usize,
payload: String,
}
fn collect_v30_lines(lines: &[(usize, &str)]) -> Vec<LogicalLine> {
let mut result: Vec<LogicalLine> = Vec::new();
let mut iter = lines.iter().peekable();
while let Some(&(lineno, raw)) = iter.next() {
if let Some(payload) = raw.strip_prefix(V30_PREFIX) {
let mut text = payload.to_string();
let first_line = lineno;
while text.ends_with('-') {
text.pop();
match iter.next() {
Some(&(_, cont_raw)) => {
if let Some(cont_payload) = cont_raw.strip_prefix(V30_PREFIX) {
text.push(' ');
text.push_str(cont_payload);
}
}
None => break,
}
}
result.push(LogicalLine {
line_num: first_line,
payload: text,
});
}
}
result
}
fn parse_kv(tokens: &[&str], key: &str) -> Option<String> {
for tok in tokens {
if let Some(rest) = tok.strip_prefix(key)
&& let Some(val) = rest.strip_prefix('=')
{
return Some(val.to_string());
}
}
None
}
#[allow(clippy::type_complexity)]
pub fn parse_mol_v3000_with_coords(
input: &str,
) -> Result<(Molecule, MolMetadata, Vec<(f64, f64)>), MolParseError> {
let all_lines: Vec<(usize, &str)> =
input.lines().enumerate().map(|(i, l)| (i + 1, l)).collect();
if all_lines.len() < 4 {
return Err(MolParseError::UnexpectedEnd);
}
let name = all_lines[0].1.to_string();
let comment = all_lines[2].1.to_string();
let metadata = MolMetadata { name, comment };
let (counts_lineno, counts_line) = all_lines[3];
if !counts_line.contains("V3000") {
return Err(MolParseError::InvalidCountLine {
line: counts_lineno,
detail: "missing V3000 version tag".to_string(),
});
}
let v30_lines = collect_v30_lines(&all_lines);
let mut builder = MoleculeBuilder::new();
let mut atom_idx_map: Vec<(u32, AtomIdx)> = Vec::new();
let mut coords: Vec<(f64, f64)> = Vec::new();
enum State {
BeforeCtab,
InCtab,
InAtomBlock,
AfterAtomBlock,
InBondBlock,
AfterBondBlock,
InCollection,
Done,
}
let mut state = State::BeforeCtab;
let mut expected_atoms: usize = 0;
let mut stereo_groups: Vec<StereoGroup> = Vec::new();
for LogicalLine { line_num, payload } in &v30_lines {
let lnum = *line_num;
let tokens: Vec<&str> = payload.split_whitespace().collect();
if tokens.is_empty() {
continue;
}
match state {
State::BeforeCtab => {
if is_marker(&tokens, "BEGIN", "CTAB") {
state = State::InCtab;
}
}
State::InCtab => {
if tokens[0] == "COUNTS" {
if tokens.len() < 3 {
return Err(v3k_err(lnum, "COUNTS line has fewer than 2 values"));
}
expected_atoms = tokens[1].parse::<usize>().map_err(|_| {
v3k_err(
lnum,
format!("cannot parse atom count from '{}'", tokens[1]),
)
})?;
tokens[2].parse::<usize>().map_err(|_| {
v3k_err(
lnum,
format!("cannot parse bond count from '{}'", tokens[2]),
)
})?;
} else if is_marker(&tokens, "BEGIN", "ATOM") {
state = State::InAtomBlock;
} else if is_marker(&tokens, "END", "CTAB") {
state = State::Done;
}
}
State::InAtomBlock => {
if is_marker(&tokens, "END", "ATOM") {
state = State::AfterAtomBlock;
if builder.atom_count() != expected_atoms {
return Err(v3k_err(
lnum,
format!(
"expected {} atoms, found {}",
expected_atoms,
builder.atom_count()
),
));
}
continue;
}
if tokens.len() < 6 {
return Err(MolParseError::InvalidAtomLine {
line: lnum,
detail: format!(
"V3000 atom line needs at least 6 fields, got {}",
tokens.len()
),
});
}
let v3k_idx =
tokens[0]
.parse::<u32>()
.map_err(|_| MolParseError::InvalidAtomLine {
line: lnum,
detail: format!("cannot parse atom index from '{}'", tokens[0]),
})?;
let sym = tokens[1].trim_start_matches('[').trim_end_matches(']');
let element =
Element::from_symbol(sym).ok_or_else(|| MolParseError::UnknownElement {
symbol: sym.to_string(),
line: lnum,
})?;
let x: f64 = tokens[2].parse().unwrap_or(0.0);
let y: f64 = tokens[3].parse().unwrap_or(0.0);
let aamap_raw = tokens[5].parse::<u16>().unwrap_or(0);
let atom_map = if aamap_raw == 0 {
None
} else {
Some(aamap_raw)
};
let kv_tokens = tokens.get(6..).unwrap_or(&[]);
let charge: i8 = parse_kv(kv_tokens, "CHG")
.and_then(|v| v.parse::<i8>().ok())
.unwrap_or(0);
let isotope: Option<u16> =
parse_kv(kv_tokens, "MASS").and_then(|v| v.parse::<u16>().ok());
let hydrogen_count: Option<u8> = parse_kv(kv_tokens, "HCOUNT").and_then(|v| {
let n: i32 = v.parse().ok()?;
if n < 0 { None } else { Some(n as u8) }
});
let mut atom = Atom::new(element);
atom.charge = charge;
atom.isotope = isotope;
atom.hydrogen_count = hydrogen_count;
atom.atom_map = atom_map;
let builder_idx = builder.add_atom(atom);
atom_idx_map.push((v3k_idx, builder_idx));
coords.push((x, y));
}
State::AfterAtomBlock => {
if is_marker(&tokens, "BEGIN", "BOND") {
state = State::InBondBlock;
} else if is_marker(&tokens, "END", "CTAB") {
state = State::Done;
}
}
State::InBondBlock => {
if is_marker(&tokens, "END", "BOND") {
state = State::AfterBondBlock;
continue;
}
if tokens.len() < 4 {
return Err(MolParseError::InvalidBondLine {
line: lnum,
detail: format!(
"V3000 bond line needs at least 4 fields, got {}",
tokens.len()
),
});
}
let btype_raw =
tokens[1]
.parse::<u8>()
.map_err(|_| MolParseError::InvalidBondLine {
line: lnum,
detail: format!("cannot parse bond type from '{}'", tokens[1]),
})?;
let a1_v3k =
tokens[2]
.parse::<u32>()
.map_err(|_| MolParseError::InvalidBondLine {
line: lnum,
detail: format!("cannot parse atom1 index from '{}'", tokens[2]),
})?;
let a2_v3k =
tokens[3]
.parse::<u32>()
.map_err(|_| MolParseError::InvalidBondLine {
line: lnum,
detail: format!("cannot parse atom2 index from '{}'", tokens[3]),
})?;
let a1 = resolve_atom_idx(a1_v3k, &atom_idx_map).ok_or_else(|| {
MolParseError::InvalidBondLine {
line: lnum,
detail: format!("atom index {a1_v3k} not found in atom block"),
}
})?;
let a2 = resolve_atom_idx(a2_v3k, &atom_idx_map).ok_or_else(|| {
MolParseError::InvalidBondLine {
line: lnum,
detail: format!("atom index {a2_v3k} not found in atom block"),
}
})?;
let order = match btype_raw {
0 => BondOrder::Zero,
1 => BondOrder::Single,
2 => BondOrder::Double,
3 => BondOrder::Triple,
4 => BondOrder::Aromatic,
5 => BondOrder::QuerySingleOrDouble,
6 => BondOrder::QuerySingleOrAromatic,
7 => BondOrder::QueryDoubleOrAromatic,
8 => BondOrder::QueryAny,
_ => BondOrder::Single,
};
builder
.add_bond(a1, a2, order)
.map_err(|e| MolParseError::InvalidBondLine {
line: lnum,
detail: e.to_string(),
})?;
}
State::AfterBondBlock => {
if is_marker(&tokens, "BEGIN", "COLLECTION") {
state = State::InCollection;
} else if is_marker(&tokens, "END", "CTAB") {
state = State::Done;
}
}
State::InCollection => {
if is_marker(&tokens, "END", "COLLECTION") {
state = State::AfterBondBlock;
} else if let Some(group) = parse_stereo_group_line(payload, &atom_idx_map) {
stereo_groups.push(group);
}
}
State::Done => {}
}
}
match state {
State::Done | State::AfterBondBlock => {}
State::InAtomBlock => {
return Err(MolParseError::V3000ParseError {
line: 0,
msg: "missing M V30 END ATOM".to_string(),
});
}
State::InBondBlock => {
return Err(MolParseError::V3000ParseError {
line: 0,
msg: "missing M V30 END BOND".to_string(),
});
}
_ => {
return Err(MolParseError::UnexpectedEnd);
}
}
let mut mol = builder.build();
if !stereo_groups.is_empty() {
mol.set_stereo_groups(stereo_groups);
}
Ok((mol, metadata, coords))
}
pub fn parse_mol_v3000(input: &str) -> Result<(Molecule, MolMetadata), MolParseError> {
parse_mol_v3000_with_coords(input).map(|(mol, meta, _coords)| (mol, meta))
}
fn resolve_atom_idx(v3k_idx: u32, map: &[(u32, AtomIdx)]) -> Option<AtomIdx> {
map.iter().find(|&&(k, _)| k == v3k_idx).map(|&(_, v)| v)
}
fn parse_stereo_group_line(payload: &str, atom_idx_map: &[(u32, AtomIdx)]) -> Option<StereoGroup> {
let first_tok = payload.split_whitespace().next()?;
let kind = if first_tok == "MDLV30/STEABS" {
StereoGroupKind::Absolute
} else if let Some(n_str) = first_tok.strip_prefix("MDLV30/STEOR") {
let n: u32 = n_str.parse().ok()?;
StereoGroupKind::Or(n)
} else if let Some(n_str) = first_tok.strip_prefix("MDLV30/STEAND") {
let n: u32 = n_str.parse().ok()?;
StereoGroupKind::And(n)
} else {
return None; };
let atoms_start = payload.find("ATOMS=(")?;
let after_paren = &payload[atoms_start + "ATOMS=(".len()..];
let close = after_paren.find(')')?;
let inner = &after_paren[..close];
let mut nums = inner.split_whitespace();
let _count: usize = nums.next()?.parse().ok()?;
let atom_indices: Vec<AtomIdx> = nums
.filter_map(|s| {
let v3k: u32 = s.parse().ok()?;
resolve_atom_idx(v3k, atom_idx_map)
})
.collect();
if atom_indices.is_empty() {
return None;
}
Some(StereoGroup::new(kind, atom_indices))
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_core::{AtomIdx, BondOrder, Element};
const METHANE_V3K: &str = "\
methane
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 1 0 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0
M V30 END ATOM
M V30 BEGIN BOND
M V30 END BOND
M V30 END CTAB
M END
";
const ETHANOL_V3K: &str = "\
ethanol
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 3 2 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0
M V30 2 C 1.5 0.0 0.0 0
M V30 3 O 3.0 0.0 0.0 0
M V30 END ATOM
M V30 BEGIN BOND
M V30 1 1 1 2
M V30 2 1 2 3
M V30 END BOND
M V30 END CTAB
M END
";
#[test]
fn test_methane_counts() {
let (mol, _) = parse_mol_v3000(METHANE_V3K).expect("parse methane");
assert_eq!(mol.atom_count(), 1);
assert_eq!(mol.bond_count(), 0);
}
#[test]
fn test_ethanol_counts() {
let (mol, _) = parse_mol_v3000(ETHANOL_V3K).expect("parse ethanol");
assert_eq!(mol.atom_count(), 3);
assert_eq!(mol.bond_count(), 2);
}
#[test]
fn test_ethanol_bond_0_1_single() {
let (mol, _) = parse_mol_v3000(ETHANOL_V3K).expect("parse ethanol");
let (_, bond) = mol
.bond_between(AtomIdx(0), AtomIdx(1))
.expect("bond 0-1 exists");
assert_eq!(bond.order, BondOrder::Single);
}
#[test]
fn test_ethanol_bond_1_2_single() {
let (mol, _) = parse_mol_v3000(ETHANOL_V3K).expect("parse ethanol");
let (_, bond) = mol
.bond_between(AtomIdx(1), AtomIdx(2))
.expect("bond 1-2 exists");
assert_eq!(bond.order, BondOrder::Single);
}
#[test]
fn test_positive_charge() {
let mol_str = "\
charged_pos
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 1 0 0 0 0
M V30 BEGIN ATOM
M V30 1 N 0.0 0.0 0.0 0 CHG=1
M V30 END ATOM
M V30 BEGIN BOND
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, _) = parse_mol_v3000(mol_str).expect("parse charged_pos");
assert_eq!(mol.atom(AtomIdx(0)).charge, 1);
}
#[test]
fn test_negative_charge() {
let mol_str = "\
charged_neg
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 1 0 0 0 0
M V30 BEGIN ATOM
M V30 1 O 0.0 0.0 0.0 0 CHG=-1
M V30 END ATOM
M V30 BEGIN BOND
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, _) = parse_mol_v3000(mol_str).expect("parse charged_neg");
assert_eq!(mol.atom(AtomIdx(0)).charge, -1);
}
#[test]
fn test_isotope() {
let mol_str = "\
isotope
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 1 0 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0 MASS=13
M V30 END ATOM
M V30 BEGIN BOND
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, _) = parse_mol_v3000(mol_str).expect("parse isotope");
assert_eq!(mol.atom(AtomIdx(0)).isotope, Some(13));
}
#[test]
fn test_aromatic_bond() {
let mol_str = "\
aromatic
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 2 1 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0
M V30 2 C 1.5 0.0 0.0 0
M V30 END ATOM
M V30 BEGIN BOND
M V30 1 4 1 2
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, _) = parse_mol_v3000(mol_str).expect("parse aromatic");
let (_, bond) = mol
.bond_between(AtomIdx(0), AtomIdx(1))
.expect("bond exists");
assert_eq!(bond.order, BondOrder::Aromatic);
}
#[test]
fn test_double_bond() {
let mol_str = "\
double_bond
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 2 1 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0
M V30 2 O 1.2 0.0 0.0 0
M V30 END ATOM
M V30 BEGIN BOND
M V30 1 2 1 2
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, _) = parse_mol_v3000(mol_str).expect("parse double_bond");
let (_, bond) = mol
.bond_between(AtomIdx(0), AtomIdx(1))
.expect("bond exists");
assert_eq!(bond.order, BondOrder::Double);
}
#[test]
fn test_metadata() {
let mol_str = "\
my_molecule
some_prog
my comment line
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 1 0 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0
M V30 END ATOM
M V30 BEGIN BOND
M V30 END BOND
M V30 END CTAB
M END
";
let (_, meta) = parse_mol_v3000(mol_str).expect("parse metadata");
assert_eq!(meta.name, "my_molecule");
assert_eq!(meta.comment, "my comment line");
}
#[test]
fn test_line_continuation() {
let mol_str = "\
continuation
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 1 0 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0 MASS=12 -
M V30 HCOUNT=3
M V30 END ATOM
M V30 BEGIN BOND
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, _) = parse_mol_v3000(mol_str).expect("parse continuation");
assert_eq!(mol.atom(AtomIdx(0)).element, Element::C);
assert_eq!(mol.atom(AtomIdx(0)).isotope, Some(12));
assert_eq!(mol.atom(AtomIdx(0)).hydrogen_count, Some(3));
}
#[test]
fn test_missing_end_atom_is_error() {
let mol_str = "\
bad
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 1 0 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0
M END
";
let result = parse_mol_v3000(mol_str);
assert!(
matches!(result, Err(MolParseError::V3000ParseError { .. })),
"expected V3000ParseError but got a different result"
);
}
#[test]
fn test_ethanol_elements() {
let (mol, _) = parse_mol_v3000(ETHANOL_V3K).expect("parse ethanol");
let atoms: Vec<_> = mol.atoms().collect();
assert_eq!(atoms[0].1.element, Element::C);
assert_eq!(atoms[1].1.element, Element::C);
assert_eq!(atoms[2].1.element, Element::O);
}
#[test]
fn test_triple_bond() {
let mol_str = "\
triple_bond
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 2 1 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0
M V30 2 N 1.2 0.0 0.0 0
M V30 END ATOM
M V30 BEGIN BOND
M V30 1 3 1 2
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, _) = parse_mol_v3000(mol_str).expect("parse triple_bond");
let (_, bond) = mol
.bond_between(AtomIdx(0), AtomIdx(1))
.expect("bond exists");
assert_eq!(bond.order, BondOrder::Triple);
}
#[test]
fn test_query_bond_types_preserved() {
let mol_str = "\
query_bonds
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 4 2 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 0
M V30 2 C 1.0 0.0 0.0 0
M V30 3 C 2.0 0.0 0.0 0
M V30 4 C 3.0 0.0 0.0 0
M V30 END ATOM
M V30 BEGIN BOND
M V30 1 5 1 2
M V30 2 8 3 4
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, meta) = parse_mol_v3000(mol_str).expect("parse query bonds");
let bonds: Vec<_> = mol.bonds().collect();
assert_eq!(bonds[0].1.order, BondOrder::QuerySingleOrDouble);
assert_eq!(bonds[1].1.order, BondOrder::QueryAny);
let written = write_mol_v3000(&mol, &meta, &[]);
assert!(written.contains("M V30 1 5 1 2"), "{written}");
assert!(written.contains("M V30 2 8 3 4"), "{written}");
}
#[test]
fn test_atom_map() {
let mol_str = "\
atommapped
test
0 0 0 0 0 0 0 0 0 0999 V3000
M V30 BEGIN CTAB
M V30 COUNTS 1 0 0 0 0
M V30 BEGIN ATOM
M V30 1 C 0.0 0.0 0.0 3
M V30 END ATOM
M V30 BEGIN BOND
M V30 END BOND
M V30 END CTAB
M END
";
let (mol, _) = parse_mol_v3000(mol_str).expect("parse atommapped");
assert_eq!(mol.atom(AtomIdx(0)).atom_map, Some(3));
}
}
pub fn write_mol_v3000(mol: &Molecule, metadata: &MolMetadata, coords: &[(f64, f64)]) -> String {
let natoms = mol.atom_count();
let nbonds = mol.bond_count();
let mut out = String::new();
out.push_str(&metadata.name);
out.push('\n');
out.push_str(" chematic\n");
out.push_str(&metadata.comment);
out.push('\n');
out.push_str(" 0 0 0 0 0 0 0 0 0 0999 V3000\n");
out.push_str("M V30 BEGIN CTAB\n");
out.push_str(&format!("M V30 COUNTS {natoms} {nbonds} 0 0 0\n"));
out.push_str("M V30 BEGIN ATOM\n");
for (idx, atom) in mol.atoms() {
let (x, y) = coords.get(idx.0 as usize).copied().unwrap_or((0.0, 0.0));
let sym = atom.element.symbol();
let atom_map = atom.atom_map.unwrap_or(0);
let i = idx.0 + 1;
let mut line = format!("M V30 {i} {sym} {x:.4} {y:.4} 0.0000 {atom_map}");
if atom.charge != 0 {
line.push_str(&format!(" CHG={}", atom.charge));
}
if let Some(iso) = atom.isotope {
line.push_str(&format!(" MASS={iso}"));
}
if let Some(h) = atom.hydrogen_count {
line.push_str(&format!(" HCOUNT={h}"));
}
out.push_str(&line);
out.push('\n');
}
out.push_str("M V30 END ATOM\n");
out.push_str("M V30 BEGIN BOND\n");
for (bidx, bond) in mol.bonds() {
let a1 = bond.atom1.0 + 1;
let a2 = bond.atom2.0 + 1;
let order = match bond.order {
BondOrder::Zero => 0,
BondOrder::Single | BondOrder::Up | BondOrder::Down | BondOrder::Dative => 1,
BondOrder::Double => 2,
BondOrder::Triple => 3,
BondOrder::Aromatic => 4,
BondOrder::QuerySingleOrDouble => 5,
BondOrder::QuerySingleOrAromatic => 6,
BondOrder::QueryDoubleOrAromatic => 7,
BondOrder::QueryAny => 8,
BondOrder::Quadruple => 4,
};
let i = bidx.0 + 1;
let stereo = match bond.order {
BondOrder::Up => " CFG=1",
BondOrder::Down => " CFG=6",
_ => "",
};
out.push_str(&format!("M V30 {i} {order} {a1} {a2}{stereo}\n"));
}
out.push_str("M V30 END BOND\n");
let groups = mol.stereo_groups();
if !groups.is_empty() {
out.push_str("M V30 BEGIN COLLECTION\n");
for group in groups {
let key = match &group.kind {
StereoGroupKind::Absolute => "MDLV30/STEABS".to_string(),
StereoGroupKind::Or(n) => format!("MDLV30/STEOR{n}"),
StereoGroupKind::And(n) => format!("MDLV30/STEAND{n}"),
};
let n = group.atom_indices.len();
let idxs: Vec<String> = group
.atom_indices
.iter()
.map(|ai| (ai.0 + 1).to_string()) .collect();
out.push_str(&format!("M V30 {key} ATOMS=({n} {})\n", idxs.join(" ")));
}
out.push_str("M V30 END COLLECTION\n");
}
out.push_str("M V30 END CTAB\n");
out.push_str("M END\n");
out
}
#[cfg(test)]
mod write_tests {
use super::*;
use crate::mol3000::parse_mol_v3000;
use chematic_core::Element;
use chematic_core::{Atom, MoleculeBuilder};
fn ethanol() -> Molecule {
use chematic_core::BondOrder;
let mut b = MoleculeBuilder::new();
let c1 = b.add_atom(Atom::new(Element::from_symbol("C").unwrap()));
let c2 = b.add_atom(Atom::new(Element::from_symbol("C").unwrap()));
let o = b.add_atom(Atom::new(Element::from_symbol("O").unwrap()));
b.add_bond(c1, c2, BondOrder::Single).unwrap();
b.add_bond(c2, o, BondOrder::Single).unwrap();
b.build()
}
#[test]
fn write_v3000_roundtrip_ethanol() {
let mol = ethanol();
let meta = MolMetadata {
name: "ethanol".into(),
comment: String::new(),
};
let v3k = write_mol_v3000(&mol, &meta, &[]);
let (mol2, meta2) = parse_mol_v3000(&v3k).expect("round-trip parse");
assert_eq!(mol.atom_count(), mol2.atom_count());
assert_eq!(mol.bond_count(), mol2.bond_count());
assert_eq!(meta2.name, "ethanol");
}
#[test]
fn write_v3000_contains_v3000_tag() {
let mol = ethanol();
let meta = MolMetadata::default();
let v3k = write_mol_v3000(&mol, &meta, &[]);
assert!(v3k.contains("V3000"), "output should contain V3000 tag");
assert!(
v3k.contains("M V30 BEGIN CTAB"),
"should contain CTAB block"
);
}
#[test]
fn write_v3000_stereo_group_roundtrip() {
use chematic_core::{AtomIdx, BondOrder, StereoGroup, StereoGroupKind};
let mut b = MoleculeBuilder::new();
let c1 = b.add_atom(Atom::new(Element::from_symbol("C").unwrap()));
let c2 = b.add_atom(Atom::new(Element::from_symbol("C").unwrap()));
let c3 = b.add_atom(Atom::new(Element::from_symbol("N").unwrap()));
b.add_bond(c1, c2, BondOrder::Single).unwrap();
b.add_bond(c2, c3, BondOrder::Single).unwrap();
let mut mol = b.build();
mol.set_stereo_groups(vec![
StereoGroup::new(StereoGroupKind::Absolute, vec![c1]),
StereoGroup::new(StereoGroupKind::Or(1), vec![c2, c3]),
]);
let meta = MolMetadata {
name: "stereo_test".into(),
comment: String::new(),
};
let v3k = write_mol_v3000(&mol, &meta, &[]);
assert!(
v3k.contains("BEGIN COLLECTION"),
"should have COLLECTION block"
);
assert!(v3k.contains("MDLV30/STEABS"), "should have STEABS entry");
assert!(v3k.contains("MDLV30/STEOR1"), "should have STEOR1 entry");
let (mol2, _) = parse_mol_v3000(&v3k).expect("round-trip parse");
assert_eq!(
mol2.stereo_groups().len(),
2,
"should have 2 stereo groups after round-trip"
);
let abs_group = mol2
.stereo_groups()
.iter()
.find(|g| g.kind == StereoGroupKind::Absolute)
.expect("Absolute group should exist");
assert_eq!(abs_group.atom_indices, vec![AtomIdx(0)]);
let or_group = mol2
.stereo_groups()
.iter()
.find(|g| g.kind == StereoGroupKind::Or(1))
.expect("Or(1) group should exist");
assert_eq!(or_group.atom_indices, vec![AtomIdx(1), AtomIdx(2)]);
}
}