Skip to main content

readcon_core/
parser.rs

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