Skip to main content

readcon_core/
parser.rs

1use crate::error::ParseError;
2use crate::helpers::symbol_to_atomic_number;
3use crate::types::{
4    AtomDatum, ConFrame, FrameHeader, PreboxHeader, SECTION_CHARGES, SECTION_DISPLACEMENTS,
5    SECTION_ENERGIES, SECTION_FORCES, SECTION_MAGMOMS, SECTION_SPINS, SECTION_SPREADS,
6    SECTION_VELOCITIES, meta,
7};
8use serde_json::Value;
9use std::collections::BTreeMap;
10use std::iter::Peekable;
11use std::sync::Arc;
12
13/// Line source with peek for section detection (blank-line separators).
14///
15/// Implemented by the memchr cursor on the hot iterator path and by
16/// `Peekable<I>` for tests / ad-hoc callers.
17pub trait LineStream<'a> {
18    fn next_line(&mut self) -> Option<&'a str>;
19    fn peek_line(&mut self) -> Option<&'a str>;
20    /// True when every remaining line, including the current peek, is
21    /// empty or whitespace. Default is false so a one-line peek cannot
22    /// hide a following section.
23    fn remainder_is_whitespace(&mut self) -> bool {
24        false
25    }
26}
27
28impl<'a, I> LineStream<'a> for Peekable<I>
29where
30    I: Iterator<Item = &'a str>,
31{
32    #[inline]
33    fn next_line(&mut self) -> Option<&'a str> {
34        Iterator::next(self)
35    }
36    #[inline]
37    fn peek_line(&mut self) -> Option<&'a str> {
38        Peekable::peek(self).copied()
39    }
40}
41
42/// Hot-path: parse up to 5 whitespace-separated f64s into a stack buffer.
43/// Returns count of tokens actually present (before padding).
44/// Pads `out[found..max]` from `defaults` when `found < max` and `found >= min`.
45///
46/// Single-pass over the line bytes: skip ASCII whitespace, then
47/// [`fast_float2::parse_partial`] (Eisel–Lemire / SIMD-class decimal kernel)
48/// with a token-boundary check. No `SplitWhitespace`, no per-token `&str`,
49/// no heap `Vec`. Prefer this over allocating [`parse_line_of_n_f64`] on atom lines.
50#[inline]
51pub fn parse_line_of_range_f64_stack(
52    line: &str,
53    min: usize,
54    max: usize,
55    defaults: &[f64],
56    out: &mut [f64; 5],
57) -> Result<usize, ParseError> {
58    debug_assert!(max <= 5 && min <= max && defaults.len() >= max);
59    let bytes = line.as_bytes();
60    let n = bytes.len();
61    let mut i = 0usize;
62    let mut found = 0usize;
63    while found < max {
64        while i < n && bytes[i].is_ascii_whitespace() {
65            i += 1;
66        }
67        if i >= n {
68            break;
69        }
70        let end = bytes[i..]
71            .iter()
72            .position(|b| b.is_ascii_whitespace())
73            .map(|k| i + k)
74            .unwrap_or(n);
75        let token = std::str::from_utf8(&bytes[i..end]).unwrap_or("");
76        if is_nonfinite_spelling(token) {
77            return Err(ParseError::NonFiniteValue(token.to_string()));
78        }
79        let (val, consumed) = fast_float2::parse_partial::<f64, _>(&bytes[i..])
80            .map_err(|_| ParseError::InvalidNumberFormat(format!("invalid float: {token}")))?;
81        let next = i + consumed;
82        // Reject partial tokens like "1.2abc" (must end at whitespace or EOS).
83        if next != end {
84            return Err(ParseError::InvalidNumberFormat(format!(
85                "invalid float: {token}"
86            )));
87        }
88        if !val.is_finite() {
89            return Err(ParseError::NonFiniteValue(token.to_string()));
90        }
91        out[found] = val;
92        found += 1;
93        i = next;
94    }
95    while i < n && bytes[i].is_ascii_whitespace() {
96        i += 1;
97    }
98    // Reject trailing extra tokens (same contract as the old split loop).
99    if i < n {
100        return Err(ParseError::InvalidVectorLength {
101            expected: max,
102            found: found + 1,
103        });
104    }
105    if found < min || found > max {
106        return Err(ParseError::InvalidVectorLength {
107            expected: max,
108            found,
109        });
110    }
111    while found < max {
112        out[found] = defaults[found];
113        found += 1;
114    }
115    Ok(found)
116}
117
118/// Parses a line of whitespace-separated f64 values using fast-float2.
119///
120/// This is the hot-path parser for coordinate and velocity lines. It uses
121/// `fast_float2::parse` instead of `str::parse::<f64>()` for better throughput
122/// on the numeric-heavy atom data lines. Fixed-width atom lines use
123/// [`parse_line_of_range_f64_stack`] to avoid a heap `Vec` per line.
124///
125/// # Arguments
126///
127/// * `line` - A string slice representing a single line of data.
128/// * `n` - The exact number of f64 values expected on the line.
129pub fn parse_line_of_n_f64(line: &str, n: usize) -> Result<Vec<f64>, ParseError> {
130    if n <= 5 {
131        let defaults = [0.0f64; 5];
132        let mut buf = [0.0f64; 5];
133        parse_line_of_range_f64_stack(line, n, n, &defaults, &mut buf)?;
134        return Ok(buf[..n].to_vec());
135    }
136    let mut values = Vec::with_capacity(n);
137    for token in line.split_ascii_whitespace() {
138        values.push(parse_finite_float_token(token)?);
139    }
140    if values.len() == n {
141        Ok(values)
142    } else {
143        Err(ParseError::InvalidVectorLength {
144            expected: n,
145            found: values.len(),
146        })
147    }
148}
149
150/// Parses a line of whitespace-separated f64 values, accepting between `min`
151/// and `max` values (inclusive). Returns a vector of exactly `max` elements,
152/// padding with values from `defaults` when fewer than `max` are present.
153///
154/// Used for atom lines where column 5 (atom_index) is optional.
155/// Prefer [`parse_line_of_range_f64_stack`] on the atom hot path (`max <= 5`).
156pub fn parse_line_of_range_f64(
157    line: &str,
158    min: usize,
159    max: usize,
160    defaults: &[f64],
161) -> Result<Vec<f64>, ParseError> {
162    if max <= 5 {
163        let mut buf = [0.0f64; 5];
164        parse_line_of_range_f64_stack(line, min, max, defaults, &mut buf)?;
165        return Ok(buf[..max].to_vec());
166    }
167    let mut values = Vec::with_capacity(max);
168    for token in line.split_ascii_whitespace() {
169        values.push(parse_finite_float_token(token)?);
170    }
171    if values.len() < min || values.len() > max {
172        return Err(ParseError::InvalidVectorLength {
173            expected: max,
174            found: values.len(),
175        });
176    }
177    while values.len() < max {
178        let idx = values.len();
179        values.push(defaults[idx]);
180    }
181    Ok(values)
182}
183
184/// CON-num.1 spellings. A leading sign is optional. Case is ignored.
185/// Digit and dot tokens return before any letter compare so the atom
186/// hot path does not allocate.
187fn is_nonfinite_spelling(token: &str) -> bool {
188    let rest = token
189        .strip_prefix('+')
190        .or_else(|| token.strip_prefix('-'))
191        .unwrap_or(token);
192    match rest.as_bytes().first() {
193        Some(b) if b.is_ascii_digit() || *b == b'.' => return false,
194        None => return false,
195        _ => {}
196    }
197    rest.eq_ignore_ascii_case("nan")
198        || rest.eq_ignore_ascii_case("inf")
199        || rest.eq_ignore_ascii_case("infinity")
200}
201
202fn parse_finite_float_token(token: &str) -> Result<f64, ParseError> {
203    if is_nonfinite_spelling(token) {
204        return Err(ParseError::NonFiniteValue(token.to_string()));
205    }
206    let (val, consumed) = fast_float2::parse_partial::<f64, _>(token.as_bytes())
207        .map_err(|_| ParseError::InvalidNumberFormat(format!("invalid float: {token}")))?;
208    if consumed != token.len() {
209        return Err(ParseError::InvalidNumberFormat(format!(
210            "invalid float: {token}"
211        )));
212    }
213    if !val.is_finite() {
214        return Err(ParseError::NonFiniteValue(token.to_string()));
215    }
216    Ok(val)
217}
218
219fn skip_ascii_ws(bytes: &[u8], mut i: usize) -> usize {
220    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
221        i += 1;
222    }
223    i
224}
225
226fn take_token(line: &str, i: usize) -> Option<(&str, usize)> {
227    let bytes = line.as_bytes();
228    if i >= bytes.len() {
229        return None;
230    }
231    let mut j = i;
232    while j < bytes.len() && !bytes[j].is_ascii_whitespace() {
233        j += 1;
234    }
235    Some((&line[i..j], j))
236}
237
238/// Column 4. A sign, a decimal point, or a value outside the spec's set
239/// is `InvalidFixedFlag` in every mode (CON-mask.1, CON-mask.5, D8).
240fn parse_fixed_flag(token: &str, spec_version: u32) -> Result<[bool; 3], ParseError> {
241    let bad = || {
242        ParseError::InvalidFixedFlag(format!(
243            "column 4 flag {token:?} is not a legal integer for spec {spec_version}"
244        ))
245    };
246    if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
247        return Err(bad());
248    }
249    let val: u64 = token.parse::<u64>().map_err(|_| bad())?;
250    if spec_version < 2 {
251        return match val {
252            0 => Ok([false, false, false]),
253            1 => Ok([true, true, true]),
254            _ => Err(bad()),
255        };
256    }
257    if val > 7 {
258        return Err(bad());
259    }
260    let v = val as u8;
261    Ok([v & 1 != 0, v & 2 != 0, v & 4 != 0])
262}
263
264/// Column 5. Digits only, parsed as `u64`, never through `f64` (CON-id.1, D9).
265/// A sign or a decimal point is `InvalidNumberFormat` at the token level.
266fn parse_atom_id_token(token: &str) -> Result<u64, ParseError> {
267    if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
268        return Err(ParseError::InvalidNumberFormat(format!(
269            "invalid integer: {token}"
270        )));
271    }
272    token.parse::<u64>().map_err(|_| {
273        ParseError::InvalidNumberFormat(format!("integer out of range 0..=2^64-1: {token}"))
274    })
275}
276
277/// Coordinate row: three finite floats, an integer flag, and an optional id.
278/// Four columns take `default_id`, the 0-based position in the frame
279/// (CON-id.9). Any other width is `InvalidVectorLength` (CON-num.9).
280fn parse_coord_row(
281    line: &str,
282    spec_version: u32,
283    default_id: u64,
284) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
285    parse_vector_row(line, spec_version, Some(default_id))
286}
287
288/// Vector section row. The flag and id columns are required (exact width 5).
289fn parse_vector_section_row(
290    line: &str,
291    spec_version: u32,
292) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
293    parse_vector_row(line, spec_version, None)
294}
295
296fn parse_vector_row(
297    line: &str,
298    spec_version: u32,
299    default_id: Option<u64>,
300) -> Result<([f64; 3], [bool; 3], u64), ParseError> {
301    let bytes = line.as_bytes();
302    let mut i = skip_ascii_ws(bytes, 0);
303    let mut xyz = [0.0f64; 3];
304    for slot in 0..3 {
305        if i >= bytes.len() {
306            return Err(ParseError::InvalidVectorLength {
307                expected: 5,
308                found: slot,
309            });
310        }
311        let (val, next) = parse_finite_at(line, i)?;
312        xyz[slot] = val;
313        i = skip_ascii_ws(bytes, next);
314    }
315    let (flag_tok, i) = take_token(line, i).ok_or(ParseError::InvalidVectorLength {
316        expected: 5,
317        found: 3,
318    })?;
319    let fixed = parse_fixed_flag(flag_tok, spec_version)?;
320    let i = skip_ascii_ws(bytes, i);
321    let (atom_id, i) = match take_token(line, i) {
322        Some((tok, next)) => (parse_atom_id_token(tok)?, next),
323        None => (
324            default_id.ok_or(ParseError::InvalidVectorLength {
325                expected: 5,
326                found: 4,
327            })?,
328            i,
329        ),
330    };
331    let i = skip_ascii_ws(bytes, i);
332    if i < bytes.len() {
333        let found = 5 + line[i..].split_ascii_whitespace().count();
334        return Err(ParseError::InvalidVectorLength { expected: 5, found });
335    }
336    Ok((xyz, fixed, atom_id))
337}
338
339/// Scalar section row: one finite float, a flag, and an id. Exact width 3.
340fn parse_scalar_section_row(
341    line: &str,
342    spec_version: u32,
343) -> Result<(f64, [bool; 3], u64), ParseError> {
344    let bytes = line.as_bytes();
345    let i = skip_ascii_ws(bytes, 0);
346    let (value, i) = parse_finite_at(line, i)?;
347    let i = skip_ascii_ws(bytes, i);
348    let (flag_tok, i) = take_token(line, i).ok_or(ParseError::InvalidVectorLength {
349        expected: 3,
350        found: 1,
351    })?;
352    let fixed = parse_fixed_flag(flag_tok, spec_version)?;
353    let i = skip_ascii_ws(bytes, i);
354    let (id_tok, i) = take_token(line, i).ok_or(ParseError::InvalidVectorLength {
355        expected: 3,
356        found: 2,
357    })?;
358    let atom_id = parse_atom_id_token(id_tok)?;
359    let i = skip_ascii_ws(bytes, i);
360    if i < bytes.len() {
361        let found = 3 + line[i..].split_ascii_whitespace().count();
362        return Err(ParseError::InvalidVectorLength { expected: 3, found });
363    }
364    Ok((value, fixed, atom_id))
365}
366
367fn parse_finite_at(line: &str, i: usize) -> Result<(f64, usize), ParseError> {
368    let bytes = line.as_bytes();
369    if i >= bytes.len() {
370        return Err(ParseError::InvalidVectorLength {
371            expected: 3,
372            found: 0,
373        });
374    }
375    // "infinity" parses as the prefix "inf" in fast_float2. Name that
376    // spelling before the numeric kernel so the leftover is not a format error.
377    if looks_like_nonfinite(bytes, i) {
378        let token = take_token(line, i).map(|(tok, _)| tok).unwrap_or("");
379        if is_nonfinite_spelling(token) {
380            return Err(ParseError::NonFiniteValue(token.to_string()));
381        }
382    }
383    let (val, consumed) = fast_float2::parse_partial::<f64, _>(&bytes[i..]).map_err(|_| {
384        let token = take_token(line, i).map(|(tok, _)| tok).unwrap_or("");
385        ParseError::InvalidNumberFormat(format!("invalid float: {token}"))
386    })?;
387    let next = i + consumed;
388    let boundary = next == bytes.len() || bytes[next].is_ascii_whitespace();
389    if !boundary || !val.is_finite() {
390        let token = take_token(line, i).map(|(tok, _)| tok).unwrap_or("");
391        if is_nonfinite_spelling(token) || !val.is_finite() {
392            return Err(ParseError::NonFiniteValue(token.to_string()));
393        }
394        return Err(ParseError::InvalidNumberFormat(format!(
395            "invalid float: {token}"
396        )));
397    }
398    Ok((val, next))
399}
400
401/// True when the token may be a non-finite spelling. Digit and dot
402/// tokens, the common atom-line case, are false after one byte.
403fn looks_like_nonfinite(bytes: &[u8], i: usize) -> bool {
404    let mut j = i;
405    if bytes[j] == b'+' || bytes[j] == b'-' {
406        j += 1;
407        if j >= bytes.len() {
408            return false;
409        }
410    }
411    let b = bytes[j];
412    !b.is_ascii_digit() && b != b'.'
413}
414
415/// Case-insensitive, after collapsing each run of spaces (CON-file.5, D17).
416fn labels_equal(found: &str, expected: &str) -> bool {
417    let mut found_words = found.split_ascii_whitespace();
418    let mut expected_words = expected.split_ascii_whitespace();
419    loop {
420        match (found_words.next(), expected_words.next()) {
421            (Some(a), Some(b)) => {
422                if !a.eq_ignore_ascii_case(b) {
423                    return false;
424                }
425            }
426            (None, None) => return true,
427            _ => return false,
428        }
429    }
430}
431
432/// True when `line`, after the same folding, starts with every word of `prefix`.
433fn label_has_prefix(line: &str, prefix: &str) -> bool {
434    let mut found_words = line.split_ascii_whitespace();
435    for word in prefix.split_ascii_whitespace() {
436        match found_words.next() {
437            Some(got) if got.eq_ignore_ascii_case(word) => {}
438            _ => return false,
439        }
440    }
441    true
442}
443
444fn token_count(line: &str) -> usize {
445    line.split_ascii_whitespace().count()
446}
447
448fn json_u64(value: &Value) -> Option<u64> {
449    value.as_u64()
450}
451
452fn validate_section_arity_value(value: &Value) -> Result<(), ParseError> {
453    let obj = value.as_object().ok_or_else(|| {
454        metadata_json_error("section_arity must be an object mapping section names to integers")
455    })?;
456    for (name, entry) in obj {
457        let n = json_u64(entry).ok_or_else(|| {
458            metadata_json_error(format!("section_arity.{name} must be an integer >= 1"))
459        })?;
460        if n < 1 {
461            return Err(ParseError::InvalidCount(format!(
462                "section_arity.{name} must be >= 1"
463            )));
464        }
465        // Reserved names have a fixed value-field count (CON-section.13).
466        if let Some(expect) = crate::types::reserved_section_value_arity(name)
467            && n != expect
468        {
469            return Err(ParseError::InvalidVectorLength {
470                expected: usize::try_from(expect).unwrap_or(usize::MAX),
471                found: usize::try_from(n).unwrap_or(usize::MAX),
472            });
473        }
474    }
475    Ok(())
476}
477
478/// Integer counts. A sign, a decimal point, or any other non-digit token
479/// is `InvalidCount` (CON-error.3). Zero is a legal token here. The caller
480/// rejects a per-type count of 0 when the type count is 1 or more (D4).
481pub(crate) fn parse_count_line(line: &str, n: usize) -> Result<Vec<usize>, ParseError> {
482    let mut values = Vec::with_capacity(n);
483    for token in line.split_ascii_whitespace() {
484        if token.is_empty() || !token.bytes().all(|b| b.is_ascii_digit()) {
485            return Err(ParseError::InvalidCount(format!(
486                "count field is not an unsigned integer: {token}"
487            )));
488        }
489        let val: u64 = token.parse::<u64>().map_err(|_| {
490            ParseError::InvalidCount(format!("count field is out of range: {token}"))
491        })?;
492        let val = usize::try_from(val).map_err(|_| {
493            ParseError::InvalidCount(format!("count field is out of range: {token}"))
494        })?;
495        values.push(val);
496    }
497    if values.len() == n {
498        Ok(values)
499    } else {
500        Err(ParseError::InvalidVectorLength {
501            expected: n,
502            found: values.len(),
503        })
504    }
505}
506
507/// True when `line` has no tokens. Spaces and tabs count as empty.
508pub(crate) fn line_is_whitespace(line: &str) -> bool {
509    line.split_ascii_whitespace().next().is_none()
510}
511
512/// Lines 8 and 9 of a type count of 0.
513///
514/// A token on either line is `InvalidCount`. At end of file, a missing
515/// line is empty (CON-count.4), and that frame is not `IncompleteHeader`
516/// (CON-frame.3). Mid-file, both lines are required.
517pub(crate) fn read_zero_atom_tail<'a>(
518    mut next_line: impl FnMut() -> Option<&'a str>,
519) -> Result<(), ParseError> {
520    let bad = || {
521        ParseError::InvalidCount("a zero-atom frame requires whitespace-only lines 8 and 9".into())
522    };
523    match next_line() {
524        None => Ok(()),
525        Some(line) if line_is_whitespace(line) => match next_line() {
526            None => Ok(()),
527            Some(line) if line_is_whitespace(line) => Ok(()),
528            Some(_) => Err(bad()),
529        },
530        Some(_) => Err(bad()),
531    }
532}
533
534fn metadata_json_error(message: impl Into<String>) -> ParseError {
535    ParseError::InvalidMetadataJson(message.into())
536}
537
538fn validate_metadata_number(key: &str, value: &Value) -> Result<(), ParseError> {
539    if value.as_f64().is_some() {
540        Ok(())
541    } else {
542        Err(metadata_json_error(format!(
543            "{key} must be a finite number"
544        )))
545    }
546}
547
548fn validate_metadata_integer(key: &str, value: &Value) -> Result<(), ParseError> {
549    if value.as_u64().is_some() {
550        Ok(())
551    } else {
552        Err(metadata_json_error(format!(
553            "{key} must be a non-negative integer"
554        )))
555    }
556}
557
558/// Validates a parsed metadata JSON object against the spec v2 schema.
559///
560/// Type-checks the `validate` and `sections` keys, then runs per-key
561/// schema checks for the recommended metadata keys. Used by the
562/// builder/Python `set_metadata_json` paths to fail fast on malformed
563/// input, and by the parser when the file requested strict validation
564/// via `"validate": true`.
565pub fn validate_metadata_schema(
566    json_obj: &serde_json::Map<String, Value>,
567) -> Result<(), ParseError> {
568    let strict_requested = match json_obj.get(meta::VALIDATE) {
569        Some(Value::Bool(b)) => *b,
570        Some(_) => return Err(metadata_json_error("validate must be a boolean")),
571        None => false,
572    };
573
574    match json_obj.get(meta::SECTIONS) {
575        Some(Value::Array(values)) => {
576            if values.iter().any(|entry| !entry.is_string()) {
577                return Err(metadata_json_error("sections must be an array of strings"));
578            }
579        }
580        Some(_) => return Err(metadata_json_error("sections must be an array of strings")),
581        None if strict_requested => {
582            return Err(metadata_json_error(
583                "validate=true requires a sections array, even when empty",
584            ));
585        }
586        None => {}
587    }
588
589    for (key, value) in json_obj {
590        match key.as_str() {
591            meta::CON_SPEC_VERSION | meta::SECTIONS | meta::VALIDATE => {}
592            meta::CON_SPEC_MINOR => {
593                if json_u64(value).is_none() {
594                    return Err(metadata_json_error(
595                        "con_spec_minor must be a JSON integer >= 0",
596                    ));
597                }
598            }
599            meta::SECTION_ARITY => validate_section_arity_value(value)?,
600            meta::ENERGY
601            | meta::TIME
602            | meta::TIMESTEP
603            | meta::CONVERGENCE_FMAX
604            | meta::CONVERGENCE_ENERGY
605            | meta::FMAX => validate_metadata_number(key, value)?,
606            meta::FRAME_INDEX | meta::NEB_BEAD | meta::NEB_BAND => {
607                validate_metadata_integer(key, value)?
608            }
609            meta::GENERATOR if !value.is_string() => {
610                return Err(metadata_json_error("generator must be a string"));
611            }
612            meta::GENERATOR => {}
613            meta::UNITS | meta::POTENTIAL if !value.is_object() => {
614                return Err(metadata_json_error(format!("{key} must be an object")));
615            }
616            meta::POTENTIAL => {
617                if let Some(potential_type) = value.get("type")
618                    && !potential_type.is_string()
619                {
620                    return Err(metadata_json_error("potential.type must be a string"));
621                }
622            }
623            meta::UNITS => {
624                // Full v3 checks (required keys) run when con_spec_version >= 3.
625            }
626            meta::PBC => validate_pbc_metadata(value)?,
627            meta::BONDS => validate_bonds_metadata(value)?,
628            meta::LATTICE_VECTORS => validate_lattice_vectors_metadata(value)?,
629            meta::CONVERGED if !value.is_boolean() => {
630                return Err(metadata_json_error("converged must be a boolean"));
631            }
632            meta::CONVERGED => {}
633            _ => {}
634        }
635    }
636
637    Ok(())
638}
639
640fn validate_pbc_metadata(value: &Value) -> Result<(), ParseError> {
641    let Some(values) = value.as_array() else {
642        return Err(metadata_json_error("pbc must be a length-3 boolean array"));
643    };
644    if values.len() != 3 || values.iter().any(|entry| !entry.is_boolean()) {
645        return Err(metadata_json_error("pbc must be a length-3 boolean array"));
646    }
647    Ok(())
648}
649
650/// Validate optional `bonds` frame topology metadata.
651///
652/// Each element is either a length-2 non-negative integer pair `[i, j]` or an
653/// object `{"i": i, "j": j, "order"?: integer}`. Indices are 0-based into
654/// `atom_data` order (parser does not yet know atom count at metadata time;
655/// bounds are enforced when projecting to chemfiles / selection).
656fn validate_bonds_metadata(value: &Value) -> Result<(), ParseError> {
657    let Some(items) = value.as_array() else {
658        return Err(metadata_json_error("bonds must be an array"));
659    };
660    for (idx, item) in items.iter().enumerate() {
661        if let Some(pair) = item.as_array() {
662            if pair.len() != 2 {
663                return Err(metadata_json_error(format!(
664                    "bonds[{idx}] pair must have exactly two indices"
665                )));
666            }
667            for (k, entry) in pair.iter().enumerate() {
668                let Some(n) = entry.as_u64() else {
669                    return Err(metadata_json_error(format!(
670                        "bonds[{idx}][{k}] must be a non-negative integer"
671                    )));
672                };
673                if n > u32::MAX as u64 {
674                    return Err(metadata_json_error(format!(
675                        "bonds[{idx}][{k}] index exceeds u32"
676                    )));
677                }
678            }
679            continue;
680        }
681        if let Some(obj) = item.as_object() {
682            for key in ["i", "j"] {
683                let Some(entry) = obj.get(key) else {
684                    return Err(metadata_json_error(format!(
685                        "bonds[{idx}] object must include \"{key}\""
686                    )));
687                };
688                let Some(n) = entry.as_u64() else {
689                    return Err(metadata_json_error(format!(
690                        "bonds[{idx}].{key} must be a non-negative integer"
691                    )));
692                };
693                if n > u32::MAX as u64 {
694                    return Err(metadata_json_error(format!(
695                        "bonds[{idx}].{key} index exceeds u32"
696                    )));
697                }
698            }
699            if let Some(order) = obj.get("order")
700                && order.as_i64().is_none()
701            {
702                return Err(metadata_json_error(format!(
703                    "bonds[{idx}].order must be an integer when present"
704                )));
705            }
706            continue;
707        }
708        return Err(metadata_json_error(format!(
709            "bonds[{idx}] must be [i, j] or {{\"i\": i, \"j\": j, \"order\"?: ...}}"
710        )));
711    }
712    Ok(())
713}
714
715fn validate_lattice_vectors_metadata(value: &Value) -> Result<(), ParseError> {
716    let Some(rows) = value.as_array() else {
717        return Err(metadata_json_error(
718            "lattice_vectors must be a 3x3 numeric array",
719        ));
720    };
721    if rows.len() != 3 {
722        return Err(metadata_json_error(
723            "lattice_vectors must be a 3x3 numeric array",
724        ));
725    }
726    for row in rows {
727        let Some(entries) = row.as_array() else {
728            return Err(metadata_json_error(
729                "lattice_vectors must be a 3x3 numeric array",
730            ));
731        };
732        if entries.len() != 3 || entries.iter().any(|entry| entry.as_f64().is_none()) {
733            return Err(metadata_json_error(
734                "lattice_vectors must be a 3x3 numeric array",
735            ));
736        }
737    }
738    Ok(())
739}
740
741/// Parses a line of whitespace-separated values into a vector of a specific type.
742///
743/// This generic helper function takes a string slice, splits it by whitespace,
744/// and attempts to parse each substring into the target type `T`. The type `T`
745/// must implement `std::str::FromStr`.
746///
747/// # Arguments
748///
749/// * `line` - A string slice representing a single line of data.
750/// * `n` - The exact number of values expected on the line.
751///
752/// # Errors
753///
754/// * `ParseError::InvalidVectorLength` if the number of parsed values is not equal to `n`.
755/// * Propagates any error from the `parse()` method of the target type `T`.
756///
757/// # Example
758///
759/// ```
760/// use readcon_core::parser::parse_line_of_n;
761/// let line = "10.5 20.0 30.5";
762/// let values: Vec<f64> = parse_line_of_n(line, 3).unwrap();
763/// assert_eq!(values, vec![10.5, 20.0, 30.5]);
764///
765/// let result = parse_line_of_n::<i32>(line, 2);
766/// assert!(result.is_err());
767/// ```
768pub fn parse_line_of_n<T: std::str::FromStr>(line: &str, n: usize) -> Result<Vec<T>, ParseError>
769where
770    ParseError: From<<T as std::str::FromStr>::Err>,
771{
772    let values: Vec<T> = line
773        .split_whitespace()
774        .map(|s| s.parse::<T>())
775        .collect::<Result<_, _>>()?;
776
777    if values.len() == n {
778        Ok(values)
779    } else {
780        Err(ParseError::InvalidVectorLength {
781            expected: n,
782            found: values.len(),
783        })
784    }
785}
786
787/// Parses the 9-line header of a `.con` file frame from an iterator.
788///
789/// This function consumes the next 9 lines from the given line iterator to
790/// construct a `FrameHeader`. The iterator is advanced by 9 lines on success.
791///
792/// # Arguments
793///
794/// * `lines` - A mutable reference to an iterator that yields string slices.
795///
796/// # Errors
797///
798/// * `ParseError::IncompleteHeader` if the iterator has fewer than 9 lines remaining.
799/// * Propagates any errors from `parse_line_of_n` if the numeric data within
800///   the header is malformed.
801///
802/// # Panics
803///
804/// This function will panic if the intermediate vectors for box dimensions or angles,
805/// after being successfully parsed, cannot be converted into fixed-size arrays.
806/// This should not happen if `parse_line_of_n` is used correctly with `n=3`.
807pub fn parse_frame_header<'a>(
808    lines: &mut impl Iterator<Item = &'a str>,
809) -> Result<FrameHeader, ParseError> {
810    let prebox1 = lines
811        .next()
812        .ok_or(ParseError::IncompleteHeader)?
813        .to_string();
814    // CON-file.2: a leading BOM is rejected. It would sit on line 1.
815    if prebox1.starts_with('\u{feff}') {
816        return Err(ParseError::ValidationError(
817            "file begins with a byte-order mark".into(),
818        ));
819    }
820    let prebox2_raw = lines.next().ok_or(ParseError::IncompleteHeader)?;
821
822    // Line 2: if it starts with '{', parse as JSON metadata (spec v2+).
823    // Otherwise treat as a legacy (pre-v2) file with spec_version = 1.
824    // One JSON object and nothing else (CON-line2.1). serde_json::from_str
825    // rejects trailing junk after the value.
826    let trimmed = prebox2_raw.trim();
827    let (spec_version, metadata, sections, validate, sections_declared) =
828        if trimmed.starts_with('{') {
829            let json_val: serde_json::Value = serde_json::from_str(trimmed)
830                .map_err(|e| ParseError::InvalidMetadataJson(e.to_string()))?;
831            let json_obj = json_val.as_object().ok_or_else(|| {
832                ParseError::InvalidMetadataJson("expected a JSON object".to_string())
833            })?;
834            let ver_value = json_obj
835                .get(meta::CON_SPEC_VERSION)
836                .ok_or(ParseError::MissingSpecVersion)?;
837            let ver = json_u64(ver_value).ok_or_else(|| {
838                ParseError::InvalidMetadataJson(
839                    "con_spec_version must be a JSON integer, the major version".into(),
840                )
841            })?;
842            if ver > u64::from(u32::MAX) {
843                return Err(ParseError::UnsupportedVersion(u32::MAX));
844            }
845            let ver = ver as u32;
846            // JSON majors 0 and 1 are UnsupportedVersion (CON-line2.5).
847            // An unknown major is UnsupportedVersion (CON-line2.6).
848            // Unknown minors of a known major are accepted (CON-line2.7, CON-status.1).
849            if ver == 0 || ver == 1 || ver > crate::CON_SPEC_VERSION {
850                return Err(ParseError::UnsupportedVersion(ver));
851            }
852            if ver >= 3 {
853                match json_obj.get(meta::UNITS) {
854                    Some(u) => crate::units::validate_v3_units_metadata(u)
855                        .map_err(|e| ParseError::InvalidUnits(format!("v3 units: {e}")))?,
856                    None => {
857                        return Err(ParseError::InvalidUnits(
858                        "con_spec_version >= 3 requires metadata \"units\" with length and energy"
859                            .into(),
860                    ));
861                    }
862                }
863            }
864
865            // Single pass over the JSON object: collect sections, capture the
866            // validate flag, copy the rest into metadata. Folds the previous
867            // pre-extract get(validate) + re-iterate pattern into one walk.
868            let mut sections: Vec<String> = Vec::new();
869            let mut metadata = BTreeMap::new();
870            let mut sections_declared = false;
871            let mut validate = false;
872            for (k, v) in json_obj {
873                match k.as_str() {
874                    meta::CON_SPEC_VERSION => {}
875                    meta::CON_SPEC_MINOR => {
876                        if json_u64(v).is_none() {
877                            return Err(metadata_json_error(
878                                "con_spec_minor must be a JSON integer >= 0",
879                            ));
880                        }
881                        metadata.insert(k.clone(), v.clone());
882                    }
883                    meta::SECTION_ARITY => {
884                        validate_section_arity_value(v)?;
885                        metadata.insert(k.clone(), v.clone());
886                    }
887                    meta::SECTIONS => {
888                        sections_declared = true;
889                        let arr = v.as_array().ok_or_else(|| {
890                            metadata_json_error("sections must be an array of strings")
891                        })?;
892                        sections.reserve(arr.len());
893                        for entry in arr {
894                            let s = entry.as_str().ok_or_else(|| {
895                                metadata_json_error("sections must be an array of strings")
896                            })?;
897                            sections.push(s.to_string());
898                        }
899                    }
900                    meta::VALIDATE => {
901                        validate = match v {
902                            Value::Bool(b) => *b,
903                            _ => return Err(metadata_json_error("validate must be a boolean")),
904                        };
905                        metadata.insert(k.clone(), v.clone());
906                    }
907                    _ => {
908                        metadata.insert(k.clone(), v.clone());
909                    }
910                }
911            }
912
913            // Strict-mode schema check fires only when the file requested
914            // it. Hot-path parses (validate=false) skip the per-key match.
915            if validate {
916                validate_metadata_schema(json_obj)?;
917            }
918
919            (ver, metadata, sections, validate, sections_declared)
920        } else {
921            // Legacy file: no JSON metadata line.
922            (1_u32, BTreeMap::new(), Vec::new(), false, false)
923        };
924    let prebox2 = prebox2_raw.to_string();
925
926    let boxl_vec = parse_line_of_n_f64(lines.next().ok_or(ParseError::IncompleteHeader)?, 3)?;
927    let angles_vec = parse_line_of_n_f64(lines.next().ok_or(ParseError::IncompleteHeader)?, 3)?;
928    let postbox1 = lines
929        .next()
930        .ok_or(ParseError::IncompleteHeader)?
931        .to_string();
932    let postbox2 = lines
933        .next()
934        .ok_or(ParseError::IncompleteHeader)?
935        .to_string();
936    let natm_types_line = lines.next().ok_or(ParseError::IncompleteHeader)?;
937    let natm_types = parse_count_line(natm_types_line, 1)?[0];
938    // A type count of 0 is a zero-atom frame (CON-count.1, CON-count.4).
939    // A per-type count of 0 is InvalidCount only when the type count is >= 1.
940    let (natms_per_type, masses_per_type) = if natm_types == 0 {
941        read_zero_atom_tail(|| lines.next())?;
942        (Vec::new(), Vec::new())
943    } else {
944        let natms_per_type = parse_count_line(
945            lines.next().ok_or(ParseError::IncompleteHeader)?,
946            natm_types,
947        )?;
948        if natms_per_type.iter().any(|n| *n == 0) {
949            return Err(ParseError::InvalidCount(
950                "each per-type count must be >= 1".into(),
951            ));
952        }
953        let masses_per_type = parse_line_of_n_f64(
954            lines.next().ok_or(ParseError::IncompleteHeader)?,
955            natm_types,
956        )?;
957        (natms_per_type, masses_per_type)
958    };
959    if validate {
960        validate_header_geometry(&boxl_vec, &angles_vec)?;
961        validate_masses(&masses_per_type)?;
962    }
963    Ok(FrameHeader {
964        prebox_header: PreboxHeader {
965            user: prebox1,
966            metadata_line: prebox2,
967        },
968        boxl: boxl_vec.try_into().unwrap(),
969        angles: angles_vec.try_into().unwrap(),
970        postbox_header: [postbox1, postbox2],
971        natm_types,
972        natms_per_type,
973        masses_per_type,
974        spec_version,
975        metadata,
976        sections,
977        strict_validation: validate,
978        sections_declared,
979    })
980}
981
982/// Parses a complete frame from a `.con` file, including its header and atomic data.
983///
984/// This function first parses the complete frame header and then uses the information within it
985/// (specifically the number of atom types and atoms per type) to parse the subsequent
986/// atom coordinate blocks.
987///
988/// # Arguments
989///
990/// * `lines` - A mutable reference to an iterator that yields string slices for the frame.
991///
992/// # Errors
993///
994/// * `ParseError::IncompleteFrame` if the iterator ends before all expected
995///   atomic data has been read.
996/// * Propagates any errors from the underlying calls to `parse_frame_header` and
997///   `parse_line_of_n`.
998///
999/// # Example
1000///
1001/// ```
1002/// use readcon_core::parser::parse_single_frame;
1003///
1004/// let frame_text = r#"
1005///Generated by test
1006///{"con_spec_version":2}
1007///10.0 10.0 10.0
1008///90.0 90.0 90.0
1009///POSTBOX LINE 1
1010///POSTBOX LINE 2
1011///2
1012///1 1
1013///12.011 1.008
1014///C
1015///Coordinates of Component 1
1016///1.0 1.0 1.0 0 1
1017///H
1018///Coordinates of Component 2
1019///2.0 2.0 2.0 0 2
1020/// "#;
1021///
1022/// let mut lines = frame_text.trim().lines();
1023/// let con_frame = parse_single_frame(&mut lines).unwrap();
1024///
1025/// assert_eq!(con_frame.header.natm_types, 2);
1026/// assert_eq!(con_frame.atom_data.len(), 2);
1027/// assert_eq!(&*con_frame.atom_data[0].symbol, "C");
1028/// assert_eq!(con_frame.atom_data[1].atom_id, 2);
1029/// ```
1030pub fn parse_single_frame<'a>(
1031    lines: &mut impl Iterator<Item = &'a str>,
1032) -> Result<ConFrame, ParseError> {
1033    let header = parse_frame_header(lines)?;
1034    let validate = header.strict_validation;
1035    let total_atoms: usize = header.natms_per_type.iter().sum();
1036    let mut atom_data = Vec::with_capacity(total_atoms);
1037    // SoA positions: default f64 fills a flat `Vec` then one Arc wrap (profile:
1038    // per-row ArcArray mut checks were a real cost on multi-atom parse).
1039    use crate::storage_dtype::{ElementKind, FloatArray2, StorageDtypes};
1040    let dt = StorageDtypes::from_metadata(&header.metadata).unwrap_or_default();
1041    let f64_positions = dt.positions == ElementKind::Float64;
1042    let mut pos_flat = if f64_positions {
1043        vec![0.0f64; total_atoms.saturating_mul(3)]
1044    } else {
1045        Vec::new()
1046    };
1047    let mut positions_other = if f64_positions {
1048        None
1049    } else {
1050        Some(FloatArray2::zeros(dt.positions, total_atoms, 3))
1051    };
1052
1053    let mut global_atom_idx: u64 = 0;
1054    let mut atom_i = 0usize;
1055    for (type_idx, num_atoms) in header.natms_per_type.iter().enumerate() {
1056        // Allocate the per-component Arc<str> directly from the trimmed
1057        // line; going through a String intermediate would add a second
1058        // allocation and copy for no semantic gain.
1059        let symbol_line = lines.next().ok_or(ParseError::IncompleteFrame)?;
1060        let symbol: Arc<str> = Arc::from(symbol_line.trim());
1061        let coord_label = lines.next().ok_or(ParseError::IncompleteFrame)?;
1062        if validate {
1063            validate_coordinate_component(type_idx, symbol.as_ref(), coord_label)?;
1064        }
1065        for _ in 0..*num_atoms {
1066            let coord_line = lines.next().ok_or(ParseError::IncompleteFrame)?;
1067            // Column 5 is optional and defaults to the 0-based position
1068            // in the frame (CON-id.9). Flag and id are integer tokens (D8, D9).
1069            let (xyz, fixed, atom_id) =
1070                parse_coord_row(coord_line, header.spec_version, global_atom_idx)?;
1071            if f64_positions {
1072                let o = atom_i * 3;
1073                pos_flat[o] = xyz[0];
1074                pos_flat[o + 1] = xyz[1];
1075                pos_flat[o + 2] = xyz[2];
1076            } else if let Some(ref mut pos) = positions_other {
1077                pos.set_f64_row(atom_i, xyz);
1078            }
1079            atom_data.push(AtomDatum {
1080                // This is a cheap reference-count increment, not a full string clone.
1081                symbol: Arc::clone(&symbol),
1082                x: xyz[0],
1083                y: xyz[1],
1084                z: xyz[2],
1085                fixed,
1086                atom_id,
1087                velocity: None,
1088                force: None,
1089                energy: None,
1090                charge: None,
1091                spin: None,
1092                magmom: None,
1093                displacement: None,
1094                spread: None,
1095            });
1096            global_atom_idx += 1;
1097            atom_i += 1;
1098        }
1099    }
1100    let positions = if f64_positions {
1101        FloatArray2::from_f64_row_major(total_atoms, 3, pos_flat)
1102    } else {
1103        positions_other.expect("non-f64 positions allocated")
1104    };
1105    // Sections still attach to AoS; assemble uses prefilled positions (no second pos pass).
1106    Ok(crate::types::con_frame_from_atom_data_with_positions(
1107        header, atom_data, positions,
1108    ))
1109}
1110
1111fn validate_header_geometry(boxl: &[f64], angles: &[f64]) -> Result<(), ParseError> {
1112    if boxl
1113        .iter()
1114        .any(|length| !length.is_finite() || *length <= 0.0)
1115        || angles
1116            .iter()
1117            .any(|angle| !angle.is_finite() || *angle <= 0.0 || *angle >= 180.0)
1118    {
1119        return Err(ParseError::ValidationError(
1120            "cell geometry must have positive lengths and angles between 0 and 180 degrees"
1121                .to_string(),
1122        ));
1123    }
1124    Ok(())
1125}
1126
1127fn validate_masses(masses_per_type: &[f64]) -> Result<(), ParseError> {
1128    if masses_per_type
1129        .iter()
1130        .any(|mass| !mass.is_finite() || *mass <= 0.0)
1131    {
1132        return Err(ParseError::ValidationError(
1133            "component masses must be positive".to_string(),
1134        ));
1135    }
1136    Ok(())
1137}
1138
1139fn validate_coordinate_component(
1140    type_idx: usize,
1141    symbol: &str,
1142    label: &str,
1143) -> Result<(), ParseError> {
1144    let expected_label = format!("Coordinates of Component {}", type_idx + 1);
1145    if !labels_equal(label, &expected_label) {
1146        return Err(ParseError::ValidationError(format!(
1147            "expected coordinate label {expected_label:?}, found {label:?}"
1148        )));
1149    }
1150    if symbol != "X" && symbol_to_atomic_number(symbol) == 0 {
1151        return Err(ParseError::ValidationError(format!(
1152            "unknown component symbol {symbol}"
1153        )));
1154    }
1155    Ok(())
1156}
1157
1158fn validate_section_component(
1159    section: &str,
1160    type_idx: usize,
1161    atom_idx: usize,
1162    symbol: &str,
1163    label: &str,
1164    header: &FrameHeader,
1165    atom_data: &[AtomDatum],
1166) -> Result<(), ParseError> {
1167    let expected_label = format!("{section} of Component {}", type_idx + 1);
1168    if !labels_equal(label, &expected_label) {
1169        return Err(ParseError::ValidationError(format!(
1170            "expected section label {expected_label:?}, found {label:?}"
1171        )));
1172    }
1173
1174    if header.natms_per_type[type_idx] == 0 {
1175        return Ok(());
1176    }
1177
1178    let expected_symbol = atom_data
1179        .get(atom_idx)
1180        .map(|atom| atom.symbol.as_ref())
1181        .ok_or_else(|| {
1182            ParseError::ValidationError(format!(
1183                "{section} component {} has no coordinate atom to validate against",
1184                type_idx + 1
1185            ))
1186        })?;
1187    if symbol != expected_symbol {
1188        return Err(ParseError::ValidationError(format!(
1189            "{section} component {} symbol mismatch: expected {expected_symbol}, found {symbol}",
1190            type_idx + 1
1191        )));
1192    }
1193
1194    Ok(())
1195}
1196
1197fn validate_section_atom_identity(
1198    section: &str,
1199    atom_idx: usize,
1200    fixed: [bool; 3],
1201    atom_id: u64,
1202    atom_data: &[AtomDatum],
1203) -> Result<(), ParseError> {
1204    let atom = atom_data.get(atom_idx).ok_or_else(|| {
1205        ParseError::ValidationError(format!(
1206            "{section} row {atom_idx} has no coordinate atom to validate against"
1207        ))
1208    })?;
1209
1210    if atom.fixed != fixed {
1211        return Err(ParseError::ValidationError(format!(
1212            "{section} row {atom_idx} fixed mask mismatch for atom_id {}",
1213            atom.atom_id
1214        )));
1215    }
1216    if atom.atom_id != atom_id {
1217        return Err(ParseError::ValidationError(format!(
1218            "{section} row {atom_idx} atom_id mismatch: expected {}, found {atom_id}",
1219            atom.atom_id
1220        )));
1221    }
1222
1223    Ok(())
1224}
1225
1226/// Consume the single blank line that opens a section.
1227///
1228/// A blank line followed only by whitespace to EOF does not open a section
1229/// (CON-frame.5). The lines stay in the stream.
1230fn frame_minor(header: &FrameHeader) -> u64 {
1231    header
1232        .metadata
1233        .get(meta::CON_SPEC_MINOR)
1234        .and_then(|v| v.as_u64())
1235        .unwrap_or(0)
1236}
1237
1238fn section_row_arity(header: &FrameHeader, name: &str) -> Option<usize> {
1239    let obj = header.metadata.get(meta::SECTION_ARITY)?.as_object()?;
1240    let n = obj.get(name)?.as_u64()?;
1241    if n >= 1 { Some(n as usize) } else { None }
1242}
1243
1244/// Skip one unknown section of a newer minor.
1245///
1246/// `arity` is the value-field count (CON-section.12). Each data row holds
1247/// that many values, then the flag, and an optional id, so the row has
1248/// `arity + 1` or `arity + 2` tokens (CON-section.14). The label is the
1249/// section name under CON-file.5, then `of Component N` (CON-section.15).
1250fn skip_unknown_section<'a>(
1251    lines: &mut impl LineStream<'a>,
1252    header: &FrameHeader,
1253    name: &str,
1254    arity: usize,
1255) -> Result<(), ParseError> {
1256    if !open_section(lines)? {
1257        return Err(ParseError::IncompleteSection(name.to_string()));
1258    }
1259    let with_flag = arity.saturating_add(1);
1260    let with_id = arity.saturating_add(2);
1261    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1262        if lines.next_line().is_none() {
1263            return Err(ParseError::IncompleteSection(name.to_string()));
1264        }
1265        let label = lines
1266            .next_line()
1267            .ok_or_else(|| ParseError::IncompleteSection(name.to_string()))?;
1268        let expected = format!("{name} of Component {}", type_idx + 1);
1269        if !labels_equal(label, &expected) {
1270            return Err(ParseError::IncompleteSection(name.to_string()));
1271        }
1272        for _ in 0..num_atoms {
1273            let row = lines
1274                .next_line()
1275                .ok_or_else(|| ParseError::IncompleteSection(name.to_string()))?;
1276            let found = token_count(row);
1277            if found != with_flag && found != with_id {
1278                let expected = if found < with_flag {
1279                    with_flag
1280                } else {
1281                    with_id
1282                };
1283                return Err(ParseError::InvalidVectorLength { expected, found });
1284            }
1285        }
1286    }
1287    Ok(())
1288}
1289
1290fn open_section<'a>(lines: &mut impl LineStream<'a>) -> Result<bool, ParseError> {
1291    match lines.peek_line() {
1292        Some(line) if line.trim().is_empty() => {
1293            if lines.remainder_is_whitespace() {
1294                return Ok(false);
1295            }
1296            lines.next_line();
1297            Ok(true)
1298        }
1299        _ => Ok(false),
1300    }
1301}
1302
1303/// Attempts to parse an optional velocity section following coordinate blocks.
1304///
1305/// In `.convel` files, after all coordinate blocks there is a blank separator line
1306/// followed by per-component velocity blocks with the same structure as coordinate
1307/// blocks (symbol line, "Velocities of Component N" line, then atom lines with
1308/// `vx vy vz fixed atomID`).
1309///
1310/// This function peeks at the next line. If it is blank (or contains only whitespace),
1311/// it consumes the blank line and parses velocity data into the existing `atom_data`.
1312/// If the next line is not blank (or is absent), no velocities are parsed.
1313///
1314/// Returns `Ok(true)` if velocities were found and parsed, `Ok(false)` otherwise.
1315pub fn parse_velocity_section<'a>(
1316    lines: &mut impl LineStream<'a>,
1317    header: &FrameHeader,
1318    atom_data: &mut [AtomDatum],
1319) -> Result<bool, ParseError> {
1320    let validate = header.strict_validation;
1321    // A blank line followed only by whitespace to EOF is not a velocity
1322    // block (CON-frame.5, D2). Leave those lines for the iterator.
1323    match lines.peek_line() {
1324        Some(line) if line.trim().is_empty() => {
1325            if lines.remainder_is_whitespace() {
1326                return Ok(false);
1327            }
1328            lines.next_line();
1329        }
1330        _ => return Ok(false),
1331    }
1332
1333    let mut atom_idx: usize = 0;
1334    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1335        // Symbol line
1336        let symbol = lines
1337            .next_line()
1338            .ok_or(ParseError::IncompleteSection(SECTION_VELOCITIES.into()))?
1339            .trim();
1340
1341        // "Velocities of Component N" line
1342        let comp_line = lines
1343            .next_line()
1344            .ok_or(ParseError::IncompleteSection(SECTION_VELOCITIES.into()))?;
1345        if !label_has_prefix(comp_line, "Velocities of Component") {
1346            return Err(ParseError::IncompleteSection(SECTION_VELOCITIES.into()));
1347        }
1348        if validate {
1349            validate_section_component(
1350                "Velocities",
1351                type_idx,
1352                atom_idx,
1353                symbol,
1354                comp_line,
1355                header,
1356                atom_data,
1357            )?;
1358        }
1359
1360        for _ in 0..num_atoms {
1361            let vel_line = lines
1362                .next_line()
1363                .ok_or(ParseError::IncompleteSection(SECTION_VELOCITIES.into()))?;
1364            let (vals, fixed, atom_id) = parse_vector_section_row(vel_line, header.spec_version)?;
1365            if validate {
1366                validate_section_atom_identity("velocities", atom_idx, fixed, atom_id, atom_data)?;
1367            }
1368            if atom_idx < atom_data.len() {
1369                atom_data[atom_idx].velocity = Some(vals);
1370            }
1371            atom_idx += 1;
1372        }
1373    }
1374
1375    Ok(true)
1376}
1377
1378/// Attempts to parse a force section following coordinate (and optional velocity) blocks.
1379///
1380/// Force sections mirror velocity sections: a blank separator line followed by per-component
1381/// force blocks (symbol line, "Forces of Component N" line, then atom lines with
1382/// `fx fy fz fixed_flag atom_id`).
1383///
1384/// Returns `Ok(true)` if forces were found and parsed, `Ok(false)` otherwise.
1385pub fn parse_force_section<'a>(
1386    lines: &mut impl LineStream<'a>,
1387    header: &FrameHeader,
1388    atom_data: &mut [AtomDatum],
1389) -> Result<bool, ParseError> {
1390    let validate = header.strict_validation;
1391    if !open_section(lines)? {
1392        return Ok(false);
1393    }
1394
1395    let mut atom_idx: usize = 0;
1396    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1397        let symbol = lines
1398            .next_line()
1399            .ok_or(ParseError::IncompleteSection(SECTION_FORCES.into()))?
1400            .trim();
1401
1402        let comp_line = lines
1403            .next_line()
1404            .ok_or(ParseError::IncompleteSection(SECTION_FORCES.into()))?;
1405        if !label_has_prefix(comp_line, "Forces of Component") {
1406            return Err(ParseError::IncompleteSection(SECTION_FORCES.into()));
1407        }
1408        if validate {
1409            validate_section_component(
1410                "Forces", type_idx, atom_idx, symbol, comp_line, header, atom_data,
1411            )?;
1412        }
1413
1414        for _ in 0..num_atoms {
1415            let force_line = lines
1416                .next_line()
1417                .ok_or(ParseError::IncompleteSection(SECTION_FORCES.into()))?;
1418            let (vals, fixed, atom_id) = parse_vector_section_row(force_line, header.spec_version)?;
1419            if validate {
1420                validate_section_atom_identity("forces", atom_idx, fixed, atom_id, atom_data)?;
1421            }
1422            if atom_idx < atom_data.len() {
1423                atom_data[atom_idx].force = Some(vals);
1424            }
1425            atom_idx += 1;
1426        }
1427    }
1428
1429    Ok(true)
1430}
1431
1432/// Attempts to parse an energies section following coordinate (and optional
1433/// velocity / force) blocks.
1434///
1435/// Energy sections mirror force sections but with one scalar per atom:
1436/// blank separator, then per-component blocks of (symbol, "Energies of
1437/// Component N", and atom lines `e fixed_flag atom_id`). The two
1438/// trailing identity columns are optional and used only for strict
1439/// validation; in non-strict mode any whitespace after the energy is
1440/// ignored.
1441///
1442/// Returns `Ok(true)` if energies were found and parsed, `Ok(false)`
1443/// otherwise.
1444pub fn parse_energy_section<'a>(
1445    lines: &mut impl LineStream<'a>,
1446    header: &FrameHeader,
1447    atom_data: &mut [AtomDatum],
1448) -> Result<bool, ParseError> {
1449    let validate = header.strict_validation;
1450    if !open_section(lines)? {
1451        return Ok(false);
1452    }
1453
1454    let mut atom_idx: usize = 0;
1455    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1456        let symbol = lines
1457            .next_line()
1458            .ok_or(ParseError::IncompleteSection(SECTION_ENERGIES.into()))?
1459            .trim();
1460
1461        let comp_line = lines
1462            .next_line()
1463            .ok_or(ParseError::IncompleteSection(SECTION_ENERGIES.into()))?;
1464        if !label_has_prefix(comp_line, "Energies of Component") {
1465            return Err(ParseError::IncompleteSection(SECTION_ENERGIES.into()));
1466        }
1467        if validate {
1468            validate_section_component(
1469                "Energies", type_idx, atom_idx, symbol, comp_line, header, atom_data,
1470            )?;
1471        }
1472
1473        for _ in 0..num_atoms {
1474            let energy_line = lines
1475                .next_line()
1476                .ok_or(ParseError::IncompleteSection(SECTION_ENERGIES.into()))?;
1477            let (value, fixed, atom_id) =
1478                parse_scalar_section_row(energy_line, header.spec_version)?;
1479            if validate {
1480                validate_section_atom_identity("energies", atom_idx, fixed, atom_id, atom_data)?;
1481            }
1482            if atom_idx < atom_data.len() {
1483                atom_data[atom_idx].energy = Some(value);
1484            }
1485            atom_idx += 1;
1486        }
1487    }
1488
1489    Ok(true)
1490}
1491
1492/// Parses declared sections from a frame's header metadata.
1493///
1494/// If `header.sections` is non-empty (v2 file with `"sections"` key in JSON),
1495/// parses each declared section in order. Otherwise falls back to legacy
1496/// blank-separator velocity detection.
1497pub fn parse_declared_sections<'a>(
1498    lines: &mut impl LineStream<'a>,
1499    header: &mut FrameHeader,
1500    atom_data: &mut [AtomDatum],
1501) -> Result<usize, ParseError> {
1502    let mut applied = 0usize;
1503    // A zero-atom frame has no coordinate block and no section block.
1504    // The legacy velocity fallback must not run (CON-count.1).
1505    if header.natm_types == 0 {
1506        if !header.sections.is_empty() {
1507            return Err(ParseError::InvalidCount(
1508                "a zero-atom frame cannot declare sections".into(),
1509            ));
1510        }
1511        return Ok(0);
1512    }
1513    if !header.sections_declared && header.sections.is_empty() {
1514        // Legacy: try velocity detection via blank separator
1515        let found = parse_velocity_section(lines, header, atom_data)?;
1516        if found {
1517            header.sections.push(SECTION_VELOCITIES.into());
1518            applied = 1;
1519        }
1520    } else {
1521        let sections = std::mem::take(&mut header.sections);
1522        for section in &sections {
1523            match section.as_str() {
1524                SECTION_VELOCITIES => {
1525                    let found = parse_velocity_section(lines, header, atom_data)?;
1526                    if !found {
1527                        return Err(ParseError::IncompleteSection(SECTION_VELOCITIES.into()));
1528                    }
1529                    applied += 1;
1530                }
1531                SECTION_FORCES => {
1532                    let found = parse_force_section(lines, header, atom_data)?;
1533                    if !found {
1534                        return Err(ParseError::IncompleteSection(SECTION_FORCES.into()));
1535                    }
1536                    applied += 1;
1537                }
1538                SECTION_ENERGIES => {
1539                    let found = parse_energy_section(lines, header, atom_data)?;
1540                    if !found {
1541                        return Err(ParseError::IncompleteSection(SECTION_ENERGIES.into()));
1542                    }
1543                    applied += 1;
1544                }
1545                SECTION_CHARGES => {
1546                    let found = parse_charge_section(lines, header, atom_data)?;
1547                    if !found {
1548                        return Err(ParseError::IncompleteSection(SECTION_CHARGES.into()));
1549                    }
1550                    applied += 1;
1551                }
1552                SECTION_SPINS => {
1553                    let found = parse_spin_section(lines, header, atom_data)?;
1554                    if !found {
1555                        return Err(ParseError::IncompleteSection(SECTION_SPINS.into()));
1556                    }
1557                    applied += 1;
1558                }
1559                SECTION_MAGMOMS => {
1560                    let found = parse_magmom_section(lines, header, atom_data)?;
1561                    if !found {
1562                        return Err(ParseError::IncompleteSection(SECTION_MAGMOMS.into()));
1563                    }
1564                    applied += 1;
1565                }
1566                SECTION_DISPLACEMENTS => {
1567                    let found = parse_displacement_section(lines, header, atom_data)?;
1568                    if !found {
1569                        return Err(ParseError::IncompleteSection(SECTION_DISPLACEMENTS.into()));
1570                    }
1571                    applied += 1;
1572                }
1573                SECTION_SPREADS => {
1574                    let found = parse_spread_section(lines, header, atom_data)?;
1575                    if !found {
1576                        return Err(ParseError::IncompleteSection(SECTION_SPREADS.into()));
1577                    }
1578                    applied += 1;
1579                }
1580                other => {
1581                    if frame_minor(header) > u64::from(crate::CON_SPEC_MINOR) {
1582                        if let Some(arity) = section_row_arity(header, other) {
1583                            skip_unknown_section(lines, header, other, arity)?;
1584                            continue;
1585                        }
1586                    }
1587                    return Err(ParseError::UnknownSection(other.to_string()));
1588                }
1589            }
1590        }
1591        header.sections = sections;
1592    }
1593    reject_velocities_without_time(header)?;
1594    Ok(applied)
1595}
1596
1597/// CON-units.11. Versions 1, 2, and 3.0 do not require `units.time`
1598/// (CON-units.8, CON-units.9). From minor 1 the key is required when a
1599/// velocities section is present.
1600fn reject_velocities_without_time(header: &FrameHeader) -> Result<(), ParseError> {
1601    if header.spec_version < 3 || frame_minor(header) < 1 {
1602        return Ok(());
1603    }
1604    if !header
1605        .sections
1606        .iter()
1607        .any(|name| name == SECTION_VELOCITIES)
1608    {
1609        return Ok(());
1610    }
1611    let time_ok = header
1612        .metadata
1613        .get(meta::UNITS)
1614        .and_then(|units| units.get("time"))
1615        .and_then(|value| value.as_str())
1616        .is_some_and(|unit| !unit.trim().is_empty());
1617    if time_ok {
1618        Ok(())
1619    } else {
1620        Err(ParseError::InvalidUnits(
1621            "minor >= 1 requires units.time when a velocities section is present".into(),
1622        ))
1623    }
1624}
1625
1626/// Scalar per-atom section: blank separator + per-type blocks
1627/// (`symbol`, `"X of Component N"`, lines `value fixed atom_id`).
1628fn parse_scalar_atom_section<'a>(
1629    lines: &mut impl LineStream<'a>,
1630    header: &FrameHeader,
1631    atom_data: &mut [AtomDatum],
1632    section_name: &str,
1633    component_label: &str,
1634    mut set_value: impl FnMut(&mut AtomDatum, f64),
1635) -> Result<bool, ParseError> {
1636    let validate = header.strict_validation;
1637    if !open_section(lines)? {
1638        return Ok(false);
1639    }
1640
1641    let mut atom_idx: usize = 0;
1642    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1643        let symbol = lines
1644            .next_line()
1645            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?
1646            .trim();
1647
1648        let comp_line = lines
1649            .next_line()
1650            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
1651        if !label_has_prefix(comp_line, component_label) {
1652            return Err(ParseError::IncompleteSection(section_name.into()));
1653        }
1654        if validate {
1655            validate_section_component(
1656                component_label.trim_end_matches(" of Component").trim(),
1657                type_idx,
1658                atom_idx,
1659                symbol,
1660                comp_line,
1661                header,
1662                atom_data,
1663            )?;
1664        }
1665
1666        for _ in 0..num_atoms {
1667            let data_line = lines
1668                .next_line()
1669                .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
1670            let (value, fixed, atom_id) = parse_scalar_section_row(data_line, header.spec_version)?;
1671            if validate {
1672                validate_section_atom_identity(section_name, atom_idx, fixed, atom_id, atom_data)?;
1673            }
1674            if atom_idx < atom_data.len() {
1675                set_value(&mut atom_data[atom_idx], value);
1676            }
1677            atom_idx += 1;
1678        }
1679    }
1680    Ok(true)
1681}
1682
1683pub fn parse_charge_section<'a>(
1684    lines: &mut impl LineStream<'a>,
1685    header: &FrameHeader,
1686    atom_data: &mut [AtomDatum],
1687) -> Result<bool, ParseError> {
1688    parse_scalar_atom_section(
1689        lines,
1690        header,
1691        atom_data,
1692        SECTION_CHARGES,
1693        "Charges of Component",
1694        |a, v| a.charge = Some(v),
1695    )
1696}
1697
1698pub fn parse_spin_section<'a>(
1699    lines: &mut impl LineStream<'a>,
1700    header: &FrameHeader,
1701    atom_data: &mut [AtomDatum],
1702) -> Result<bool, ParseError> {
1703    parse_scalar_atom_section(
1704        lines,
1705        header,
1706        atom_data,
1707        SECTION_SPINS,
1708        "Spins of Component",
1709        |a, v| a.spin = Some(v),
1710    )
1711}
1712
1713fn parse_vector_named_section<'a>(
1714    lines: &mut impl LineStream<'a>,
1715    header: &FrameHeader,
1716    atom_data: &mut [AtomDatum],
1717    section_name: &str,
1718    component_label: &str,
1719    label_kind: &str,
1720    mut set_value: impl FnMut(&mut AtomDatum, [f64; 3]),
1721    mut check_row: impl FnMut(usize, [f64; 3]) -> Result<(), ParseError>,
1722) -> Result<bool, ParseError> {
1723    let validate = header.strict_validation;
1724    if !open_section(lines)? {
1725        return Ok(false);
1726    }
1727    let mut atom_idx: usize = 0;
1728    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1729        let symbol = lines
1730            .next_line()
1731            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?
1732            .trim();
1733        let comp_line = lines
1734            .next_line()
1735            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
1736        if !label_has_prefix(comp_line, component_label) {
1737            return Err(ParseError::IncompleteSection(section_name.into()));
1738        }
1739        if validate {
1740            validate_section_component(
1741                label_kind, type_idx, atom_idx, symbol, comp_line, header, atom_data,
1742            )?;
1743        }
1744        for _ in 0..num_atoms {
1745            let row = lines
1746                .next_line()
1747                .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
1748            let (vals, fixed, atom_id) = parse_vector_section_row(row, header.spec_version)?;
1749            check_row(atom_idx, vals)?;
1750            if validate {
1751                validate_section_atom_identity(section_name, atom_idx, fixed, atom_id, atom_data)?;
1752            }
1753            if atom_idx < atom_data.len() {
1754                set_value(&mut atom_data[atom_idx], vals);
1755            }
1756            atom_idx += 1;
1757        }
1758    }
1759    Ok(true)
1760}
1761
1762/// Magmoms: 3-vector per atom, same layout as velocities/forces.
1763pub fn parse_magmom_section<'a>(
1764    lines: &mut impl LineStream<'a>,
1765    header: &FrameHeader,
1766    atom_data: &mut [AtomDatum],
1767) -> Result<bool, ParseError> {
1768    parse_vector_named_section(
1769        lines,
1770        header,
1771        atom_data,
1772        SECTION_MAGMOMS,
1773        "Magmoms of Component",
1774        "Magmoms",
1775        |a, v| a.magmom = Some(v),
1776        |_, _| Ok(()),
1777    )
1778}
1779
1780/// Displacements: 3-vector per atom (Angstrom), same layout as velocities/forces.
1781pub fn parse_displacement_section<'a>(
1782    lines: &mut impl LineStream<'a>,
1783    header: &FrameHeader,
1784    atom_data: &mut [AtomDatum],
1785) -> Result<bool, ParseError> {
1786    parse_vector_named_section(
1787        lines,
1788        header,
1789        atom_data,
1790        SECTION_DISPLACEMENTS,
1791        "Displacements of Component",
1792        "Displacements",
1793        |a, v| a.displacement = Some(v),
1794        |_, _| Ok(()),
1795    )
1796}
1797
1798/// Spreads: per-atom root-mean-square spread `[sx, sy, sz]` (Angstrom, each
1799/// non-negative), same layout as velocities/forces.
1800pub fn parse_spread_section<'a>(
1801    lines: &mut impl LineStream<'a>,
1802    header: &FrameHeader,
1803    atom_data: &mut [AtomDatum],
1804) -> Result<bool, ParseError> {
1805    parse_vector_named_section(
1806        lines,
1807        header,
1808        atom_data,
1809        SECTION_SPREADS,
1810        "Spreads of Component",
1811        "Spreads",
1812        |a, v| a.spread = Some(v),
1813        |atom_idx, vals| {
1814            if !vals.iter().all(|v| v.is_finite()) {
1815                return Err(ParseError::NonFiniteValue(format!(
1816                    "spreads atom {atom_idx}"
1817                )));
1818            }
1819            if vals.iter().any(|v| *v < 0.0) {
1820                return Err(crate::types::invalid_spread(atom_idx));
1821            }
1822            Ok(())
1823        },
1824    )
1825}
1826
1827#[cfg(test)]
1828mod tests {
1829    use super::*;
1830    use crate::iterators::ConFrameIterator;
1831
1832    #[test]
1833    fn test_parse_line_of_n_success() {
1834        let line = "1.0 2.5 -3.0";
1835        let values = parse_line_of_n::<f64>(line, 3).unwrap();
1836        assert_eq!(values, vec![1.0, 2.5, -3.0]);
1837    }
1838
1839    #[test]
1840    fn test_parse_line_of_n_too_short() {
1841        let line = "1.0 2.5";
1842        let result = parse_line_of_n::<f64>(line, 3);
1843        assert!(result.is_err());
1844        assert!(matches!(
1845            result.unwrap_err(),
1846            ParseError::InvalidVectorLength {
1847                expected: 3,
1848                found: 2
1849            }
1850        ));
1851    }
1852
1853    #[test]
1854    fn test_parse_line_of_n_too_long() {
1855        let line = "1.0 2.5 -3.0 4.0";
1856        let result = parse_line_of_n::<f64>(line, 3);
1857        assert!(result.is_err());
1858        assert!(matches!(
1859            result.unwrap_err(),
1860            ParseError::InvalidVectorLength {
1861                expected: 3,
1862                found: 4
1863            }
1864        ));
1865    }
1866
1867    #[test]
1868    fn test_parse_line_of_n_invalid_float() {
1869        let line = "1.0 abc -3.0";
1870        let result = parse_line_of_n::<f64>(line, 3);
1871        assert!(result.is_err());
1872        assert!(matches!(
1873            result.unwrap_err(),
1874            ParseError::InvalidNumberFormat(_)
1875        ));
1876    }
1877
1878    #[test]
1879    fn test_parse_frame_header_success() {
1880        let lines = [
1881            "PREBOX1",
1882            "{\"con_spec_version\":2}",
1883            "10.0 20.0 30.0",
1884            "90.0 90.0 90.0",
1885            "POSTBOX1",
1886            "POSTBOX2",
1887            "2",
1888            "1 1",
1889            "12.011 1.008",
1890        ];
1891        let mut line_it = lines.iter().copied();
1892        match parse_frame_header(&mut line_it) {
1893            Ok(header) => {
1894                assert_eq!(header.prebox_header.user, "PREBOX1");
1895                assert_eq!(header.spec_version, 2);
1896                assert_eq!(header.boxl, [10.0, 20.0, 30.0]);
1897                assert_eq!(header.angles, [90.0, 90.0, 90.0]);
1898                assert_eq!(header.postbox_header, ["POSTBOX1", "POSTBOX2"]);
1899                assert_eq!(header.natm_types, 2);
1900                assert_eq!(header.natms_per_type, vec![1, 1]);
1901                assert_eq!(header.masses_per_type, vec![12.011, 1.008]);
1902            }
1903            Err(e) => {
1904                panic!(
1905                    "Parsing failed when it should have succeeded. Error: {:?}",
1906                    e
1907                );
1908            }
1909        }
1910    }
1911
1912    #[test]
1913    fn test_parse_frame_header_missing_line() {
1914        let lines = [
1915            "PREBOX1",
1916            "{\"con_spec_version\":2}",
1917            "10.0 20.0 30.0",
1918            "90.0 90.0 90.0",
1919            "POSTBOX1",
1920            "POSTBOX2",
1921            "2",
1922            "1 1",
1923            // Missing masses_per_type
1924        ];
1925        let mut line_it = lines.iter().copied();
1926        let result = parse_frame_header(&mut line_it);
1927        assert!(result.is_err());
1928        assert!(matches!(result.unwrap_err(), ParseError::IncompleteHeader));
1929    }
1930
1931    #[test]
1932    fn test_parse_frame_header_missing_spec_version() {
1933        let lines = vec![
1934            "PREBOX1",
1935            "{}",
1936            "10.0 20.0 30.0",
1937            "90.0 90.0 90.0",
1938            "POSTBOX1",
1939            "POSTBOX2",
1940            "2",
1941            "1 1",
1942            "12.011 1.008",
1943        ];
1944        let mut line_it = lines.iter().copied();
1945        let result = parse_frame_header(&mut line_it);
1946        assert!(result.is_err());
1947        assert!(matches!(
1948            result.unwrap_err(),
1949            ParseError::MissingSpecVersion
1950        ));
1951    }
1952
1953    #[test]
1954    fn test_parse_frame_header_legacy_no_json() {
1955        // A non-JSON line 2 is treated as a legacy (v1) file.
1956        let lines = vec![
1957            "PREBOX1",
1958            "0.0000 TIME",
1959            "10.0 20.0 30.0",
1960            "90.0 90.0 90.0",
1961            "POSTBOX1",
1962            "POSTBOX2",
1963            "2",
1964            "1 1",
1965            "12.011 1.008",
1966        ];
1967        let mut line_it = lines.iter().copied();
1968        let header = parse_frame_header(&mut line_it).unwrap();
1969        assert_eq!(header.spec_version, 1);
1970        assert!(header.metadata.is_empty());
1971    }
1972
1973    #[test]
1974    fn test_parse_frame_header_malformed_json() {
1975        // Line 2 starts with '{' but is not valid JSON -- this IS an error.
1976        let lines = vec![
1977            "PREBOX1",
1978            "{broken json",
1979            "10.0 20.0 30.0",
1980            "90.0 90.0 90.0",
1981            "POSTBOX1",
1982            "POSTBOX2",
1983            "2",
1984            "1 1",
1985            "12.011 1.008",
1986        ];
1987        let mut line_it = lines.iter().copied();
1988        let result = parse_frame_header(&mut line_it);
1989        assert!(result.is_err());
1990        assert!(matches!(
1991            result.unwrap_err(),
1992            ParseError::InvalidMetadataJson(_)
1993        ));
1994    }
1995
1996    #[test]
1997    fn test_parse_frame_header_unsupported_version() {
1998        let lines = vec![
1999            "PREBOX1",
2000            "{\"con_spec_version\":999}",
2001            "10.0 20.0 30.0",
2002            "90.0 90.0 90.0",
2003            "POSTBOX1",
2004            "POSTBOX2",
2005            "2",
2006            "1 1",
2007            "12.011 1.008",
2008        ];
2009        let mut line_it = lines.iter().copied();
2010        let result = parse_frame_header(&mut line_it);
2011        assert!(result.is_err());
2012        assert!(matches!(
2013            result.unwrap_err(),
2014            ParseError::UnsupportedVersion(999)
2015        ));
2016    }
2017
2018    #[test]
2019    fn test_v3_missing_units_rejected() {
2020        let lines = vec![
2021            "PREBOX1",
2022            "{\"con_spec_version\":3}",
2023            "10.0 20.0 30.0",
2024            "90.0 90.0 90.0",
2025            "POSTBOX1",
2026            "POSTBOX2",
2027            "1",
2028            "1",
2029            "1.0",
2030        ];
2031        let mut line_it = lines.iter().copied();
2032        let err = parse_frame_header(&mut line_it).unwrap_err();
2033        let msg = err.to_string();
2034        assert!(
2035            msg.contains("units") || msg.contains("v3"),
2036            "expected units error, got {msg}"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_v3_invalid_units_rejected() {
2042        let lines = vec![
2043            "PREBOX1",
2044            r#"{"con_spec_version":3,"units":{"length":"eV","energy":"angstrom"}}"#,
2045            "10.0 20.0 30.0",
2046            "90.0 90.0 90.0",
2047            "POSTBOX1",
2048            "POSTBOX2",
2049            "1",
2050            "1",
2051            "1.0",
2052        ];
2053        let mut line_it = lines.iter().copied();
2054        assert!(parse_frame_header(&mut line_it).is_err());
2055    }
2056
2057    #[test]
2058    fn test_v3_valid_units_exposes_length_energy() {
2059        let lines = vec![
2060            "PREBOX1",
2061            r#"{"con_spec_version":3,"units":{"length":"angstrom","energy":"eV","mass":"amu","time":"fs"}}"#,
2062            "10.0 20.0 30.0",
2063            "90.0 90.0 90.0",
2064            "POSTBOX1",
2065            "POSTBOX2",
2066            "1",
2067            "1",
2068            "1.0",
2069        ];
2070        let mut line_it = lines.iter().copied();
2071        let header = parse_frame_header(&mut line_it).unwrap();
2072        assert_eq!(header.spec_version, 3);
2073        assert_eq!(header.length_unit(), Some("angstrom"));
2074        assert_eq!(header.energy_unit(), Some("eV"));
2075        let f = header.conversion_factor_to("length", "nm").unwrap();
2076        assert!((f - 0.1).abs() < 1e-12);
2077    }
2078
2079    #[test]
2080    fn test_parse_frame_header_extra_metadata_preserved() {
2081        let lines = vec![
2082            "PREBOX1",
2083            "{\"con_spec_version\":2,\"generator\":\"test\"}",
2084            "10.0 20.0 30.0",
2085            "90.0 90.0 90.0",
2086            "POSTBOX1",
2087            "POSTBOX2",
2088            "2",
2089            "1 1",
2090            "12.011 1.008",
2091        ];
2092        let mut line_it = lines.iter().copied();
2093        let header = parse_frame_header(&mut line_it).unwrap();
2094        assert_eq!(header.spec_version, 2);
2095        assert_eq!(
2096            header.metadata.get("generator"),
2097            Some(&serde_json::Value::String("test".to_string()))
2098        );
2099    }
2100
2101    #[test]
2102    fn test_parse_frame_header_invalid_natms_per_type() {
2103        let lines = vec![
2104            "PREBOX1",
2105            "{\"con_spec_version\":2}",
2106            "10.0 20.0 30.0",
2107            "90.0 90.0 90.0",
2108            "POSTBOX1",
2109            "POSTBOX2",
2110            "2",
2111            "1 1 1", // 3 values, but natm_types is 2
2112            "12.011 1.008",
2113        ];
2114        let mut line_it = lines.iter().copied();
2115        let result = parse_frame_header(&mut line_it);
2116        assert!(result.is_err());
2117        assert!(matches!(
2118            result.unwrap_err(),
2119            ParseError::InvalidVectorLength {
2120                expected: 2,
2121                found: 3
2122            }
2123        ));
2124    }
2125
2126    #[test]
2127    fn test_parse_single_frame_success() {
2128        let lines = vec![
2129            "PREBOX1",
2130            "{\"con_spec_version\":2}",
2131            "10.0 20.0 30.0",
2132            "90.0 90.0 90.0",
2133            "POSTBOX1",
2134            "POSTBOX2",
2135            "2",
2136            "3 3",
2137            "12.011 1.008",
2138            "1",
2139            "Coordinates of Component 1",
2140            "0.0 0.0 0.0 0 1",
2141            "1.0940 0.0 0.0 0 2",
2142            "-0.5470 0.9499 0.0 0 3",
2143            "2",
2144            "Coordinates of Component 2",
2145            "5.0 5.0 5.0 0 4",
2146            "6.0940 5.0 5.0 0 5",
2147            "5.5470 5.9499 5.0 0 6",
2148        ];
2149        let mut line_it = lines.iter().copied();
2150        let frame = parse_single_frame(&mut line_it).unwrap();
2151
2152        assert_eq!(frame.header.natm_types, 2);
2153        assert_eq!(frame.header.natms_per_type, vec![3, 3]);
2154        assert_eq!(frame.header.masses_per_type, vec![12.011, 1.008]);
2155        assert_eq!(frame.atom_data.len(), 6);
2156        assert_eq!(&*frame.atom_data[0].symbol, "1");
2157        assert_eq!(frame.atom_data[0].atom_id, 1);
2158        assert_eq!(&*frame.atom_data[5].symbol, "2");
2159        assert_eq!(frame.atom_data[5].atom_id, 6);
2160    }
2161
2162    #[test]
2163    fn test_column_4_value_1_is_x_only_on_spec_2() {
2164        let lines = vec![
2165            "Generated by probe",
2166            "{\"con_spec_version\":2}",
2167            "20.0 20.0 20.0",
2168            "90.0 90.0 90.0",
2169            "",
2170            "",
2171            "1",
2172            "1",
2173            "63.5",
2174            "Cu",
2175            "Coordinates of component   1",
2176            "1.0 2.0 3.0 1 1",
2177        ];
2178        let mut line_it = lines.iter().copied();
2179        let frame = parse_single_frame(&mut line_it).unwrap();
2180        assert_eq!(frame.header.spec_version, 2);
2181        assert_eq!(frame.atom_data[0].fixed, [true, false, false]);
2182    }
2183
2184    #[test]
2185    fn test_column_4_value_1_is_all_fixed_on_spec_1() {
2186        let lines = vec![
2187            "Generated by probe",
2188            "0.0000 TIME",
2189            "20.0 20.0 20.0",
2190            "90.0 90.0 90.0",
2191            "",
2192            "",
2193            "1",
2194            "1",
2195            "63.5",
2196            "Cu",
2197            "Coordinates of component   1",
2198            "1.0 2.0 3.0 1 1",
2199        ];
2200        let mut line_it = lines.iter().copied();
2201        let frame = parse_single_frame(&mut line_it).unwrap();
2202        assert_eq!(frame.header.spec_version, 1);
2203        assert_eq!(frame.atom_data[0].fixed, [true, true, true]);
2204    }
2205
2206    #[test]
2207    fn test_parse_single_frame_missing_line() {
2208        // With a valid header but truncated atom data, we get IncompleteFrame.
2209        let lines = vec![
2210            "PREBOX1",
2211            "{\"con_spec_version\":2}",
2212            "10.0 20.0 30.0",
2213            "90.0 90.0 90.0",
2214            "POSTBOX1",
2215            "POSTBOX2",
2216            "2",
2217            "3 3",
2218            "12.011 1.008",
2219            "1",
2220            "Coordinates of Component 1",
2221            "0.0 0.0 0.0 0 1",
2222            "1.0940 0.0 0.0 0 2",
2223            "-0.5470 0.9499 0.0 0 3",
2224            // Missing Component 2 entirely
2225        ];
2226        let mut line_it = lines.iter().copied();
2227        let result = parse_single_frame(&mut line_it);
2228        assert!(result.is_err());
2229        assert!(matches!(result.unwrap_err(), ParseError::IncompleteFrame));
2230    }
2231
2232    #[test]
2233    fn test_parse_single_frame_missing_atom_index_defaults_sequential() {
2234        // Column 5 (atom_index) is optional; when absent, defaults to sequential.
2235        let lines = vec![
2236            "PREBOX1",
2237            "{\"con_spec_version\":2}",
2238            "10.0 20.0 30.0",
2239            "90.0 90.0 90.0",
2240            "POSTBOX1",
2241            "POSTBOX2",
2242            "2",
2243            "3 3",
2244            "12.011 1.008",
2245            "1",
2246            "Coordinates of Component 1",
2247            "0.0 0.0 0.0 0 1",
2248            "1.0940 0.0 0.0 0 2",
2249            "-0.5470 0.9499 0.0 0 3",
2250            "2",
2251            "Coordinates of Component 2",
2252            "5.0 5.0 5.0 0",       // No atom_index: defaults to 3
2253            "6.0940 5.0 5.0 0 10", // Explicit atom_index: 10
2254            "5.5470 5.9499 5.0 0", // No atom_index: defaults to 5
2255        ];
2256        let mut line_it = lines.iter().copied();
2257        let frame = parse_single_frame(&mut line_it).unwrap();
2258        assert_eq!(frame.atom_data.len(), 6);
2259        // First type: explicit atom_index values
2260        assert_eq!(frame.atom_data[0].atom_id, 1);
2261        assert_eq!(frame.atom_data[1].atom_id, 2);
2262        assert_eq!(frame.atom_data[2].atom_id, 3);
2263        // Second type: mixed explicit and defaulted
2264        assert_eq!(frame.atom_data[3].atom_id, 3); // defaulted (global idx 3)
2265        assert_eq!(frame.atom_data[4].atom_id, 10); // explicit
2266        assert_eq!(frame.atom_data[5].atom_id, 5); // defaulted (global idx 5)
2267    }
2268
2269    #[test]
2270    fn test_parse_single_frame_too_few_columns_fails() {
2271        // Only 3 columns (missing fixed_flag too) should still fail.
2272        let lines = vec![
2273            "PREBOX1",
2274            "{\"con_spec_version\":2}",
2275            "10.0 20.0 30.0",
2276            "90.0 90.0 90.0",
2277            "POSTBOX1",
2278            "POSTBOX2",
2279            "1",
2280            "1",
2281            "12.011",
2282            "C",
2283            "Coordinates of Component 1",
2284            "0.0 0.0 0.0", // Only 3 values
2285        ];
2286        let mut line_it = lines.iter().copied();
2287        let result = parse_single_frame(&mut line_it);
2288        assert!(result.is_err());
2289        assert!(matches!(
2290            result.unwrap_err(),
2291            ParseError::InvalidVectorLength {
2292                expected: 5,
2293                found: 3
2294            }
2295        ));
2296    }
2297
2298    #[test]
2299    fn test_parse_velocity_section_present() {
2300        let lines = vec![
2301            "PREBOX1",
2302            "{\"con_spec_version\":2}",
2303            "10.0 20.0 30.0",
2304            "90.0 90.0 90.0",
2305            "POSTBOX1",
2306            "POSTBOX2",
2307            "2",
2308            "1 1",
2309            "63.546 1.008",
2310            "Cu",
2311            "Coordinates of Component 1",
2312            "0.0 0.0 0.0 1 0",
2313            "H",
2314            "Coordinates of Component 2",
2315            "1.0 2.0 3.0 0 1",
2316            "",
2317            "Cu",
2318            "Velocities of Component 1",
2319            "0.1 0.2 0.3 1 0",
2320            "H",
2321            "Velocities of Component 2",
2322            "0.4 0.5 0.6 0 1",
2323        ];
2324        let mut line_it = lines.iter().copied().peekable();
2325        // Parse the frame first (consuming 15 lines)
2326        let mut frame =
2327            parse_single_frame(&mut line_it).expect("coordinate parsing should succeed");
2328        assert!(!frame.has_velocities());
2329
2330        // Now parse the velocity section
2331        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
2332            .expect("velocity parsing should succeed");
2333        assert!(has_vel);
2334        assert_eq!(frame.atom_data[0].velocity, Some([0.1, 0.2, 0.3]));
2335        assert_eq!(frame.atom_data[1].velocity, Some([0.4, 0.5, 0.6]));
2336    }
2337
2338    #[test]
2339    fn test_validate_true_accepts_matching_section_identity() {
2340        let text = r#"
2341PREBOX1
2342{"con_spec_version":2,"sections":["velocities"],"validate":true}
234310.0 20.0 30.0
234490.0 90.0 90.0
2345POSTBOX1
2346POSTBOX2
23472
23481 1
234963.546 1.008
2350Cu
2351Coordinates of Component 1
23520.0 0.0 0.0 5 0
2353H
2354Coordinates of Component 2
23551.0 2.0 3.0 0 1
2356
2357Cu
2358Velocities of Component 1
23590.1 0.2 0.3 5 0
2360H
2361Velocities of Component 2
23620.4 0.5 0.6 0 1
2363"#;
2364        let mut iter = ConFrameIterator::new(text.trim());
2365        let frame = iter.next().unwrap().unwrap();
2366
2367        assert!(frame.has_velocities());
2368        assert_eq!(
2369            frame
2370                .header
2371                .metadata
2372                .get("validate")
2373                .and_then(|v| v.as_bool()),
2374            Some(true)
2375        );
2376    }
2377
2378    #[test]
2379    fn test_validate_true_rejects_section_atom_id_mismatch() {
2380        let text = r#"
2381PREBOX1
2382{"con_spec_version":2,"sections":["velocities"],"validate":true}
238310.0 20.0 30.0
238490.0 90.0 90.0
2385POSTBOX1
2386POSTBOX2
23871
23881
238963.546
2390Cu
2391Coordinates of Component 1
23920.0 0.0 0.0 5 0
2393
2394Cu
2395Velocities of Component 1
23960.1 0.2 0.3 5 99
2397"#;
2398        let mut iter = ConFrameIterator::new(text.trim());
2399        let err = iter.next().unwrap().unwrap_err();
2400
2401        assert!(matches!(err, ParseError::ValidationError(_)));
2402        assert!(err.to_string().contains("atom_id mismatch"));
2403    }
2404
2405    #[test]
2406    fn test_validate_true_rejects_section_symbol_mismatch() {
2407        let text = r#"
2408PREBOX1
2409{"con_spec_version":2,"sections":["forces"],"validate":true}
241010.0 20.0 30.0
241190.0 90.0 90.0
2412POSTBOX1
2413POSTBOX2
24141
24151
241663.546
2417Cu
2418Coordinates of Component 1
24190.0 0.0 0.0 5 0
2420
2421H
2422Forces of Component 1
24230.1 0.2 0.3 5 0
2424"#;
2425        let mut iter = ConFrameIterator::new(text.trim());
2426        let err = iter.next().unwrap().unwrap_err();
2427
2428        assert!(matches!(err, ParseError::ValidationError(_)));
2429        assert!(err.to_string().contains("symbol mismatch"));
2430    }
2431
2432    #[test]
2433    fn test_validate_absent_allows_legacy_duplicate_identity_mismatch() {
2434        let text = r#"
2435PREBOX1
2436{"con_spec_version":2,"sections":["velocities"]}
243710.0 20.0 30.0
243890.0 90.0 90.0
2439POSTBOX1
2440POSTBOX2
24411
24421
244363.546
2444Cu
2445Coordinates of Component 1
24460.0 0.0 0.0 5 0
2447
2448Cu
2449Velocities of Component 1
24500.1 0.2 0.3 0 99
2451"#;
2452        let mut iter = ConFrameIterator::new(text.trim());
2453        let frame = iter.next().unwrap().unwrap();
2454
2455        assert!(frame.has_velocities());
2456        assert_eq!(frame.atom_data[0].atom_id, 0);
2457        assert_eq!(frame.atom_data[0].fixed, [true, false, true]);
2458    }
2459
2460    #[test]
2461    fn test_validate_must_be_boolean_when_present() {
2462        let lines = vec![
2463            "PREBOX1",
2464            "{\"con_spec_version\":2,\"validate\":\"yes\",\"sections\":[]}",
2465            "10.0 20.0 30.0",
2466            "90.0 90.0 90.0",
2467            "POSTBOX1",
2468            "POSTBOX2",
2469            "1",
2470            "1",
2471            "12.011",
2472        ];
2473        let mut line_it = lines.iter().copied();
2474        let err = parse_frame_header(&mut line_it).unwrap_err();
2475
2476        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2477        assert!(err.to_string().contains("validate"));
2478    }
2479
2480    #[test]
2481    fn test_validate_true_requires_sections_key() {
2482        let lines = vec![
2483            "PREBOX1",
2484            "{\"con_spec_version\":2,\"validate\":true}",
2485            "10.0 20.0 30.0",
2486            "90.0 90.0 90.0",
2487            "POSTBOX1",
2488            "POSTBOX2",
2489            "1",
2490            "1",
2491            "12.011",
2492        ];
2493        let mut line_it = lines.iter().copied();
2494        let err = parse_frame_header(&mut line_it).unwrap_err();
2495
2496        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2497        assert!(err.to_string().contains("sections"));
2498    }
2499
2500    #[test]
2501    fn test_sections_must_be_string_array_when_present() {
2502        let lines = vec![
2503            "PREBOX1",
2504            "{\"con_spec_version\":2,\"sections\":[\"velocities\",7]}",
2505            "10.0 20.0 30.0",
2506            "90.0 90.0 90.0",
2507            "POSTBOX1",
2508            "POSTBOX2",
2509            "1",
2510            "1",
2511            "12.011",
2512        ];
2513        let mut line_it = lines.iter().copied();
2514        let err = parse_frame_header(&mut line_it).unwrap_err();
2515
2516        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2517        assert!(err.to_string().contains("sections"));
2518    }
2519
2520    #[test]
2521    fn test_validate_true_rejects_non_integer_coordinate_identity_columns() {
2522        let text = r#"
2523PREBOX1
2524{"con_spec_version":2,"sections":[],"validate":true}
252510.0 20.0 30.0
252690.0 90.0 90.0
2527POSTBOX1
2528POSTBOX2
25291
25301
253163.546
2532Cu
2533Coordinates of Component 1
25340.0 0.0 0.0 5.0 0
2535"#;
2536        let mut iter = ConFrameIterator::new(text.trim());
2537        let err = iter.next().unwrap().unwrap_err();
2538
2539        assert!(matches!(err, ParseError::InvalidFixedFlag(_)));
2540        assert!(err.to_string().contains("5.0"));
2541    }
2542
2543    #[test]
2544    fn test_validate_true_rejects_non_exact_coordinate_label() {
2545        let text = r#"
2546PREBOX1
2547{"con_spec_version":2,"sections":[],"validate":true}
254810.0 20.0 30.0
254990.0 90.0 90.0
2550POSTBOX1
2551POSTBOX2
25521
25531
255463.546
2555Cu
2556Coordinates Component 1
25570.0 0.0 0.0 5 0
2558"#;
2559        let mut iter = ConFrameIterator::new(text.trim());
2560        let err = iter.next().unwrap().unwrap_err();
2561
2562        assert!(matches!(err, ParseError::ValidationError(_)));
2563        assert!(err.to_string().contains("Coordinates of Component 1"));
2564    }
2565
2566    #[test]
2567    fn test_validate_true_rejects_unknown_component_symbol() {
2568        let text = r#"
2569PREBOX1
2570{"con_spec_version":2,"sections":[],"validate":true}
257110.0 20.0 30.0
257290.0 90.0 90.0
2573POSTBOX1
2574POSTBOX2
25751
25761
257763.546
2578Qq
2579Coordinates of Component 1
25800.0 0.0 0.0 0 0
2581"#;
2582        let mut iter = ConFrameIterator::new(text.trim());
2583        let err = iter.next().unwrap().unwrap_err();
2584
2585        assert!(matches!(err, ParseError::ValidationError(_)));
2586        assert!(err.to_string().contains("symbol"));
2587    }
2588
2589    #[test]
2590    fn test_declared_section_must_be_present() {
2591        let text = r#"
2592PREBOX1
2593{"con_spec_version":2,"sections":["velocities"]}
259410.0 20.0 30.0
259590.0 90.0 90.0
2596POSTBOX1
2597POSTBOX2
25981
25991
260063.546
2601Cu
2602Coordinates of Component 1
26030.0 0.0 0.0 0 0
2604"#;
2605        let mut iter = ConFrameIterator::new(text.trim());
2606        let err = iter.next().unwrap().unwrap_err();
2607
2608        assert!(matches!(
2609            err,
2610            ParseError::IncompleteSection(ref name) if name == "velocities"
2611        ));
2612    }
2613
2614    #[test]
2615    fn test_non_finite_cell_geometry_rejected_in_strict_mode() {
2616        let lines = vec![
2617            "PREBOX1",
2618            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
2619            "10.0 NaN 30.0",
2620            "90.0 90.0 90.0",
2621            "POSTBOX1",
2622            "POSTBOX2",
2623            "1",
2624            "1",
2625            "12.011",
2626        ];
2627        let mut line_it = lines.iter().copied();
2628        let err = parse_frame_header(&mut line_it).unwrap_err();
2629
2630        assert!(matches!(err, ParseError::NonFiniteValue(_)));
2631        assert!(err.to_string().contains("NaN"));
2632    }
2633
2634    #[test]
2635    fn test_validate_true_rejects_non_physical_cell_geometry() {
2636        let lines = vec![
2637            "PREBOX1",
2638            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
2639            "0.0 20.0 30.0",
2640            "90.0 180.0 90.0",
2641            "POSTBOX1",
2642            "POSTBOX2",
2643            "1",
2644            "1",
2645            "12.011",
2646        ];
2647        let mut line_it = lines.iter().copied();
2648        let err = parse_frame_header(&mut line_it).unwrap_err();
2649
2650        assert!(matches!(err, ParseError::ValidationError(_)));
2651        assert!(err.to_string().contains("cell"));
2652    }
2653
2654    #[test]
2655    fn test_validate_true_rejects_reserved_metadata_type_mismatch() {
2656        let lines = vec![
2657            "PREBOX1",
2658            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"energy\":\"low\"}",
2659            "10.0 20.0 30.0",
2660            "90.0 90.0 90.0",
2661            "POSTBOX1",
2662            "POSTBOX2",
2663            "1",
2664            "1",
2665            "12.011",
2666        ];
2667        let mut line_it = lines.iter().copied();
2668        let err = parse_frame_header(&mut line_it).unwrap_err();
2669
2670        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2671        assert!(err.to_string().contains("energy"));
2672    }
2673
2674    #[test]
2675    fn test_validate_true_rejects_malformed_bonds() {
2676        let lines = vec![
2677            "PREBOX1",
2678            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"bonds\":[[0]]}",
2679            "10.0 20.0 30.0",
2680            "90.0 90.0 90.0",
2681            "POSTBOX1",
2682            "POSTBOX2",
2683            "1",
2684            "1",
2685            "12.011",
2686        ];
2687        let mut line_it = lines.iter().copied();
2688        let err = parse_frame_header(&mut line_it).unwrap_err();
2689        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2690        assert!(err.to_string().contains("bonds"));
2691    }
2692
2693    #[test]
2694    fn test_bonds_metadata_round_trip_in_header() {
2695        use crate::types::{Bond, meta};
2696        let lines = vec![
2697            "PREBOX1",
2698            "{\"con_spec_version\":2,\"bonds\":[[0,1],{\"i\":0,\"j\":2,\"order\":1}]}",
2699            "10.0 20.0 30.0",
2700            "90.0 90.0 90.0",
2701            "POSTBOX1",
2702            "POSTBOX2",
2703            "1",
2704            "1",
2705            "12.011",
2706        ];
2707        let mut line_it = lines.iter().copied();
2708        let header = parse_frame_header(&mut line_it).expect("header");
2709        let bonds = header.bonds();
2710        assert_eq!(bonds.len(), 2);
2711        assert_eq!(bonds[0], Bond::new(0, 1));
2712        assert_eq!(bonds[1].i, 0);
2713        assert_eq!(bonds[1].j, 2);
2714        assert_eq!(bonds[1].order, Some(1));
2715        assert!(header.metadata.contains_key(meta::BONDS));
2716    }
2717
2718    #[test]
2719    fn test_parse_velocity_section_absent() {
2720        let lines = vec![
2721            "PREBOX1",
2722            "{\"con_spec_version\":2}",
2723            "10.0 20.0 30.0",
2724            "90.0 90.0 90.0",
2725            "POSTBOX1",
2726            "POSTBOX2",
2727            "1",
2728            "1",
2729            "12.011",
2730            "C",
2731            "Coordinates of Component 1",
2732            "0.0 0.0 0.0 0 1",
2733        ];
2734        let mut line_it = lines.iter().copied().peekable();
2735        let mut frame = parse_single_frame(&mut line_it).expect("parse should succeed");
2736        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
2737            .expect("should succeed with no velocities");
2738        assert!(!has_vel);
2739        assert_eq!(frame.atom_data[0].velocity, None);
2740    }
2741
2742    #[test]
2743    fn test_parse_line_of_range_f64_exact() {
2744        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0 42", 4, 5, &[0.0; 5]).unwrap();
2745        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 42.0]);
2746    }
2747
2748    #[test]
2749    fn test_byte_scan_stack_parses_realistic_coord_line() {
2750        // Typical CON atom line: x y z fixed_mask [atom_id]
2751        let line = "   0.63939999999999997    0.90449999999999997   -0.00009999999999977 1    0";
2752        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
2753        let mut buf = [0.0f64; 5];
2754        let n = parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
2755        assert_eq!(n, 5);
2756        assert!((buf[0] - 0.6394).abs() < 1e-4);
2757        assert!((buf[1] - 0.9045).abs() < 1e-4);
2758        assert_eq!(buf[3] as u8, 1);
2759        assert_eq!(buf[4] as u64, 0);
2760        // trailing junk must error
2761        let bad = "1.0 2.0 3.0 1 0 EXTRA";
2762        assert!(parse_line_of_range_f64_stack(bad, 4, 5, &defaults, &mut buf).is_err());
2763        // partial non-boundary token must error
2764        let glued = "1.0 2.0 3.0abc 1 0";
2765        assert!(parse_line_of_range_f64_stack(glued, 4, 5, &defaults, &mut buf).is_err());
2766    }
2767
2768    #[test]
2769    fn test_parse_line_of_range_f64_stack_matches_vec_api() {
2770        let defaults = [0.0, 0.0, 0.0, 0.0, 7.0];
2771        let line = "1.0 2.0 3.0 0.0";
2772        let mut buf = [0.0f64; 5];
2773        parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
2774        let via_vec = parse_line_of_range_f64(line, 4, 5, &defaults).unwrap();
2775        assert_eq!(&buf[..5], via_vec.as_slice());
2776        assert_eq!(buf[4], 7.0);
2777    }
2778
2779    #[test]
2780    fn test_parse_line_of_range_f64_padded() {
2781        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
2782        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0", 4, 5, &defaults).unwrap();
2783        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 99.0]);
2784    }
2785
2786    #[test]
2787    fn test_parse_line_of_range_f64_too_few() {
2788        let result = parse_line_of_range_f64("1.0 2.0 3.0", 4, 5, &[0.0; 5]);
2789        assert!(result.is_err());
2790    }
2791
2792    #[test]
2793    fn test_parse_line_of_range_f64_too_many() {
2794        let result = parse_line_of_range_f64("1.0 2.0 3.0 0.0 5.0 6.0", 4, 5, &[0.0; 5]);
2795        assert!(result.is_err());
2796    }
2797
2798    #[test]
2799    fn test_parse_all_four_column_lines() {
2800        // All atom lines have only 4 columns; atom_index defaults to sequential.
2801        let lines = vec![
2802            "PREBOX1",
2803            "{\"con_spec_version\":2}",
2804            "10.0 10.0 10.0",
2805            "90.0 90.0 90.0",
2806            "POSTBOX1",
2807            "POSTBOX2",
2808            "1",
2809            "3",
2810            "12.011",
2811            "C",
2812            "Coordinates of Component 1",
2813            "0.0 0.0 0.0 0",
2814            "1.0 0.0 0.0 0",
2815            "2.0 0.0 0.0 1",
2816        ];
2817        let mut line_it = lines.iter().copied();
2818        let frame = parse_single_frame(&mut line_it).unwrap();
2819        assert_eq!(frame.atom_data[0].atom_id, 0);
2820        assert_eq!(frame.atom_data[1].atom_id, 1);
2821        assert_eq!(frame.atom_data[2].atom_id, 2);
2822        assert!(frame.atom_data[2].is_fixed());
2823    }
2824}