Skip to main content

chematic_mol/
mol2000.rs

1//! MOL V2000 (Ctab) parser and writer.
2//!
3//! Reference format:
4//!   Line 1  — molecule name (may be blank)
5//!   Line 2  — program/date info (may be blank)
6//!   Line 3  — comment (may be blank)
7//!   Line 4  — counts line: fixed-width fields for atom count, bond count, version tag
8//!   Lines 5..5+natoms — atom block (one line per atom)
9//!   Lines 5+natoms..5+natoms+nbonds — bond block (one line per bond)
10//!   "M  END" — molecule terminator
11
12use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
13
14use crate::error::MolParseError;
15
16/// Maximum number of atoms allowed in a MOL file (prevents memory exhaustion).
17const MAX_ATOMS: usize = 100_000;
18
19/// Maximum number of bonds allowed in a MOL file (prevents memory exhaustion).
20const MAX_BONDS: usize = 200_000;
21
22/// Metadata extracted from the three-line MOL header.
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
24pub struct MolMetadata {
25    /// Molecule name from header line 1.
26    pub name: String,
27    /// Comment string from header line 3.
28    pub comment: String,
29}
30
31impl MolMetadata {
32    /// Set the molecule name and return `self` (builder style).
33    pub fn with_name(mut self, name: &str) -> Self {
34        self.name = name.to_owned();
35        self
36    }
37
38    /// Set the comment and return `self` (builder style).
39    pub fn with_comment(mut self, comment: &str) -> Self {
40        self.comment = comment.to_owned();
41        self
42    }
43}
44
45// ---------------------------------------------------------------------------
46// Charge encoding table (V2000 ccc field → formal charge)
47// ---------------------------------------------------------------------------
48
49/// Decode a V2000 charge code into a formal charge value.
50fn decode_charge(code: i8) -> i8 {
51    match code {
52        1 => 3,
53        2 => 2,
54        3 => 1,
55        4 => 0, // doublet radical — treated as neutral
56        5 => -1,
57        6 => -2,
58        7 => -3,
59        _ => 0,
60    }
61}
62
63/// Encode a formal charge into a V2000 charge code.
64fn 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
76// ---------------------------------------------------------------------------
77// Parser
78// ---------------------------------------------------------------------------
79
80/// Parse a fixed-width 3-character integer field from a string slice.
81///
82/// Returns an error using `make_err` when the slice is missing or the text
83/// cannot be parsed as an integer.
84fn 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/// Parse a MOL V2000 string into a `(Molecule, MolMetadata, coords)` triple.
100///
101/// The parser follows the MDL/CTfile fixed-width column layout.
102/// `coords[i]` is the `(x, y)` position for atom `i` extracted from the
103/// atom block.  Z-coordinates are discarded.
104#[allow(clippy::type_complexity)]
105pub fn parse_mol_with_coords(
106    input: &str,
107) -> Result<(Molecule, MolMetadata, Vec<(f64, f64)>), MolParseError> {
108    // Yields (1-based line number, line text); short-circuits on EOF.
109    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    // -- Header block: lines 1–3 -------------------------------------------
113
114    let name = next_line()?.1.to_string();
115    next_line()?; // line 2: program/date — discarded
116    let comment = next_line()?.1.to_string();
117
118    let metadata = MolMetadata { name, comment };
119
120    // -- Counts line (line 4) -----------------------------------------------
121
122    let (counts_lineno, counts_line) = next_line()?;
123
124    // Be lenient with shorter lines — just check the V2000 tag exists.
125    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    // -- Atom block ---------------------------------------------------------
161
162    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        // Coordinates: bytes 0–9 (x), 10–19 (y), 20–29 (z) — each 10 chars.
173        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        // Element symbol: bytes 31–33 (3 chars, left-padded with a space in
184        // the spec, but writers vary; trim both ends).
185        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        // Charge code: bytes 36–38 (3 chars).
201        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    // -- Bond block ---------------------------------------------------------
212
213    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        // Stereo field (columns 9-11, 0-indexed): only meaningful for single bonds.
236        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    // Skip property lines until "M  END" (or EOF if absent).
267    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
276/// Parse a MOL V2000 string into a `(Molecule, MolMetadata)` pair.
277///
278/// This is a convenience wrapper around [`parse_mol_with_coords`] that discards
279/// the 2D coordinate data.
280pub fn parse_mol(input: &str) -> Result<(Molecule, MolMetadata), MolParseError> {
281    parse_mol_with_coords(input).map(|(mol, meta, _coords)| (mol, meta))
282}
283
284/// Parse all molecules from an SDF string, returning 2D coordinates.
285///
286/// Each entry contains the molecule, its metadata, and a `Vec<(x, y)>` of
287/// 2D coordinates in atom-insertion order (the same order as `.atoms()`).
288///
289/// Stops and returns an error on the first parse failure.
290#[allow(clippy::type_complexity)]
291pub fn parse_sdf_with_coords(
292    input: &str,
293) -> Result<Vec<(Molecule, MolMetadata, Vec<(f64, f64)>)>, MolParseError> {
294    // Re-use the SDF record splitter by borrowing its block-splitting logic,
295    // but call parse_mol_with_coords on each block instead of parse_mol.
296    let mut result = Vec::new();
297    let mut remaining = input;
298    loop {
299        // Skip leading blank lines.
300        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        // Find the $$$$ delimiter (line-by-line to avoid false matches inside data).
311        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
343// ---------------------------------------------------------------------------
344// Writer
345// ---------------------------------------------------------------------------
346
347/// Write a `Molecule` to MOL V2000 format.
348///
349/// Coordinates are written as 0.0 because the core `Molecule` type does not
350/// store 2D/3D coordinates.  All other atom and bond fields are derived from
351/// the molecule graph.
352pub fn write_mol(mol: &Molecule, metadata: &MolMetadata) -> String {
353    write_mol_with_coords(mol, metadata, &[])
354}
355
356/// Serialize `mol` to a V2000 MOL block, using `coords` for atom positions.
357///
358/// `coords[i]` is the `(x, y)` position in Ångström for atom index `i`.
359/// Atoms beyond `coords.len()` receive `(0.0, 0.0, 0.0)`.
360pub 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    // Header lines 1–3
368    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    // Counts line (line 4)
375    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    // Atom block
383    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    // Bond block
394    for (_idx, bond) in mol.bonds() {
395        let a1 = bond.atom1.0 + 1; // convert to 1-based
396        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    // Terminator
412    out.push_str("M  END\n");
413
414    out
415}
416
417// ---------------------------------------------------------------------------
418// SDF writer
419// ---------------------------------------------------------------------------
420
421/// Serialise one or more molecules to SDF format.
422///
423/// `records` — slice of `(molecule, metadata, coords)` tuples.
424/// `coords` is optional; pass an empty slice to write zero coordinates.
425/// Each molecule block is terminated with `$$$$`.
426#[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/// Serialise one or more molecules to SDF format, appending per-atom partial
437/// charges as an SD property `<PARTIAL_CHARGES>`.
438///
439/// `records` — slice of `(molecule, metadata, 2D-coords, charges)` tuples.
440/// `charges[i]` is the partial charge for atom `i` (heavy atoms only).
441/// Pass an empty charges slice to omit the property block.
442///
443/// Example SD block appended after `M  END`:
444/// ```text
445/// > <PARTIAL_CHARGES>
446/// -0.2359 0.1076 -0.4500 0.1806
447///
448/// $$$$
449/// ```
450#[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
468/// Serialise a single molecule to one SDF record with arbitrary SD data fields.
469///
470/// Keys starting with `_` are treated as internal/computed properties and are
471/// omitted from the SD block (e.g. `_Name` is written into the MOL header, not
472/// as an SD field).  The record is terminated with `$$$$`.
473pub 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
489/// Like [`write_sdf_record`] but emits a MOL V3000 (Extended Ctab) block
490/// instead of V2000 — required for molecules with more than 999 atoms or
491/// bonds, which don't fit V2000's fixed-width count fields.
492pub 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// ---------------------------------------------------------------------------
509// Tests
510// ---------------------------------------------------------------------------
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    /// Minimal ethanol MOL V2000 block (CCO, 3 atoms, 2 bonds).
517    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        // Two carbons: single, double, triple, aromatic bonds.
550        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        // Nitrogen with charge code 3 (+1 formal charge).
613        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        // Oxygen with charge code 5 (-1 formal charge).
628        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        // Parse → write → parse again; atom and bond counts must match.
643        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        // Counts line says 3 atoms but only 1 is provided.
679        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        // Counts line too short (no V2000 tag at all).
692        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        // MOL V2000 with a stereo=1 (Up) bond
722        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        // Build a two-atom molecule and write it → name appears on MOL header line 1.
748        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()); // internal, should be omitted
797
798        let sdf = write_sdf_record(&mol, &meta, &coords, &props);
799
800        // _Name must NOT appear as an SD field
801        assert!(
802            !sdf.contains("> <_Name>"),
803            "internal prop leaked to SD block"
804        );
805        // Other props must appear
806        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        // Parse back and verify
811        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}