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