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                        && let Some(arity) = section_row_arity(header, other) {
2199                            skip_unknown_section(lines, header, other, arity)?;
2200                            continue;
2201                        }
2202                    return Err(ParseError::UnknownSection(other.to_string()));
2203                }
2204            }
2205        }
2206        header.sections = sections;
2207    }
2208    reject_velocities_without_time(header)?;
2209    Ok(applied)
2210}
2211
2212/// CON-units.11. Versions 1, 2, and 3.0 do not require `units.time`
2213/// (CON-units.8, CON-units.9). From minor 1 the key is required when a
2214/// velocities section is present.
2215fn reject_velocities_without_time(header: &FrameHeader) -> Result<(), ParseError> {
2216    if header.spec_version < 3 || frame_minor(header) < 1 {
2217        return Ok(());
2218    }
2219    if !header
2220        .sections
2221        .iter()
2222        .any(|name| name == SECTION_VELOCITIES)
2223    {
2224        return Ok(());
2225    }
2226    let time_ok = header
2227        .metadata
2228        .get(meta::UNITS)
2229        .and_then(|units| units.get("time"))
2230        .and_then(|value| value.as_str())
2231        .is_some_and(|unit| !unit.trim().is_empty());
2232    if time_ok {
2233        Ok(())
2234    } else {
2235        Err(ParseError::InvalidUnits(
2236            "minor >= 1 requires units.time when a velocities section is present".into(),
2237        ))
2238    }
2239}
2240
2241/// Scalar per-atom section: blank separator + per-type blocks
2242/// (`symbol`, `"X of Component N"`, lines `value fixed atom_id`).
2243fn parse_scalar_atom_section<'a>(
2244    lines: &mut impl LineStream<'a>,
2245    header: &FrameHeader,
2246    atom_data: &mut [AtomDatum],
2247    section_name: &str,
2248    component_label: &str,
2249    mut set_value: impl FnMut(&mut AtomDatum, f64),
2250) -> Result<bool, ParseError> {
2251    let validate = header.strict_validation;
2252    if !open_section(lines)? {
2253        return Ok(false);
2254    }
2255
2256    let mut atom_idx: usize = 0;
2257    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
2258        let symbol = lines
2259            .next_line()
2260            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?
2261            .trim();
2262
2263        let comp_line = lines
2264            .next_line()
2265            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
2266        if !label_has_prefix(comp_line, component_label) {
2267            return Err(ParseError::IncompleteSection(section_name.into()));
2268        }
2269        if validate {
2270            validate_section_component(
2271                component_label.trim_end_matches(" of Component").trim(),
2272                type_idx,
2273                atom_idx,
2274                symbol,
2275                comp_line,
2276                header,
2277                atom_data,
2278            )?;
2279        }
2280
2281        for _ in 0..num_atoms {
2282            let data_line = lines
2283                .next_line()
2284                .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
2285            let (value, fixed, atom_id) = parse_scalar_section_row(data_line, header.spec_version)?;
2286            if validate {
2287                validate_section_atom_identity(section_name, atom_idx, fixed, atom_id, atom_data)?;
2288            }
2289            if atom_idx < atom_data.len() {
2290                set_value(&mut atom_data[atom_idx], value);
2291            }
2292            atom_idx += 1;
2293        }
2294    }
2295    Ok(true)
2296}
2297
2298pub fn parse_charge_section<'a>(
2299    lines: &mut impl LineStream<'a>,
2300    header: &FrameHeader,
2301    atom_data: &mut [AtomDatum],
2302) -> Result<bool, ParseError> {
2303    parse_scalar_atom_section(
2304        lines,
2305        header,
2306        atom_data,
2307        SECTION_CHARGES,
2308        "Charges of Component",
2309        |a, v| a.set_charge(Some(v)),
2310    )
2311}
2312
2313pub fn parse_spin_section<'a>(
2314    lines: &mut impl LineStream<'a>,
2315    header: &FrameHeader,
2316    atom_data: &mut [AtomDatum],
2317) -> Result<bool, ParseError> {
2318    parse_scalar_atom_section(
2319        lines,
2320        header,
2321        atom_data,
2322        SECTION_SPINS,
2323        "Spins of Component",
2324        |a, v| a.set_spin(Some(v)),
2325    )
2326}
2327
2328fn parse_vector_named_section<'a>(
2329    lines: &mut impl LineStream<'a>,
2330    header: &FrameHeader,
2331    atom_data: &mut [AtomDatum],
2332    names: (&str, &str, &str),
2333    mut set_value: impl FnMut(&mut AtomDatum, [f64; 3]),
2334    mut check_row: impl FnMut(usize, [f64; 3]) -> Result<(), ParseError>,
2335) -> Result<bool, ParseError> {
2336    let (section_name, component_label, label_kind) = names;
2337    let validate = header.strict_validation;
2338    if !open_section(lines)? {
2339        return Ok(false);
2340    }
2341    let mut atom_idx: usize = 0;
2342    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
2343        let symbol = lines
2344            .next_line()
2345            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?
2346            .trim();
2347        let comp_line = lines
2348            .next_line()
2349            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
2350        if !label_has_prefix(comp_line, component_label) {
2351            return Err(ParseError::IncompleteSection(section_name.into()));
2352        }
2353        if validate {
2354            validate_section_component(
2355                label_kind, type_idx, atom_idx, symbol, comp_line, header, atom_data,
2356            )?;
2357        }
2358        for _ in 0..num_atoms {
2359            let (vals, fixed, atom_id) = take_vector_row(lines, header.spec_version, section_name)?;
2360            check_row(atom_idx, vals)?;
2361            if validate {
2362                validate_section_atom_identity(section_name, atom_idx, fixed, atom_id, atom_data)?;
2363            }
2364            if atom_idx < atom_data.len() {
2365                set_value(&mut atom_data[atom_idx], vals);
2366            }
2367            atom_idx += 1;
2368        }
2369    }
2370    Ok(true)
2371}
2372
2373/// Magmoms: 3-vector per atom, same layout as velocities/forces.
2374pub fn parse_magmom_section<'a>(
2375    lines: &mut impl LineStream<'a>,
2376    header: &FrameHeader,
2377    atom_data: &mut [AtomDatum],
2378) -> Result<bool, ParseError> {
2379    parse_vector_named_section(
2380        lines,
2381        header,
2382        atom_data,
2383        (SECTION_MAGMOMS, "Magmoms of Component", "Magmoms"),
2384        |a, v| a.set_magmom(Some(v)),
2385        |_, _| Ok(()),
2386    )
2387}
2388
2389/// Displacements: 3-vector per atom (Angstrom), same layout as velocities/forces.
2390pub fn parse_displacement_section<'a>(
2391    lines: &mut impl LineStream<'a>,
2392    header: &FrameHeader,
2393    atom_data: &mut [AtomDatum],
2394) -> Result<bool, ParseError> {
2395    parse_vector_named_section(
2396        lines,
2397        header,
2398        atom_data,
2399        (
2400            SECTION_DISPLACEMENTS,
2401            "Displacements of Component",
2402            "Displacements",
2403        ),
2404        |a, v| a.set_displacement(Some(v)),
2405        |_, _| Ok(()),
2406    )
2407}
2408
2409/// Spreads: per-atom root-mean-square spread `[sx, sy, sz]` (Angstrom, each
2410/// non-negative), same layout as velocities/forces.
2411pub fn parse_spread_section<'a>(
2412    lines: &mut impl LineStream<'a>,
2413    header: &FrameHeader,
2414    atom_data: &mut [AtomDatum],
2415) -> Result<bool, ParseError> {
2416    parse_vector_named_section(
2417        lines,
2418        header,
2419        atom_data,
2420        (SECTION_SPREADS, "Spreads of Component", "Spreads"),
2421        |a, v| a.set_spread(Some(v)),
2422        |atom_idx, vals| {
2423            if !vals.iter().all(|v| v.is_finite()) {
2424                return Err(ParseError::NonFiniteValue(format!(
2425                    "spreads atom {atom_idx}"
2426                )));
2427            }
2428            if vals.iter().any(|v| *v < 0.0) {
2429                return Err(crate::types::invalid_spread(atom_idx));
2430            }
2431            Ok(())
2432        },
2433    )
2434}
2435
2436#[cfg(test)]
2437mod tests {
2438    use super::*;
2439    use crate::iterators::ConFrameIterator;
2440
2441    #[test]
2442    fn test_parse_line_of_n_success() {
2443        let line = "1.0 2.5 -3.0";
2444        let values = parse_line_of_n::<f64>(line, 3).unwrap();
2445        assert_eq!(values, vec![1.0, 2.5, -3.0]);
2446    }
2447
2448    #[test]
2449    fn test_parse_line_of_n_too_short() {
2450        let line = "1.0 2.5";
2451        let result = parse_line_of_n::<f64>(line, 3);
2452        assert!(result.is_err());
2453        assert!(matches!(
2454            result.unwrap_err(),
2455            ParseError::InvalidVectorLength {
2456                expected: 3,
2457                found: 2
2458            }
2459        ));
2460    }
2461
2462    #[test]
2463    fn test_parse_line_of_n_too_long() {
2464        let line = "1.0 2.5 -3.0 4.0";
2465        let result = parse_line_of_n::<f64>(line, 3);
2466        assert!(result.is_err());
2467        assert!(matches!(
2468            result.unwrap_err(),
2469            ParseError::InvalidVectorLength {
2470                expected: 3,
2471                found: 4
2472            }
2473        ));
2474    }
2475
2476    #[test]
2477    fn test_parse_line_of_n_invalid_float() {
2478        let line = "1.0 abc -3.0";
2479        let result = parse_line_of_n::<f64>(line, 3);
2480        assert!(result.is_err());
2481        assert!(matches!(
2482            result.unwrap_err(),
2483            ParseError::InvalidNumberFormat(_)
2484        ));
2485    }
2486
2487    #[test]
2488    fn test_parse_frame_header_success() {
2489        let lines = [
2490            "PREBOX1",
2491            "{\"con_spec_version\":2}",
2492            "10.0 20.0 30.0",
2493            "90.0 90.0 90.0",
2494            "POSTBOX1",
2495            "POSTBOX2",
2496            "2",
2497            "1 1",
2498            "12.011 1.008",
2499        ];
2500        let mut line_it = lines.iter().copied();
2501        match parse_frame_header(&mut line_it) {
2502            Ok(header) => {
2503                assert_eq!(header.prebox_header.user, "PREBOX1");
2504                assert_eq!(header.spec_version, 2);
2505                assert_eq!(header.boxl, [10.0, 20.0, 30.0]);
2506                assert_eq!(header.angles, [90.0, 90.0, 90.0]);
2507                assert_eq!(header.postbox_header, ["POSTBOX1", "POSTBOX2"]);
2508                assert_eq!(header.natm_types, 2);
2509                assert_eq!(header.natms_per_type, vec![1, 1]);
2510                assert_eq!(header.masses_per_type, vec![12.011, 1.008]);
2511            }
2512            Err(e) => {
2513                panic!("Parsing failed when it should have succeeded. Error: {e:?}");
2514            }
2515        }
2516    }
2517
2518    #[test]
2519    fn test_parse_frame_header_missing_line() {
2520        let lines = [
2521            "PREBOX1",
2522            "{\"con_spec_version\":2}",
2523            "10.0 20.0 30.0",
2524            "90.0 90.0 90.0",
2525            "POSTBOX1",
2526            "POSTBOX2",
2527            "2",
2528            "1 1",
2529            // Missing masses_per_type
2530        ];
2531        let mut line_it = lines.iter().copied();
2532        let result = parse_frame_header(&mut line_it);
2533        assert!(result.is_err());
2534        assert!(matches!(result.unwrap_err(), ParseError::IncompleteHeader));
2535    }
2536
2537    #[test]
2538    fn test_parse_frame_header_missing_spec_version() {
2539        let lines = [
2540            "PREBOX1",
2541            "{}",
2542            "10.0 20.0 30.0",
2543            "90.0 90.0 90.0",
2544            "POSTBOX1",
2545            "POSTBOX2",
2546            "2",
2547            "1 1",
2548            "12.011 1.008",
2549        ];
2550        let mut line_it = lines.iter().copied();
2551        let result = parse_frame_header(&mut line_it);
2552        assert!(result.is_err());
2553        assert!(matches!(
2554            result.unwrap_err(),
2555            ParseError::MissingSpecVersion
2556        ));
2557    }
2558
2559    #[test]
2560    fn test_parse_frame_header_legacy_no_json() {
2561        // A non-JSON line 2 is treated as a legacy (v1) file.
2562        let lines = [
2563            "PREBOX1",
2564            "0.0000 TIME",
2565            "10.0 20.0 30.0",
2566            "90.0 90.0 90.0",
2567            "POSTBOX1",
2568            "POSTBOX2",
2569            "2",
2570            "1 1",
2571            "12.011 1.008",
2572        ];
2573        let mut line_it = lines.iter().copied();
2574        let header = parse_frame_header(&mut line_it).unwrap();
2575        assert_eq!(header.spec_version, 1);
2576        assert!(header.metadata.is_empty());
2577    }
2578
2579    #[test]
2580    fn test_parse_frame_header_malformed_json() {
2581        // Line 2 starts with '{' but is not valid JSON -- this IS an error.
2582        let lines = [
2583            "PREBOX1",
2584            "{broken json",
2585            "10.0 20.0 30.0",
2586            "90.0 90.0 90.0",
2587            "POSTBOX1",
2588            "POSTBOX2",
2589            "2",
2590            "1 1",
2591            "12.011 1.008",
2592        ];
2593        let mut line_it = lines.iter().copied();
2594        let result = parse_frame_header(&mut line_it);
2595        assert!(result.is_err());
2596        assert!(matches!(
2597            result.unwrap_err(),
2598            ParseError::InvalidMetadataJson(_)
2599        ));
2600    }
2601
2602    #[test]
2603    fn test_parse_frame_header_unsupported_version() {
2604        let lines = [
2605            "PREBOX1",
2606            "{\"con_spec_version\":999}",
2607            "10.0 20.0 30.0",
2608            "90.0 90.0 90.0",
2609            "POSTBOX1",
2610            "POSTBOX2",
2611            "2",
2612            "1 1",
2613            "12.011 1.008",
2614        ];
2615        let mut line_it = lines.iter().copied();
2616        let result = parse_frame_header(&mut line_it);
2617        assert!(result.is_err());
2618        assert!(matches!(
2619            result.unwrap_err(),
2620            ParseError::UnsupportedVersion(999)
2621        ));
2622    }
2623
2624    #[test]
2625    fn test_v3_missing_units_rejected() {
2626        let lines = [
2627            "PREBOX1",
2628            "{\"con_spec_version\":3}",
2629            "10.0 20.0 30.0",
2630            "90.0 90.0 90.0",
2631            "POSTBOX1",
2632            "POSTBOX2",
2633            "1",
2634            "1",
2635            "1.0",
2636        ];
2637        let mut line_it = lines.iter().copied();
2638        let err = parse_frame_header(&mut line_it).unwrap_err();
2639        let msg = err.to_string();
2640        assert!(
2641            msg.contains("units") || msg.contains("v3"),
2642            "expected units error, got {msg}"
2643        );
2644    }
2645
2646    #[test]
2647    fn test_v3_invalid_units_rejected() {
2648        let lines = [
2649            "PREBOX1",
2650            r#"{"con_spec_version":3,"units":{"length":"eV","energy":"angstrom"}}"#,
2651            "10.0 20.0 30.0",
2652            "90.0 90.0 90.0",
2653            "POSTBOX1",
2654            "POSTBOX2",
2655            "1",
2656            "1",
2657            "1.0",
2658        ];
2659        let mut line_it = lines.iter().copied();
2660        assert!(parse_frame_header(&mut line_it).is_err());
2661    }
2662
2663    #[test]
2664    fn test_v3_valid_units_exposes_length_energy() {
2665        let lines = [
2666            "PREBOX1",
2667            r#"{"con_spec_version":3,"units":{"length":"angstrom","energy":"eV","mass":"amu","time":"fs"}}"#,
2668            "10.0 20.0 30.0",
2669            "90.0 90.0 90.0",
2670            "POSTBOX1",
2671            "POSTBOX2",
2672            "1",
2673            "1",
2674            "1.0",
2675        ];
2676        let mut line_it = lines.iter().copied();
2677        let header = parse_frame_header(&mut line_it).unwrap();
2678        assert_eq!(header.spec_version, 3);
2679        assert_eq!(header.length_unit(), Some("angstrom"));
2680        assert_eq!(header.energy_unit(), Some("eV"));
2681        let f = header.conversion_factor_to("length", "nm").unwrap();
2682        assert!((f - 0.1).abs() < 1e-12);
2683    }
2684
2685    #[test]
2686    fn test_parse_frame_header_extra_metadata_preserved() {
2687        let lines = [
2688            "PREBOX1",
2689            "{\"con_spec_version\":2,\"generator\":\"test\"}",
2690            "10.0 20.0 30.0",
2691            "90.0 90.0 90.0",
2692            "POSTBOX1",
2693            "POSTBOX2",
2694            "2",
2695            "1 1",
2696            "12.011 1.008",
2697        ];
2698        let mut line_it = lines.iter().copied();
2699        let header = parse_frame_header(&mut line_it).unwrap();
2700        assert_eq!(header.spec_version, 2);
2701        assert_eq!(
2702            header.metadata.get("generator"),
2703            Some(&serde_json::Value::String("test".to_string()))
2704        );
2705    }
2706
2707    #[test]
2708    fn test_parse_frame_header_invalid_natms_per_type() {
2709        let lines = [
2710            "PREBOX1",
2711            "{\"con_spec_version\":2}",
2712            "10.0 20.0 30.0",
2713            "90.0 90.0 90.0",
2714            "POSTBOX1",
2715            "POSTBOX2",
2716            "2",
2717            "1 1 1", // 3 values, but natm_types is 2
2718            "12.011 1.008",
2719        ];
2720        let mut line_it = lines.iter().copied();
2721        let result = parse_frame_header(&mut line_it);
2722        assert!(result.is_err());
2723        assert!(matches!(
2724            result.unwrap_err(),
2725            ParseError::InvalidVectorLength {
2726                expected: 2,
2727                found: 3
2728            }
2729        ));
2730    }
2731
2732    #[test]
2733    fn test_parse_single_frame_success() {
2734        let lines = vec![
2735            "PREBOX1",
2736            "{\"con_spec_version\":2}",
2737            "10.0 20.0 30.0",
2738            "90.0 90.0 90.0",
2739            "POSTBOX1",
2740            "POSTBOX2",
2741            "2",
2742            "3 3",
2743            "12.011 1.008",
2744            "1",
2745            "Coordinates of Component 1",
2746            "0.0 0.0 0.0 0 1",
2747            "1.0940 0.0 0.0 0 2",
2748            "-0.5470 0.9499 0.0 0 3",
2749            "2",
2750            "Coordinates of Component 2",
2751            "5.0 5.0 5.0 0 4",
2752            "6.0940 5.0 5.0 0 5",
2753            "5.5470 5.9499 5.0 0 6",
2754        ];
2755        let mut line_it = lines.iter().copied();
2756        let frame = parse_single_frame(&mut line_it).unwrap();
2757
2758        assert_eq!(frame.header.natm_types, 2);
2759        assert_eq!(frame.header.natms_per_type, vec![3, 3]);
2760        assert_eq!(frame.header.masses_per_type, vec![12.011, 1.008]);
2761        assert_eq!(frame.atom_data.len(), 6);
2762        assert_eq!(&*frame.atom_data[0].symbol, "1");
2763        assert_eq!(frame.atom_data[0].atom_id, 1);
2764        assert_eq!(&*frame.atom_data[5].symbol, "2");
2765        assert_eq!(frame.atom_data[5].atom_id, 6);
2766    }
2767
2768    #[test]
2769    fn test_column_4_value_1_is_x_only_on_spec_2() {
2770        let lines = [
2771            "Generated by probe",
2772            "{\"con_spec_version\":2}",
2773            "20.0 20.0 20.0",
2774            "90.0 90.0 90.0",
2775            "",
2776            "",
2777            "1",
2778            "1",
2779            "63.5",
2780            "Cu",
2781            "Coordinates of component   1",
2782            "1.0 2.0 3.0 1 1",
2783        ];
2784        let mut line_it = lines.iter().copied();
2785        let frame = parse_single_frame(&mut line_it).unwrap();
2786        assert_eq!(frame.header.spec_version, 2);
2787        assert_eq!(frame.atom_data[0].fixed, [true, false, false]);
2788    }
2789
2790    #[test]
2791    fn test_column_4_value_1_is_all_fixed_on_spec_1() {
2792        let lines = [
2793            "Generated by probe",
2794            "0.0000 TIME",
2795            "20.0 20.0 20.0",
2796            "90.0 90.0 90.0",
2797            "",
2798            "",
2799            "1",
2800            "1",
2801            "63.5",
2802            "Cu",
2803            "Coordinates of component   1",
2804            "1.0 2.0 3.0 1 1",
2805        ];
2806        let mut line_it = lines.iter().copied();
2807        let frame = parse_single_frame(&mut line_it).unwrap();
2808        assert_eq!(frame.header.spec_version, 1);
2809        assert_eq!(frame.atom_data[0].fixed, [true, true, true]);
2810    }
2811
2812    #[test]
2813    fn test_parse_single_frame_missing_line() {
2814        // With a valid header but truncated atom data, we get IncompleteFrame.
2815        let lines = vec![
2816            "PREBOX1",
2817            "{\"con_spec_version\":2}",
2818            "10.0 20.0 30.0",
2819            "90.0 90.0 90.0",
2820            "POSTBOX1",
2821            "POSTBOX2",
2822            "2",
2823            "3 3",
2824            "12.011 1.008",
2825            "1",
2826            "Coordinates of Component 1",
2827            "0.0 0.0 0.0 0 1",
2828            "1.0940 0.0 0.0 0 2",
2829            "-0.5470 0.9499 0.0 0 3",
2830            // Missing Component 2 entirely
2831        ];
2832        let mut line_it = lines.iter().copied();
2833        let result = parse_single_frame(&mut line_it);
2834        assert!(result.is_err());
2835        assert!(matches!(result.unwrap_err(), ParseError::IncompleteFrame));
2836    }
2837
2838    #[test]
2839    fn test_parse_single_frame_missing_atom_index_defaults_sequential() {
2840        // Column 5 (atom_index) is optional; when absent, defaults to sequential.
2841        let lines = vec![
2842            "PREBOX1",
2843            "{\"con_spec_version\":2}",
2844            "10.0 20.0 30.0",
2845            "90.0 90.0 90.0",
2846            "POSTBOX1",
2847            "POSTBOX2",
2848            "2",
2849            "3 3",
2850            "12.011 1.008",
2851            "1",
2852            "Coordinates of Component 1",
2853            "0.0 0.0 0.0 0 1",
2854            "1.0940 0.0 0.0 0 2",
2855            "-0.5470 0.9499 0.0 0 3",
2856            "2",
2857            "Coordinates of Component 2",
2858            "5.0 5.0 5.0 0",       // No atom_index: defaults to 3
2859            "6.0940 5.0 5.0 0 10", // Explicit atom_index: 10
2860            "5.5470 5.9499 5.0 0", // No atom_index: defaults to 5
2861        ];
2862        let mut line_it = lines.iter().copied();
2863        let frame = parse_single_frame(&mut line_it).unwrap();
2864        assert_eq!(frame.atom_data.len(), 6);
2865        // First type: explicit atom_index values
2866        assert_eq!(frame.atom_data[0].atom_id, 1);
2867        assert_eq!(frame.atom_data[1].atom_id, 2);
2868        assert_eq!(frame.atom_data[2].atom_id, 3);
2869        // Second type: mixed explicit and defaulted
2870        assert_eq!(frame.atom_data[3].atom_id, 3); // defaulted (global idx 3)
2871        assert_eq!(frame.atom_data[4].atom_id, 10); // explicit
2872        assert_eq!(frame.atom_data[5].atom_id, 5); // defaulted (global idx 5)
2873    }
2874
2875    #[test]
2876    fn test_parse_single_frame_too_few_columns_fails() {
2877        // Only 3 columns (missing fixed_flag too) should still fail.
2878        let lines = [
2879            "PREBOX1",
2880            "{\"con_spec_version\":2}",
2881            "10.0 20.0 30.0",
2882            "90.0 90.0 90.0",
2883            "POSTBOX1",
2884            "POSTBOX2",
2885            "1",
2886            "1",
2887            "12.011",
2888            "C",
2889            "Coordinates of Component 1",
2890            "0.0 0.0 0.0", // Only 3 values
2891        ];
2892        let mut line_it = lines.iter().copied();
2893        let result = parse_single_frame(&mut line_it);
2894        assert!(result.is_err());
2895        assert!(matches!(
2896            result.unwrap_err(),
2897            ParseError::InvalidVectorLength {
2898                expected: 5,
2899                found: 3
2900            }
2901        ));
2902    }
2903
2904    #[test]
2905    fn test_parse_velocity_section_present() {
2906        let lines = vec![
2907            "PREBOX1",
2908            "{\"con_spec_version\":2}",
2909            "10.0 20.0 30.0",
2910            "90.0 90.0 90.0",
2911            "POSTBOX1",
2912            "POSTBOX2",
2913            "2",
2914            "1 1",
2915            "63.546 1.008",
2916            "Cu",
2917            "Coordinates of Component 1",
2918            "0.0 0.0 0.0 1 0",
2919            "H",
2920            "Coordinates of Component 2",
2921            "1.0 2.0 3.0 0 1",
2922            "",
2923            "Cu",
2924            "Velocities of Component 1",
2925            "0.1 0.2 0.3 1 0",
2926            "H",
2927            "Velocities of Component 2",
2928            "0.4 0.5 0.6 0 1",
2929        ];
2930        let mut line_it = lines.iter().copied().peekable();
2931        // Parse the frame first (consuming 15 lines)
2932        let mut frame =
2933            parse_single_frame(&mut line_it).expect("coordinate parsing should succeed");
2934        assert!(!frame.has_velocities());
2935
2936        // Now parse the velocity section
2937        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
2938            .expect("velocity parsing should succeed");
2939        assert!(has_vel);
2940        assert_eq!(frame.atom_data[0].velocity(), Some([0.1, 0.2, 0.3]));
2941        assert_eq!(frame.atom_data[1].velocity(), Some([0.4, 0.5, 0.6]));
2942    }
2943
2944    #[test]
2945    fn test_validate_true_accepts_matching_section_identity() {
2946        let text = r#"
2947PREBOX1
2948{"con_spec_version":2,"sections":["velocities"],"validate":true}
294910.0 20.0 30.0
295090.0 90.0 90.0
2951POSTBOX1
2952POSTBOX2
29532
29541 1
295563.546 1.008
2956Cu
2957Coordinates of Component 1
29580.0 0.0 0.0 5 0
2959H
2960Coordinates of Component 2
29611.0 2.0 3.0 0 1
2962
2963Cu
2964Velocities of Component 1
29650.1 0.2 0.3 5 0
2966H
2967Velocities of Component 2
29680.4 0.5 0.6 0 1
2969"#;
2970        let mut iter = ConFrameIterator::new(text.trim());
2971        let frame = iter.next().unwrap().unwrap();
2972
2973        assert!(frame.has_velocities());
2974        assert_eq!(
2975            frame
2976                .header
2977                .metadata
2978                .get("validate")
2979                .and_then(|v| v.as_bool()),
2980            Some(true)
2981        );
2982    }
2983
2984    #[test]
2985    fn test_validate_true_rejects_section_atom_id_mismatch() {
2986        let text = r#"
2987PREBOX1
2988{"con_spec_version":2,"sections":["velocities"],"validate":true}
298910.0 20.0 30.0
299090.0 90.0 90.0
2991POSTBOX1
2992POSTBOX2
29931
29941
299563.546
2996Cu
2997Coordinates of Component 1
29980.0 0.0 0.0 5 0
2999
3000Cu
3001Velocities of Component 1
30020.1 0.2 0.3 5 99
3003"#;
3004        let mut iter = ConFrameIterator::new(text.trim());
3005        let err = iter.next().unwrap().unwrap_err();
3006
3007        assert!(matches!(err, ParseError::ValidationError(_)));
3008        assert!(err.to_string().contains("atom_id mismatch"));
3009    }
3010
3011    #[test]
3012    fn test_validate_true_rejects_section_symbol_mismatch() {
3013        let text = r#"
3014PREBOX1
3015{"con_spec_version":2,"sections":["forces"],"validate":true}
301610.0 20.0 30.0
301790.0 90.0 90.0
3018POSTBOX1
3019POSTBOX2
30201
30211
302263.546
3023Cu
3024Coordinates of Component 1
30250.0 0.0 0.0 5 0
3026
3027H
3028Forces of Component 1
30290.1 0.2 0.3 5 0
3030"#;
3031        let mut iter = ConFrameIterator::new(text.trim());
3032        let err = iter.next().unwrap().unwrap_err();
3033
3034        assert!(matches!(err, ParseError::ValidationError(_)));
3035        assert!(err.to_string().contains("symbol mismatch"));
3036    }
3037
3038    #[test]
3039    fn test_validate_absent_allows_legacy_duplicate_identity_mismatch() {
3040        let text = r#"
3041PREBOX1
3042{"con_spec_version":2,"sections":["velocities"]}
304310.0 20.0 30.0
304490.0 90.0 90.0
3045POSTBOX1
3046POSTBOX2
30471
30481
304963.546
3050Cu
3051Coordinates of Component 1
30520.0 0.0 0.0 5 0
3053
3054Cu
3055Velocities of Component 1
30560.1 0.2 0.3 0 99
3057"#;
3058        let mut iter = ConFrameIterator::new(text.trim());
3059        let frame = iter.next().unwrap().unwrap();
3060
3061        assert!(frame.has_velocities());
3062        assert_eq!(frame.atom_data[0].atom_id, 0);
3063        assert_eq!(frame.atom_data[0].fixed, [true, false, true]);
3064    }
3065
3066    #[test]
3067    fn test_validate_must_be_boolean_when_present() {
3068        let lines = [
3069            "PREBOX1",
3070            "{\"con_spec_version\":2,\"validate\":\"yes\",\"sections\":[]}",
3071            "10.0 20.0 30.0",
3072            "90.0 90.0 90.0",
3073            "POSTBOX1",
3074            "POSTBOX2",
3075            "1",
3076            "1",
3077            "12.011",
3078        ];
3079        let mut line_it = lines.iter().copied();
3080        let err = parse_frame_header(&mut line_it).unwrap_err();
3081
3082        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
3083        assert!(err.to_string().contains("validate"));
3084    }
3085
3086    #[test]
3087    fn test_validate_true_requires_sections_key() {
3088        let lines = [
3089            "PREBOX1",
3090            "{\"con_spec_version\":2,\"validate\":true}",
3091            "10.0 20.0 30.0",
3092            "90.0 90.0 90.0",
3093            "POSTBOX1",
3094            "POSTBOX2",
3095            "1",
3096            "1",
3097            "12.011",
3098        ];
3099        let mut line_it = lines.iter().copied();
3100        let err = parse_frame_header(&mut line_it).unwrap_err();
3101
3102        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
3103        assert!(err.to_string().contains("sections"));
3104    }
3105
3106    #[test]
3107    fn test_sections_must_be_string_array_when_present() {
3108        let lines = [
3109            "PREBOX1",
3110            "{\"con_spec_version\":2,\"sections\":[\"velocities\",7]}",
3111            "10.0 20.0 30.0",
3112            "90.0 90.0 90.0",
3113            "POSTBOX1",
3114            "POSTBOX2",
3115            "1",
3116            "1",
3117            "12.011",
3118        ];
3119        let mut line_it = lines.iter().copied();
3120        let err = parse_frame_header(&mut line_it).unwrap_err();
3121
3122        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
3123        assert!(err.to_string().contains("sections"));
3124    }
3125
3126    #[test]
3127    fn test_validate_true_rejects_non_integer_coordinate_identity_columns() {
3128        let text = r#"
3129PREBOX1
3130{"con_spec_version":2,"sections":[],"validate":true}
313110.0 20.0 30.0
313290.0 90.0 90.0
3133POSTBOX1
3134POSTBOX2
31351
31361
313763.546
3138Cu
3139Coordinates of Component 1
31400.0 0.0 0.0 5.0 0
3141"#;
3142        let mut iter = ConFrameIterator::new(text.trim());
3143        let err = iter.next().unwrap().unwrap_err();
3144
3145        assert!(matches!(err, ParseError::InvalidFixedFlag(_)));
3146        assert!(err.to_string().contains("5.0"));
3147    }
3148
3149    #[test]
3150    fn test_validate_true_rejects_non_exact_coordinate_label() {
3151        let text = r#"
3152PREBOX1
3153{"con_spec_version":2,"sections":[],"validate":true}
315410.0 20.0 30.0
315590.0 90.0 90.0
3156POSTBOX1
3157POSTBOX2
31581
31591
316063.546
3161Cu
3162Coordinates Component 1
31630.0 0.0 0.0 5 0
3164"#;
3165        let mut iter = ConFrameIterator::new(text.trim());
3166        let err = iter.next().unwrap().unwrap_err();
3167
3168        assert!(matches!(err, ParseError::ValidationError(_)));
3169        assert!(err.to_string().contains("Coordinates of Component 1"));
3170    }
3171
3172    #[test]
3173    fn test_validate_true_rejects_unknown_component_symbol() {
3174        let text = r#"
3175PREBOX1
3176{"con_spec_version":2,"sections":[],"validate":true}
317710.0 20.0 30.0
317890.0 90.0 90.0
3179POSTBOX1
3180POSTBOX2
31811
31821
318363.546
3184Qq
3185Coordinates of Component 1
31860.0 0.0 0.0 0 0
3187"#;
3188        let mut iter = ConFrameIterator::new(text.trim());
3189        let err = iter.next().unwrap().unwrap_err();
3190
3191        assert!(matches!(err, ParseError::ValidationError(_)));
3192        assert!(err.to_string().contains("symbol"));
3193    }
3194
3195    #[test]
3196    fn test_declared_section_must_be_present() {
3197        let text = r#"
3198PREBOX1
3199{"con_spec_version":2,"sections":["velocities"]}
320010.0 20.0 30.0
320190.0 90.0 90.0
3202POSTBOX1
3203POSTBOX2
32041
32051
320663.546
3207Cu
3208Coordinates of Component 1
32090.0 0.0 0.0 0 0
3210"#;
3211        let mut iter = ConFrameIterator::new(text.trim());
3212        let err = iter.next().unwrap().unwrap_err();
3213
3214        assert!(matches!(
3215            err,
3216            ParseError::IncompleteSection(ref name) if name == "velocities"
3217        ));
3218    }
3219
3220    #[test]
3221    fn test_non_finite_cell_geometry_rejected_in_strict_mode() {
3222        let lines = [
3223            "PREBOX1",
3224            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
3225            "10.0 NaN 30.0",
3226            "90.0 90.0 90.0",
3227            "POSTBOX1",
3228            "POSTBOX2",
3229            "1",
3230            "1",
3231            "12.011",
3232        ];
3233        let mut line_it = lines.iter().copied();
3234        let err = parse_frame_header(&mut line_it).unwrap_err();
3235
3236        assert!(matches!(err, ParseError::NonFiniteValue(_)));
3237        assert!(err.to_string().contains("NaN"));
3238    }
3239
3240    #[test]
3241    fn test_validate_true_rejects_non_physical_cell_geometry() {
3242        let lines = [
3243            "PREBOX1",
3244            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
3245            "0.0 20.0 30.0",
3246            "90.0 180.0 90.0",
3247            "POSTBOX1",
3248            "POSTBOX2",
3249            "1",
3250            "1",
3251            "12.011",
3252        ];
3253        let mut line_it = lines.iter().copied();
3254        let err = parse_frame_header(&mut line_it).unwrap_err();
3255
3256        assert!(matches!(err, ParseError::ValidationError(_)));
3257        assert!(err.to_string().contains("cell"));
3258    }
3259
3260    #[test]
3261    fn test_validate_true_rejects_reserved_metadata_type_mismatch() {
3262        let lines = [
3263            "PREBOX1",
3264            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"energy\":\"low\"}",
3265            "10.0 20.0 30.0",
3266            "90.0 90.0 90.0",
3267            "POSTBOX1",
3268            "POSTBOX2",
3269            "1",
3270            "1",
3271            "12.011",
3272        ];
3273        let mut line_it = lines.iter().copied();
3274        let err = parse_frame_header(&mut line_it).unwrap_err();
3275
3276        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
3277        assert!(err.to_string().contains("energy"));
3278    }
3279
3280    #[test]
3281    fn test_validate_true_rejects_malformed_bonds() {
3282        let lines = [
3283            "PREBOX1",
3284            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"bonds\":[[0]]}",
3285            "10.0 20.0 30.0",
3286            "90.0 90.0 90.0",
3287            "POSTBOX1",
3288            "POSTBOX2",
3289            "1",
3290            "1",
3291            "12.011",
3292        ];
3293        let mut line_it = lines.iter().copied();
3294        let err = parse_frame_header(&mut line_it).unwrap_err();
3295        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
3296        assert!(err.to_string().contains("bonds"));
3297    }
3298
3299    #[test]
3300    fn test_bonds_metadata_round_trip_in_header() {
3301        use crate::types::{Bond, meta};
3302        let lines = [
3303            "PREBOX1",
3304            "{\"con_spec_version\":2,\"bonds\":[[0,1],{\"i\":0,\"j\":2,\"order\":1}]}",
3305            "10.0 20.0 30.0",
3306            "90.0 90.0 90.0",
3307            "POSTBOX1",
3308            "POSTBOX2",
3309            "1",
3310            "1",
3311            "12.011",
3312        ];
3313        let mut line_it = lines.iter().copied();
3314        let header = parse_frame_header(&mut line_it).expect("header");
3315        let bonds = header.bonds();
3316        assert_eq!(bonds.len(), 2);
3317        assert_eq!(bonds[0], Bond::new(0, 1));
3318        assert_eq!(bonds[1].i, 0);
3319        assert_eq!(bonds[1].j, 2);
3320        assert_eq!(bonds[1].order, Some(1));
3321        assert!(header.metadata.contains_key(meta::BONDS));
3322    }
3323
3324    #[test]
3325    fn test_parse_velocity_section_absent() {
3326        let lines = [
3327            "PREBOX1",
3328            "{\"con_spec_version\":2}",
3329            "10.0 20.0 30.0",
3330            "90.0 90.0 90.0",
3331            "POSTBOX1",
3332            "POSTBOX2",
3333            "1",
3334            "1",
3335            "12.011",
3336            "C",
3337            "Coordinates of Component 1",
3338            "0.0 0.0 0.0 0 1",
3339        ];
3340        let mut line_it = lines.iter().copied().peekable();
3341        let mut frame = parse_single_frame(&mut line_it).expect("parse should succeed");
3342        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
3343            .expect("should succeed with no velocities");
3344        assert!(!has_vel);
3345        assert_eq!(frame.atom_data[0].velocity(), None);
3346    }
3347
3348    #[test]
3349    fn test_parse_line_of_range_f64_exact() {
3350        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0 42", 4, 5, &[0.0; 5]).unwrap();
3351        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 42.0]);
3352    }
3353
3354    #[test]
3355    fn test_byte_scan_stack_parses_realistic_coord_line() {
3356        // Typical CON atom line: x y z fixed_mask [atom_id]
3357        let line = "   0.63939999999999997    0.90449999999999997   -0.00009999999999977 1    0";
3358        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
3359        let mut buf = [0.0f64; 5];
3360        let n = parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
3361        assert_eq!(n, 5);
3362        assert!((buf[0] - 0.6394).abs() < 1e-4);
3363        assert!((buf[1] - 0.9045).abs() < 1e-4);
3364        assert_eq!(buf[3] as u8, 1);
3365        assert_eq!(buf[4] as u64, 0);
3366        // trailing junk must error
3367        let bad = "1.0 2.0 3.0 1 0 EXTRA";
3368        assert!(parse_line_of_range_f64_stack(bad, 4, 5, &defaults, &mut buf).is_err());
3369        // partial non-boundary token must error
3370        let glued = "1.0 2.0 3.0abc 1 0";
3371        assert!(parse_line_of_range_f64_stack(glued, 4, 5, &defaults, &mut buf).is_err());
3372    }
3373
3374    #[test]
3375    fn test_parse_line_of_range_f64_stack_matches_vec_api() {
3376        let defaults = [0.0, 0.0, 0.0, 0.0, 7.0];
3377        let line = "1.0 2.0 3.0 0.0";
3378        let mut buf = [0.0f64; 5];
3379        parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
3380        let via_vec = parse_line_of_range_f64(line, 4, 5, &defaults).unwrap();
3381        assert_eq!(&buf[..5], via_vec.as_slice());
3382        assert_eq!(buf[4], 7.0);
3383    }
3384
3385    #[test]
3386    fn test_parse_line_of_range_f64_padded() {
3387        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
3388        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0", 4, 5, &defaults).unwrap();
3389        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 99.0]);
3390    }
3391
3392    #[test]
3393    fn test_parse_line_of_range_f64_too_few() {
3394        let result = parse_line_of_range_f64("1.0 2.0 3.0", 4, 5, &[0.0; 5]);
3395        assert!(result.is_err());
3396    }
3397
3398    #[test]
3399    fn test_parse_line_of_range_f64_too_many() {
3400        let result = parse_line_of_range_f64("1.0 2.0 3.0 0.0 5.0 6.0", 4, 5, &[0.0; 5]);
3401        assert!(result.is_err());
3402    }
3403
3404    #[test]
3405    fn test_parse_all_four_column_lines() {
3406        // All atom lines have only 4 columns; atom_index defaults to sequential.
3407        let lines = [
3408            "PREBOX1",
3409            "{\"con_spec_version\":2}",
3410            "10.0 10.0 10.0",
3411            "90.0 90.0 90.0",
3412            "POSTBOX1",
3413            "POSTBOX2",
3414            "1",
3415            "3",
3416            "12.011",
3417            "C",
3418            "Coordinates of Component 1",
3419            "0.0 0.0 0.0 0",
3420            "1.0 0.0 0.0 0",
3421            "2.0 0.0 0.0 1",
3422        ];
3423        let mut line_it = lines.iter().copied();
3424        let frame = parse_single_frame(&mut line_it).unwrap();
3425        assert_eq!(frame.atom_data[0].atom_id, 0);
3426        assert_eq!(frame.atom_data[1].atom_id, 1);
3427        assert_eq!(frame.atom_data[2].atom_id, 2);
3428        assert!(frame.atom_data[2].is_fixed());
3429    }
3430}