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