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