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_VELOCITIES,
6    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            });
702            global_atom_idx += 1;
703            atom_i += 1;
704        }
705    }
706    let positions = if f64_positions {
707        FloatArray2::from_f64_row_major(total_atoms, 3, pos_flat)
708    } else {
709        positions_other.expect("non-f64 positions allocated")
710    };
711    // Sections still attach to AoS; assemble uses prefilled positions (no second pos pass).
712    Ok(crate::types::con_frame_from_atom_data_with_positions(
713        header, atom_data, positions,
714    ))
715}
716
717fn validate_header_geometry(
718    boxl: &[f64],
719    angles: &[f64],
720    natm_types: usize,
721    natms_per_type: &[usize],
722) -> Result<(), ParseError> {
723    if boxl
724        .iter()
725        .any(|length| !length.is_finite() || *length <= 0.0)
726        || angles
727            .iter()
728            .any(|angle| !angle.is_finite() || *angle <= 0.0 || *angle >= 180.0)
729    {
730        return Err(ParseError::ValidationError(
731            "cell geometry must have positive lengths and angles between 0 and 180 degrees"
732                .to_string(),
733        ));
734    }
735    if natm_types == 0 || natms_per_type.contains(&0) {
736        return Err(ParseError::ValidationError(
737            "atom counts must contain at least one atom per component".to_string(),
738        ));
739    }
740    Ok(())
741}
742
743fn validate_masses(masses_per_type: &[f64]) -> Result<(), ParseError> {
744    if masses_per_type
745        .iter()
746        .any(|mass| !mass.is_finite() || *mass <= 0.0)
747    {
748        return Err(ParseError::ValidationError(
749            "component masses must be positive".to_string(),
750        ));
751    }
752    Ok(())
753}
754
755fn validate_coordinate_component(
756    type_idx: usize,
757    symbol: &str,
758    label: &str,
759) -> Result<(), ParseError> {
760    let expected_label = format!("Coordinates of Component {}", type_idx + 1);
761    if label.trim() != expected_label {
762        return Err(ParseError::ValidationError(format!(
763            "expected coordinate label {expected_label:?}, found {label:?}"
764        )));
765    }
766    if symbol != "X" && symbol_to_atomic_number(symbol) == 0 {
767        return Err(ParseError::ValidationError(format!(
768            "unknown component symbol {symbol}"
769        )));
770    }
771    Ok(())
772}
773
774/// Strict-validation parser for the per-row identity columns
775/// (fixed bitmask + atom_id) used by every section type.
776///
777/// `n_cols` is the total whitespace-separated column count expected on
778/// the row in strict mode, and `(fixed_idx, atom_id_idx)` are the
779/// 0-based positions of the fixed bitmask and atom_id columns inside
780/// that layout. Each section calls in with its own values:
781///
782/// - coordinates / velocities / forces: 5 cols, fixed=3, atom_id=4
783/// - energies: 3 cols, fixed=1, atom_id=2
784///
785/// String-based parsing on purpose: strict v2 mode rejects values that
786/// are not in the canonical integer form (e.g. `5.0` for a bitmask),
787/// which an f64 round-trip would silently accept.
788fn parse_identity_columns(
789    line: &str,
790    row_kind: &str,
791    fixed_idx: usize,
792    atom_id_idx: usize,
793    n_cols: usize,
794    spec_version: u32,
795) -> Result<([bool; 3], u64), ParseError> {
796    let columns = line.split_ascii_whitespace().collect::<Vec<_>>();
797    if columns.len() != n_cols {
798        return Err(ParseError::ValidationError(format!(
799            "{row_kind} rows require {n_cols} columns including fixed_flag and atom_id in validate mode"
800        )));
801    }
802    let fixed_flag = columns[fixed_idx].parse::<u8>().map_err(|_| {
803        ParseError::ValidationError(format!("{row_kind} fixed_flag must be an integer bitmask"))
804    })?;
805    if fixed_flag > 7 {
806        return Err(ParseError::ValidationError(format!(
807            "{row_kind} fixed_flag must be between 0 and 7"
808        )));
809    }
810    let atom_id = columns[atom_id_idx].parse::<u64>().map_err(|_| {
811        ParseError::ValidationError(format!("{row_kind} atom_id must be an integer"))
812    })?;
813    Ok((
814        decode_fixed_bitmask_for_spec(fixed_flag, spec_version),
815        atom_id,
816    ))
817}
818
819fn validate_section_component(
820    section: &str,
821    type_idx: usize,
822    atom_idx: usize,
823    symbol: &str,
824    label: &str,
825    header: &FrameHeader,
826    atom_data: &[AtomDatum],
827) -> Result<(), ParseError> {
828    let expected_label = format!("{section} of Component {}", type_idx + 1);
829    if label.trim() != expected_label {
830        return Err(ParseError::ValidationError(format!(
831            "expected section label {expected_label:?}, found {label:?}"
832        )));
833    }
834
835    if header.natms_per_type[type_idx] == 0 {
836        return Ok(());
837    }
838
839    let expected_symbol = atom_data
840        .get(atom_idx)
841        .map(|atom| atom.symbol.as_ref())
842        .ok_or_else(|| {
843            ParseError::ValidationError(format!(
844                "{section} component {} has no coordinate atom to validate against",
845                type_idx + 1
846            ))
847        })?;
848    if symbol != expected_symbol {
849        return Err(ParseError::ValidationError(format!(
850            "{section} component {} symbol mismatch: expected {expected_symbol}, found {symbol}",
851            type_idx + 1
852        )));
853    }
854
855    Ok(())
856}
857
858fn validate_section_atom_identity(
859    section: &str,
860    atom_idx: usize,
861    fixed: [bool; 3],
862    atom_id: u64,
863    atom_data: &[AtomDatum],
864) -> Result<(), ParseError> {
865    let atom = atom_data.get(atom_idx).ok_or_else(|| {
866        ParseError::ValidationError(format!(
867            "{section} row {atom_idx} has no coordinate atom to validate against"
868        ))
869    })?;
870
871    if atom.fixed != fixed {
872        return Err(ParseError::ValidationError(format!(
873            "{section} row {atom_idx} fixed mask mismatch for atom_id {}",
874            atom.atom_id
875        )));
876    }
877    if atom.atom_id != atom_id {
878        return Err(ParseError::ValidationError(format!(
879            "{section} row {atom_idx} atom_id mismatch: expected {}, found {atom_id}",
880            atom.atom_id
881        )));
882    }
883
884    Ok(())
885}
886
887/// Attempts to parse an optional velocity section following coordinate blocks.
888///
889/// In `.convel` files, after all coordinate blocks there is a blank separator line
890/// followed by per-component velocity blocks with the same structure as coordinate
891/// blocks (symbol line, "Velocities of Component N" line, then atom lines with
892/// `vx vy vz fixed atomID`).
893///
894/// This function peeks at the next line. If it is blank (or contains only whitespace),
895/// it consumes the blank line and parses velocity data into the existing `atom_data`.
896/// If the next line is not blank (or is absent), no velocities are parsed.
897///
898/// Returns `Ok(true)` if velocities were found and parsed, `Ok(false)` otherwise.
899pub fn parse_velocity_section<'a>(
900    lines: &mut impl LineStream<'a>,
901    header: &FrameHeader,
902    atom_data: &mut [AtomDatum],
903) -> Result<bool, ParseError> {
904    let validate = header.strict_validation;
905    // Peek at the next line to check for blank separator
906    match lines.peek_line() {
907        Some(line) if line.trim().is_empty() => {
908            // Consume the blank separator
909            lines.next_line();
910        }
911        _ => return Ok(false),
912    }
913
914    let mut atom_idx: usize = 0;
915    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
916        // Symbol line
917        let symbol = lines
918            .next_line()
919            .ok_or(ParseError::IncompleteVelocitySection)?
920            .trim();
921
922        // "Velocities of Component N" line
923        let comp_line = lines
924            .next_line()
925            .ok_or(ParseError::IncompleteVelocitySection)?;
926        // Validate it looks like a velocity header (optional strictness)
927        if !comp_line.contains("Velocities of Component") {
928            return Err(ParseError::IncompleteVelocitySection);
929        }
930        if validate {
931            validate_section_component(
932                "Velocities",
933                type_idx,
934                atom_idx,
935                symbol,
936                comp_line,
937                header,
938                atom_data,
939            )?;
940        }
941
942        for _ in 0..num_atoms {
943            let vel_line = lines
944                .next_line()
945                .ok_or(ParseError::IncompleteVelocitySection)?;
946            // Column 5 (atom_index) is optional in velocity lines too.
947            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
948            let mut vals = [0.0f64; 5];
949            parse_line_of_range_f64_stack(vel_line, 4, 5, &defaults, &mut vals)?;
950            if validate {
951                let (fixed, atom_id) =
952                    parse_identity_columns(vel_line, "velocities", 3, 4, 5, header.spec_version)?;
953                validate_section_atom_identity("velocities", atom_idx, fixed, atom_id, atom_data)?;
954            }
955            if atom_idx < atom_data.len() {
956                atom_data[atom_idx].velocity = Some([vals[0], vals[1], vals[2]]);
957            }
958            atom_idx += 1;
959        }
960    }
961
962    Ok(true)
963}
964
965/// Attempts to parse a force section following coordinate (and optional velocity) blocks.
966///
967/// Force sections mirror velocity sections: a blank separator line followed by per-component
968/// force blocks (symbol line, "Forces of Component N" line, then atom lines with
969/// `fx fy fz fixed_flag atom_id`).
970///
971/// Returns `Ok(true)` if forces were found and parsed, `Ok(false)` otherwise.
972pub fn parse_force_section<'a>(
973    lines: &mut impl LineStream<'a>,
974    header: &FrameHeader,
975    atom_data: &mut [AtomDatum],
976) -> Result<bool, ParseError> {
977    let validate = header.strict_validation;
978    // Peek at the next line to check for blank separator
979    match lines.peek_line() {
980        Some(line) if line.trim().is_empty() => {
981            lines.next_line();
982        }
983        _ => return Ok(false),
984    }
985
986    let mut atom_idx: usize = 0;
987    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
988        let symbol = lines
989            .next_line()
990            .ok_or(ParseError::IncompleteForceSection)?
991            .trim();
992
993        let comp_line = lines
994            .next_line()
995            .ok_or(ParseError::IncompleteForceSection)?;
996        if !comp_line.contains("Forces of Component") {
997            return Err(ParseError::IncompleteForceSection);
998        }
999        if validate {
1000            validate_section_component(
1001                "Forces", type_idx, atom_idx, symbol, comp_line, header, atom_data,
1002            )?;
1003        }
1004
1005        for _ in 0..num_atoms {
1006            let force_line = lines
1007                .next_line()
1008                .ok_or(ParseError::IncompleteForceSection)?;
1009            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
1010            let mut vals = [0.0f64; 5];
1011            parse_line_of_range_f64_stack(force_line, 4, 5, &defaults, &mut vals)?;
1012            if validate {
1013                let (fixed, atom_id) =
1014                    parse_identity_columns(force_line, "forces", 3, 4, 5, header.spec_version)?;
1015                validate_section_atom_identity("forces", atom_idx, fixed, atom_id, atom_data)?;
1016            }
1017            if atom_idx < atom_data.len() {
1018                atom_data[atom_idx].force = Some([vals[0], vals[1], vals[2]]);
1019            }
1020            atom_idx += 1;
1021        }
1022    }
1023
1024    Ok(true)
1025}
1026
1027/// Attempts to parse an energies section following coordinate (and optional
1028/// velocity / force) blocks.
1029///
1030/// Energy sections mirror force sections but with one scalar per atom:
1031/// blank separator, then per-component blocks of (symbol, "Energies of
1032/// Component N", and atom lines `e fixed_flag atom_id`). The two
1033/// trailing identity columns are optional and used only for strict
1034/// validation; in non-strict mode any whitespace after the energy is
1035/// ignored.
1036///
1037/// Returns `Ok(true)` if energies were found and parsed, `Ok(false)`
1038/// otherwise.
1039pub fn parse_energy_section<'a>(
1040    lines: &mut impl LineStream<'a>,
1041    header: &FrameHeader,
1042    atom_data: &mut [AtomDatum],
1043) -> Result<bool, ParseError> {
1044    let validate = header.strict_validation;
1045    match lines.peek_line() {
1046        Some(line) if line.trim().is_empty() => {
1047            lines.next_line();
1048        }
1049        _ => return Ok(false),
1050    }
1051
1052    let mut atom_idx: usize = 0;
1053    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1054        let symbol = lines
1055            .next_line()
1056            .ok_or(ParseError::IncompleteEnergySection)?
1057            .trim();
1058
1059        let comp_line = lines
1060            .next_line()
1061            .ok_or(ParseError::IncompleteEnergySection)?;
1062        if !comp_line.contains("Energies of Component") {
1063            return Err(ParseError::IncompleteEnergySection);
1064        }
1065        if validate {
1066            validate_section_component(
1067                "Energies", type_idx, atom_idx, symbol, comp_line, header, atom_data,
1068            )?;
1069        }
1070
1071        for _ in 0..num_atoms {
1072            let energy_line = lines
1073                .next_line()
1074                .ok_or(ParseError::IncompleteEnergySection)?;
1075            // Single energy column, plus optional fixed flag and atom_id
1076            // for round-trip identity checks.
1077            let defaults = [0.0, 0.0, atom_idx as f64];
1078            let vals = parse_line_of_range_f64(energy_line, 1, 3, &defaults)?;
1079            if validate {
1080                let (fixed, atom_id) =
1081                    parse_identity_columns(energy_line, "energies", 1, 2, 3, header.spec_version)?;
1082                validate_section_atom_identity("energies", atom_idx, fixed, atom_id, atom_data)?;
1083            }
1084            if atom_idx < atom_data.len() {
1085                atom_data[atom_idx].energy = Some(vals[0]);
1086            }
1087            atom_idx += 1;
1088        }
1089    }
1090
1091    Ok(true)
1092}
1093
1094/// Parses declared sections from a frame's header metadata.
1095///
1096/// If `header.sections` is non-empty (v2 file with `"sections"` key in JSON),
1097/// parses each declared section in order. Otherwise falls back to legacy
1098/// blank-separator velocity detection.
1099pub fn parse_declared_sections<'a>(
1100    lines: &mut impl LineStream<'a>,
1101    header: &mut FrameHeader,
1102    atom_data: &mut [AtomDatum],
1103) -> Result<usize, ParseError> {
1104    let mut applied = 0usize;
1105    if !header.sections_declared && header.sections.is_empty() {
1106        // Legacy: try velocity detection via blank separator
1107        let found = parse_velocity_section(lines, header, atom_data)?;
1108        if found {
1109            header.sections.push(SECTION_VELOCITIES.into());
1110            applied = 1;
1111        }
1112    } else {
1113        let sections = std::mem::take(&mut header.sections);
1114        for section in &sections {
1115            match section.as_str() {
1116                SECTION_VELOCITIES => {
1117                    let found = parse_velocity_section(lines, header, atom_data)?;
1118                    if !found {
1119                        return Err(ParseError::IncompleteVelocitySection);
1120                    }
1121                    applied += 1;
1122                }
1123                SECTION_FORCES => {
1124                    let found = parse_force_section(lines, header, atom_data)?;
1125                    if !found {
1126                        return Err(ParseError::IncompleteForceSection);
1127                    }
1128                    applied += 1;
1129                }
1130                SECTION_ENERGIES => {
1131                    let found = parse_energy_section(lines, header, atom_data)?;
1132                    if !found {
1133                        return Err(ParseError::IncompleteEnergySection);
1134                    }
1135                    applied += 1;
1136                }
1137                SECTION_CHARGES => {
1138                    let found = parse_charge_section(lines, header, atom_data)?;
1139                    if !found {
1140                        return Err(ParseError::IncompleteSection(SECTION_CHARGES.into()));
1141                    }
1142                    applied += 1;
1143                }
1144                SECTION_SPINS => {
1145                    let found = parse_spin_section(lines, header, atom_data)?;
1146                    if !found {
1147                        return Err(ParseError::IncompleteSection(SECTION_SPINS.into()));
1148                    }
1149                    applied += 1;
1150                }
1151                SECTION_MAGMOMS => {
1152                    let found = parse_magmom_section(lines, header, atom_data)?;
1153                    if !found {
1154                        return Err(ParseError::IncompleteSection(SECTION_MAGMOMS.into()));
1155                    }
1156                    applied += 1;
1157                }
1158                SECTION_DISPLACEMENTS => {
1159                    let found = parse_displacement_section(lines, header, atom_data)?;
1160                    if !found {
1161                        return Err(ParseError::IncompleteSection(SECTION_DISPLACEMENTS.into()));
1162                    }
1163                    applied += 1;
1164                }
1165                other => return Err(ParseError::UnknownSection(other.to_string())),
1166            }
1167        }
1168        header.sections = sections;
1169    }
1170    Ok(applied)
1171}
1172
1173/// Scalar per-atom section: blank separator + per-type blocks
1174/// (`symbol`, `"X of Component N"`, lines `value fixed atom_id`).
1175fn parse_scalar_atom_section<'a>(
1176    lines: &mut impl LineStream<'a>,
1177    header: &FrameHeader,
1178    atom_data: &mut [AtomDatum],
1179    section_name: &str,
1180    component_label: &str,
1181    mut set_value: impl FnMut(&mut AtomDatum, f64),
1182) -> Result<bool, ParseError> {
1183    let validate = header.strict_validation;
1184    match lines.peek_line() {
1185        Some(line) if line.trim().is_empty() => {
1186            lines.next_line();
1187        }
1188        _ => return Ok(false),
1189    }
1190
1191    let mut atom_idx: usize = 0;
1192    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1193        let symbol = lines
1194            .next_line()
1195            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?
1196            .trim();
1197
1198        let comp_line = lines
1199            .next_line()
1200            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
1201        if !comp_line.contains(component_label) {
1202            return Err(ParseError::IncompleteSection(section_name.into()));
1203        }
1204        if validate {
1205            validate_section_component(
1206                component_label.trim_end_matches(" of Component").trim(),
1207                type_idx,
1208                atom_idx,
1209                symbol,
1210                comp_line,
1211                header,
1212                atom_data,
1213            )?;
1214        }
1215
1216        for _ in 0..num_atoms {
1217            let data_line = lines
1218                .next_line()
1219                .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
1220            let defaults = [0.0, 0.0, atom_idx as f64];
1221            let vals = parse_line_of_range_f64(data_line, 1, 3, &defaults)?;
1222            if validate {
1223                let (fixed, atom_id) =
1224                    parse_identity_columns(data_line, section_name, 1, 2, 3, header.spec_version)?;
1225                validate_section_atom_identity(section_name, atom_idx, fixed, atom_id, atom_data)?;
1226            }
1227            if atom_idx < atom_data.len() {
1228                set_value(&mut atom_data[atom_idx], vals[0]);
1229            }
1230            atom_idx += 1;
1231        }
1232    }
1233    Ok(true)
1234}
1235
1236pub fn parse_charge_section<'a>(
1237    lines: &mut impl LineStream<'a>,
1238    header: &FrameHeader,
1239    atom_data: &mut [AtomDatum],
1240) -> Result<bool, ParseError> {
1241    parse_scalar_atom_section(
1242        lines,
1243        header,
1244        atom_data,
1245        SECTION_CHARGES,
1246        "Charges of Component",
1247        |a, v| a.charge = Some(v),
1248    )
1249}
1250
1251pub fn parse_spin_section<'a>(
1252    lines: &mut impl LineStream<'a>,
1253    header: &FrameHeader,
1254    atom_data: &mut [AtomDatum],
1255) -> Result<bool, ParseError> {
1256    parse_scalar_atom_section(
1257        lines,
1258        header,
1259        atom_data,
1260        SECTION_SPINS,
1261        "Spins of Component",
1262        |a, v| a.spin = Some(v),
1263    )
1264}
1265
1266/// Magmoms: 3-vector per atom, same layout as velocities/forces.
1267pub fn parse_magmom_section<'a>(
1268    lines: &mut impl LineStream<'a>,
1269    header: &FrameHeader,
1270    atom_data: &mut [AtomDatum],
1271) -> Result<bool, ParseError> {
1272    let validate = header.strict_validation;
1273    match lines.peek_line() {
1274        Some(line) if line.trim().is_empty() => {
1275            lines.next_line();
1276        }
1277        _ => return Ok(false),
1278    }
1279
1280    let mut atom_idx: usize = 0;
1281    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1282        let symbol = lines
1283            .next_line()
1284            .ok_or_else(|| ParseError::IncompleteSection(SECTION_MAGMOMS.into()))?
1285            .trim();
1286
1287        let comp_line = lines
1288            .next_line()
1289            .ok_or_else(|| ParseError::IncompleteSection(SECTION_MAGMOMS.into()))?;
1290        if !comp_line.contains("Magmoms of Component") {
1291            return Err(ParseError::IncompleteSection(SECTION_MAGMOMS.into()));
1292        }
1293        if validate {
1294            validate_section_component(
1295                "Magmoms", type_idx, atom_idx, symbol, comp_line, header, atom_data,
1296            )?;
1297        }
1298
1299        for _ in 0..num_atoms {
1300            let mm_line = lines
1301                .next_line()
1302                .ok_or_else(|| ParseError::IncompleteSection(SECTION_MAGMOMS.into()))?;
1303            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
1304            let mut vals = [0.0f64; 5];
1305            parse_line_of_range_f64_stack(mm_line, 4, 5, &defaults, &mut vals)?;
1306            if validate {
1307                let (fixed, atom_id) =
1308                    parse_identity_columns(mm_line, SECTION_MAGMOMS, 3, 4, 5, header.spec_version)?;
1309                validate_section_atom_identity(
1310                    SECTION_MAGMOMS,
1311                    atom_idx,
1312                    fixed,
1313                    atom_id,
1314                    atom_data,
1315                )?;
1316            }
1317            if atom_idx < atom_data.len() {
1318                atom_data[atom_idx].magmom = Some([vals[0], vals[1], vals[2]]);
1319            }
1320            atom_idx += 1;
1321        }
1322    }
1323    Ok(true)
1324}
1325
1326/// Displacements: 3-vector per atom (Angstrom), same layout as velocities/forces.
1327pub fn parse_displacement_section<'a>(
1328    lines: &mut impl LineStream<'a>,
1329    header: &FrameHeader,
1330    atom_data: &mut [AtomDatum],
1331) -> Result<bool, ParseError> {
1332    let validate = header.strict_validation;
1333    match lines.peek_line() {
1334        Some(line) if line.trim().is_empty() => {
1335            lines.next_line();
1336        }
1337        _ => return Ok(false),
1338    }
1339
1340    let mut atom_idx: usize = 0;
1341    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1342        let symbol = lines
1343            .next_line()
1344            .ok_or_else(|| ParseError::IncompleteSection(SECTION_DISPLACEMENTS.into()))?
1345            .trim();
1346
1347        let comp_line = lines
1348            .next_line()
1349            .ok_or_else(|| ParseError::IncompleteSection(SECTION_DISPLACEMENTS.into()))?;
1350        if !comp_line.contains("Displacements of Component") {
1351            return Err(ParseError::IncompleteSection(SECTION_DISPLACEMENTS.into()));
1352        }
1353        if validate {
1354            validate_section_component(
1355                "Displacements",
1356                type_idx,
1357                atom_idx,
1358                symbol,
1359                comp_line,
1360                header,
1361                atom_data,
1362            )?;
1363        }
1364
1365        for _ in 0..num_atoms {
1366            let dsp_line = lines
1367                .next_line()
1368                .ok_or_else(|| ParseError::IncompleteSection(SECTION_DISPLACEMENTS.into()))?;
1369            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
1370            let mut vals = [0.0f64; 5];
1371            parse_line_of_range_f64_stack(dsp_line, 4, 5, &defaults, &mut vals)?;
1372            if validate {
1373                let (fixed, atom_id) = parse_identity_columns(
1374                    dsp_line,
1375                    SECTION_DISPLACEMENTS,
1376                    3,
1377                    4,
1378                    5,
1379                    header.spec_version,
1380                )?;
1381                validate_section_atom_identity(
1382                    SECTION_DISPLACEMENTS,
1383                    atom_idx,
1384                    fixed,
1385                    atom_id,
1386                    atom_data,
1387                )?;
1388            }
1389            if atom_idx < atom_data.len() {
1390                atom_data[atom_idx].displacement = Some([vals[0], vals[1], vals[2]]);
1391            }
1392            atom_idx += 1;
1393        }
1394    }
1395    Ok(true)
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401    use crate::iterators::ConFrameIterator;
1402
1403    #[test]
1404    fn test_parse_line_of_n_success() {
1405        let line = "1.0 2.5 -3.0";
1406        let values = parse_line_of_n::<f64>(line, 3).unwrap();
1407        assert_eq!(values, vec![1.0, 2.5, -3.0]);
1408    }
1409
1410    #[test]
1411    fn test_parse_line_of_n_too_short() {
1412        let line = "1.0 2.5";
1413        let result = parse_line_of_n::<f64>(line, 3);
1414        assert!(result.is_err());
1415        assert!(matches!(
1416            result.unwrap_err(),
1417            ParseError::InvalidVectorLength {
1418                expected: 3,
1419                found: 2
1420            }
1421        ));
1422    }
1423
1424    #[test]
1425    fn test_parse_line_of_n_too_long() {
1426        let line = "1.0 2.5 -3.0 4.0";
1427        let result = parse_line_of_n::<f64>(line, 3);
1428        assert!(result.is_err());
1429        assert!(matches!(
1430            result.unwrap_err(),
1431            ParseError::InvalidVectorLength {
1432                expected: 3,
1433                found: 4
1434            }
1435        ));
1436    }
1437
1438    #[test]
1439    fn test_parse_line_of_n_invalid_float() {
1440        let line = "1.0 abc -3.0";
1441        let result = parse_line_of_n::<f64>(line, 3);
1442        assert!(result.is_err());
1443        assert!(matches!(
1444            result.unwrap_err(),
1445            ParseError::InvalidNumberFormat(_)
1446        ));
1447    }
1448
1449    #[test]
1450    fn test_parse_frame_header_success() {
1451        let lines = [
1452            "PREBOX1",
1453            "{\"con_spec_version\":2}",
1454            "10.0 20.0 30.0",
1455            "90.0 90.0 90.0",
1456            "POSTBOX1",
1457            "POSTBOX2",
1458            "2",
1459            "1 1",
1460            "12.011 1.008",
1461        ];
1462        let mut line_it = lines.iter().copied();
1463        match parse_frame_header(&mut line_it) {
1464            Ok(header) => {
1465                assert_eq!(header.prebox_header.user, "PREBOX1");
1466                assert_eq!(header.spec_version, 2);
1467                assert_eq!(header.boxl, [10.0, 20.0, 30.0]);
1468                assert_eq!(header.angles, [90.0, 90.0, 90.0]);
1469                assert_eq!(header.postbox_header, ["POSTBOX1", "POSTBOX2"]);
1470                assert_eq!(header.natm_types, 2);
1471                assert_eq!(header.natms_per_type, vec![1, 1]);
1472                assert_eq!(header.masses_per_type, vec![12.011, 1.008]);
1473            }
1474            Err(e) => {
1475                panic!(
1476                    "Parsing failed when it should have succeeded. Error: {:?}",
1477                    e
1478                );
1479            }
1480        }
1481    }
1482
1483    #[test]
1484    fn test_parse_frame_header_missing_line() {
1485        let lines = [
1486            "PREBOX1",
1487            "{\"con_spec_version\":2}",
1488            "10.0 20.0 30.0",
1489            "90.0 90.0 90.0",
1490            "POSTBOX1",
1491            "POSTBOX2",
1492            "2",
1493            "1 1",
1494            // Missing masses_per_type
1495        ];
1496        let mut line_it = lines.iter().copied();
1497        let result = parse_frame_header(&mut line_it);
1498        assert!(result.is_err());
1499        assert!(matches!(result.unwrap_err(), ParseError::IncompleteHeader));
1500    }
1501
1502    #[test]
1503    fn test_parse_frame_header_missing_spec_version() {
1504        let lines = vec![
1505            "PREBOX1",
1506            "{}",
1507            "10.0 20.0 30.0",
1508            "90.0 90.0 90.0",
1509            "POSTBOX1",
1510            "POSTBOX2",
1511            "2",
1512            "1 1",
1513            "12.011 1.008",
1514        ];
1515        let mut line_it = lines.iter().copied();
1516        let result = parse_frame_header(&mut line_it);
1517        assert!(result.is_err());
1518        assert!(matches!(
1519            result.unwrap_err(),
1520            ParseError::MissingSpecVersion
1521        ));
1522    }
1523
1524    #[test]
1525    fn test_parse_frame_header_legacy_no_json() {
1526        // A non-JSON line 2 is treated as a legacy (v1) file.
1527        let lines = vec![
1528            "PREBOX1",
1529            "0.0000 TIME",
1530            "10.0 20.0 30.0",
1531            "90.0 90.0 90.0",
1532            "POSTBOX1",
1533            "POSTBOX2",
1534            "2",
1535            "1 1",
1536            "12.011 1.008",
1537        ];
1538        let mut line_it = lines.iter().copied();
1539        let header = parse_frame_header(&mut line_it).unwrap();
1540        assert_eq!(header.spec_version, 1);
1541        assert!(header.metadata.is_empty());
1542    }
1543
1544    #[test]
1545    fn test_parse_frame_header_malformed_json() {
1546        // Line 2 starts with '{' but is not valid JSON -- this IS an error.
1547        let lines = vec![
1548            "PREBOX1",
1549            "{broken json",
1550            "10.0 20.0 30.0",
1551            "90.0 90.0 90.0",
1552            "POSTBOX1",
1553            "POSTBOX2",
1554            "2",
1555            "1 1",
1556            "12.011 1.008",
1557        ];
1558        let mut line_it = lines.iter().copied();
1559        let result = parse_frame_header(&mut line_it);
1560        assert!(result.is_err());
1561        assert!(matches!(
1562            result.unwrap_err(),
1563            ParseError::InvalidMetadataJson(_)
1564        ));
1565    }
1566
1567    #[test]
1568    fn test_parse_frame_header_unsupported_version() {
1569        let lines = vec![
1570            "PREBOX1",
1571            "{\"con_spec_version\":999}",
1572            "10.0 20.0 30.0",
1573            "90.0 90.0 90.0",
1574            "POSTBOX1",
1575            "POSTBOX2",
1576            "2",
1577            "1 1",
1578            "12.011 1.008",
1579        ];
1580        let mut line_it = lines.iter().copied();
1581        let result = parse_frame_header(&mut line_it);
1582        assert!(result.is_err());
1583        assert!(matches!(
1584            result.unwrap_err(),
1585            ParseError::UnsupportedSpecVersion(999)
1586        ));
1587    }
1588
1589    #[test]
1590    fn test_v3_missing_units_rejected() {
1591        let lines = vec![
1592            "PREBOX1",
1593            "{\"con_spec_version\":3}",
1594            "10.0 20.0 30.0",
1595            "90.0 90.0 90.0",
1596            "POSTBOX1",
1597            "POSTBOX2",
1598            "1",
1599            "1",
1600            "1.0",
1601        ];
1602        let mut line_it = lines.iter().copied();
1603        let err = parse_frame_header(&mut line_it).unwrap_err();
1604        let msg = err.to_string();
1605        assert!(
1606            msg.contains("units") || msg.contains("v3"),
1607            "expected units error, got {msg}"
1608        );
1609    }
1610
1611    #[test]
1612    fn test_v3_invalid_units_rejected() {
1613        let lines = vec![
1614            "PREBOX1",
1615            r#"{"con_spec_version":3,"units":{"length":"eV","energy":"angstrom"}}"#,
1616            "10.0 20.0 30.0",
1617            "90.0 90.0 90.0",
1618            "POSTBOX1",
1619            "POSTBOX2",
1620            "1",
1621            "1",
1622            "1.0",
1623        ];
1624        let mut line_it = lines.iter().copied();
1625        assert!(parse_frame_header(&mut line_it).is_err());
1626    }
1627
1628    #[test]
1629    fn test_v3_valid_units_exposes_length_energy() {
1630        let lines = vec![
1631            "PREBOX1",
1632            r#"{"con_spec_version":3,"units":{"length":"angstrom","energy":"eV","mass":"amu","time":"fs"}}"#,
1633            "10.0 20.0 30.0",
1634            "90.0 90.0 90.0",
1635            "POSTBOX1",
1636            "POSTBOX2",
1637            "1",
1638            "1",
1639            "1.0",
1640        ];
1641        let mut line_it = lines.iter().copied();
1642        let header = parse_frame_header(&mut line_it).unwrap();
1643        assert_eq!(header.spec_version, 3);
1644        assert_eq!(header.length_unit(), Some("angstrom"));
1645        assert_eq!(header.energy_unit(), Some("eV"));
1646        let f = header.conversion_factor_to("length", "nm").unwrap();
1647        assert!((f - 0.1).abs() < 1e-12);
1648    }
1649
1650    #[test]
1651    fn test_parse_frame_header_extra_metadata_preserved() {
1652        let lines = vec![
1653            "PREBOX1",
1654            "{\"con_spec_version\":2,\"generator\":\"test\"}",
1655            "10.0 20.0 30.0",
1656            "90.0 90.0 90.0",
1657            "POSTBOX1",
1658            "POSTBOX2",
1659            "2",
1660            "1 1",
1661            "12.011 1.008",
1662        ];
1663        let mut line_it = lines.iter().copied();
1664        let header = parse_frame_header(&mut line_it).unwrap();
1665        assert_eq!(header.spec_version, 2);
1666        assert_eq!(
1667            header.metadata.get("generator"),
1668            Some(&serde_json::Value::String("test".to_string()))
1669        );
1670    }
1671
1672    #[test]
1673    fn test_parse_frame_header_invalid_natms_per_type() {
1674        let lines = vec![
1675            "PREBOX1",
1676            "{\"con_spec_version\":2}",
1677            "10.0 20.0 30.0",
1678            "90.0 90.0 90.0",
1679            "POSTBOX1",
1680            "POSTBOX2",
1681            "2",
1682            "1 1 1", // 3 values, but natm_types is 2
1683            "12.011 1.008",
1684        ];
1685        let mut line_it = lines.iter().copied();
1686        let result = parse_frame_header(&mut line_it);
1687        assert!(result.is_err());
1688        assert!(matches!(
1689            result.unwrap_err(),
1690            ParseError::InvalidVectorLength {
1691                expected: 2,
1692                found: 3
1693            }
1694        ));
1695    }
1696
1697    #[test]
1698    fn test_parse_single_frame_success() {
1699        let lines = vec![
1700            "PREBOX1",
1701            "{\"con_spec_version\":2}",
1702            "10.0 20.0 30.0",
1703            "90.0 90.0 90.0",
1704            "POSTBOX1",
1705            "POSTBOX2",
1706            "2",
1707            "3 3",
1708            "12.011 1.008",
1709            "1",
1710            "Coordinates of Component 1",
1711            "0.0 0.0 0.0 0.0 1",
1712            "1.0940 0.0 0.0 0.0 2",
1713            "-0.5470 0.9499 0.0 0.0 3",
1714            "2",
1715            "Coordinates of Component 2",
1716            "5.0 5.0 5.0 0.0 4",
1717            "6.0940 5.0 5.0 0.0 5",
1718            "5.5470 5.9499 5.0 0.0 6",
1719        ];
1720        let mut line_it = lines.iter().copied();
1721        let frame = parse_single_frame(&mut line_it).unwrap();
1722
1723        assert_eq!(frame.header.natm_types, 2);
1724        assert_eq!(frame.header.natms_per_type, vec![3, 3]);
1725        assert_eq!(frame.header.masses_per_type, vec![12.011, 1.008]);
1726        assert_eq!(frame.atom_data.len(), 6);
1727        assert_eq!(&*frame.atom_data[0].symbol, "1");
1728        assert_eq!(frame.atom_data[0].atom_id, 1);
1729        assert_eq!(&*frame.atom_data[5].symbol, "2");
1730        assert_eq!(frame.atom_data[5].atom_id, 6);
1731    }
1732
1733    #[test]
1734    fn test_column_4_value_1_is_x_only_on_spec_2() {
1735        let lines = vec![
1736            "Generated by probe",
1737            "{\"con_spec_version\":2}",
1738            "20.0 20.0 20.0",
1739            "90.0 90.0 90.0",
1740            "",
1741            "",
1742            "1",
1743            "1",
1744            "63.5",
1745            "Cu",
1746            "Coordinates of component   1",
1747            "1.0 2.0 3.0 1 1",
1748        ];
1749        let mut line_it = lines.iter().copied();
1750        let frame = parse_single_frame(&mut line_it).unwrap();
1751        assert_eq!(frame.header.spec_version, 2);
1752        assert_eq!(frame.atom_data[0].fixed, [true, false, false]);
1753    }
1754
1755    #[test]
1756    fn test_column_4_value_1_is_all_fixed_on_spec_1() {
1757        let lines = vec![
1758            "Generated by probe",
1759            "0.0000 TIME",
1760            "20.0 20.0 20.0",
1761            "90.0 90.0 90.0",
1762            "",
1763            "",
1764            "1",
1765            "1",
1766            "63.5",
1767            "Cu",
1768            "Coordinates of component   1",
1769            "1.0 2.0 3.0 1 1",
1770        ];
1771        let mut line_it = lines.iter().copied();
1772        let frame = parse_single_frame(&mut line_it).unwrap();
1773        assert_eq!(frame.header.spec_version, 1);
1774        assert_eq!(frame.atom_data[0].fixed, [true, true, true]);
1775    }
1776
1777    #[test]
1778    fn test_parse_single_frame_missing_line() {
1779        // With a valid header but truncated atom data, we get IncompleteFrame.
1780        let lines = vec![
1781            "PREBOX1",
1782            "{\"con_spec_version\":2}",
1783            "10.0 20.0 30.0",
1784            "90.0 90.0 90.0",
1785            "POSTBOX1",
1786            "POSTBOX2",
1787            "2",
1788            "3 3",
1789            "12.011 1.008",
1790            "1",
1791            "Coordinates of Component 1",
1792            "0.0 0.0 0.0 0.0 1",
1793            "1.0940 0.0 0.0 0.0 2",
1794            "-0.5470 0.9499 0.0 0.0 3",
1795            // Missing Component 2 entirely
1796        ];
1797        let mut line_it = lines.iter().copied();
1798        let result = parse_single_frame(&mut line_it);
1799        assert!(result.is_err());
1800        assert!(matches!(result.unwrap_err(), ParseError::IncompleteFrame));
1801    }
1802
1803    #[test]
1804    fn test_parse_single_frame_missing_atom_index_defaults_sequential() {
1805        // Column 5 (atom_index) is optional; when absent, defaults to sequential.
1806        let lines = vec![
1807            "PREBOX1",
1808            "{\"con_spec_version\":2}",
1809            "10.0 20.0 30.0",
1810            "90.0 90.0 90.0",
1811            "POSTBOX1",
1812            "POSTBOX2",
1813            "2",
1814            "3 3",
1815            "12.011 1.008",
1816            "1",
1817            "Coordinates of Component 1",
1818            "0.0 0.0 0.0 0.0 1",
1819            "1.0940 0.0 0.0 0.0 2",
1820            "-0.5470 0.9499 0.0 0.0 3",
1821            "2",
1822            "Coordinates of Component 2",
1823            "5.0 5.0 5.0 0.0",       // No atom_index: defaults to 3
1824            "6.0940 5.0 5.0 0.0 10", // Explicit atom_index: 10
1825            "5.5470 5.9499 5.0 0.0", // No atom_index: defaults to 5
1826        ];
1827        let mut line_it = lines.iter().copied();
1828        let frame = parse_single_frame(&mut line_it).unwrap();
1829        assert_eq!(frame.atom_data.len(), 6);
1830        // First type: explicit atom_index values
1831        assert_eq!(frame.atom_data[0].atom_id, 1);
1832        assert_eq!(frame.atom_data[1].atom_id, 2);
1833        assert_eq!(frame.atom_data[2].atom_id, 3);
1834        // Second type: mixed explicit and defaulted
1835        assert_eq!(frame.atom_data[3].atom_id, 3); // defaulted (global idx 3)
1836        assert_eq!(frame.atom_data[4].atom_id, 10); // explicit
1837        assert_eq!(frame.atom_data[5].atom_id, 5); // defaulted (global idx 5)
1838    }
1839
1840    #[test]
1841    fn test_parse_single_frame_too_few_columns_fails() {
1842        // Only 3 columns (missing fixed_flag too) should still fail.
1843        let lines = vec![
1844            "PREBOX1",
1845            "{\"con_spec_version\":2}",
1846            "10.0 20.0 30.0",
1847            "90.0 90.0 90.0",
1848            "POSTBOX1",
1849            "POSTBOX2",
1850            "1",
1851            "1",
1852            "12.011",
1853            "C",
1854            "Coordinates of Component 1",
1855            "0.0 0.0 0.0", // Only 3 values
1856        ];
1857        let mut line_it = lines.iter().copied();
1858        let result = parse_single_frame(&mut line_it);
1859        assert!(result.is_err());
1860        assert!(matches!(
1861            result.unwrap_err(),
1862            ParseError::InvalidVectorLength {
1863                expected: 5,
1864                found: 3
1865            }
1866        ));
1867    }
1868
1869    #[test]
1870    fn test_parse_velocity_section_present() {
1871        let lines = vec![
1872            "PREBOX1",
1873            "{\"con_spec_version\":2}",
1874            "10.0 20.0 30.0",
1875            "90.0 90.0 90.0",
1876            "POSTBOX1",
1877            "POSTBOX2",
1878            "2",
1879            "1 1",
1880            "63.546 1.008",
1881            "Cu",
1882            "Coordinates of Component 1",
1883            "0.0 0.0 0.0 1.0 0",
1884            "H",
1885            "Coordinates of Component 2",
1886            "1.0 2.0 3.0 0.0 1",
1887            "",
1888            "Cu",
1889            "Velocities of Component 1",
1890            "0.1 0.2 0.3 1.0 0",
1891            "H",
1892            "Velocities of Component 2",
1893            "0.4 0.5 0.6 0.0 1",
1894        ];
1895        let mut line_it = lines.iter().copied().peekable();
1896        // Parse the frame first (consuming 15 lines)
1897        let mut frame =
1898            parse_single_frame(&mut line_it).expect("coordinate parsing should succeed");
1899        assert!(!frame.has_velocities());
1900
1901        // Now parse the velocity section
1902        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
1903            .expect("velocity parsing should succeed");
1904        assert!(has_vel);
1905        assert_eq!(frame.atom_data[0].velocity, Some([0.1, 0.2, 0.3]));
1906        assert_eq!(frame.atom_data[1].velocity, Some([0.4, 0.5, 0.6]));
1907    }
1908
1909    #[test]
1910    fn test_validate_true_accepts_matching_section_identity() {
1911        let text = r#"
1912PREBOX1
1913{"con_spec_version":2,"sections":["velocities"],"validate":true}
191410.0 20.0 30.0
191590.0 90.0 90.0
1916POSTBOX1
1917POSTBOX2
19182
19191 1
192063.546 1.008
1921Cu
1922Coordinates of Component 1
19230.0 0.0 0.0 5 0
1924H
1925Coordinates of Component 2
19261.0 2.0 3.0 0 1
1927
1928Cu
1929Velocities of Component 1
19300.1 0.2 0.3 5 0
1931H
1932Velocities of Component 2
19330.4 0.5 0.6 0 1
1934"#;
1935        let mut iter = ConFrameIterator::new(text.trim());
1936        let frame = iter.next().unwrap().unwrap();
1937
1938        assert!(frame.has_velocities());
1939        assert_eq!(
1940            frame
1941                .header
1942                .metadata
1943                .get("validate")
1944                .and_then(|v| v.as_bool()),
1945            Some(true)
1946        );
1947    }
1948
1949    #[test]
1950    fn test_validate_true_rejects_section_atom_id_mismatch() {
1951        let text = r#"
1952PREBOX1
1953{"con_spec_version":2,"sections":["velocities"],"validate":true}
195410.0 20.0 30.0
195590.0 90.0 90.0
1956POSTBOX1
1957POSTBOX2
19581
19591
196063.546
1961Cu
1962Coordinates of Component 1
19630.0 0.0 0.0 5 0
1964
1965Cu
1966Velocities of Component 1
19670.1 0.2 0.3 5 99
1968"#;
1969        let mut iter = ConFrameIterator::new(text.trim());
1970        let err = iter.next().unwrap().unwrap_err();
1971
1972        assert!(matches!(err, ParseError::ValidationError(_)));
1973        assert!(err.to_string().contains("atom_id mismatch"));
1974    }
1975
1976    #[test]
1977    fn test_validate_true_rejects_section_symbol_mismatch() {
1978        let text = r#"
1979PREBOX1
1980{"con_spec_version":2,"sections":["forces"],"validate":true}
198110.0 20.0 30.0
198290.0 90.0 90.0
1983POSTBOX1
1984POSTBOX2
19851
19861
198763.546
1988Cu
1989Coordinates of Component 1
19900.0 0.0 0.0 5 0
1991
1992H
1993Forces of Component 1
19940.1 0.2 0.3 5 0
1995"#;
1996        let mut iter = ConFrameIterator::new(text.trim());
1997        let err = iter.next().unwrap().unwrap_err();
1998
1999        assert!(matches!(err, ParseError::ValidationError(_)));
2000        assert!(err.to_string().contains("symbol mismatch"));
2001    }
2002
2003    #[test]
2004    fn test_validate_absent_allows_legacy_duplicate_identity_mismatch() {
2005        let text = r#"
2006PREBOX1
2007{"con_spec_version":2,"sections":["velocities"]}
200810.0 20.0 30.0
200990.0 90.0 90.0
2010POSTBOX1
2011POSTBOX2
20121
20131
201463.546
2015Cu
2016Coordinates of Component 1
20170.0 0.0 0.0 5 0
2018
2019Cu
2020Velocities of Component 1
20210.1 0.2 0.3 0 99
2022"#;
2023        let mut iter = ConFrameIterator::new(text.trim());
2024        let frame = iter.next().unwrap().unwrap();
2025
2026        assert!(frame.has_velocities());
2027        assert_eq!(frame.atom_data[0].atom_id, 0);
2028        assert_eq!(frame.atom_data[0].fixed, [true, false, true]);
2029    }
2030
2031    #[test]
2032    fn test_validate_must_be_boolean_when_present() {
2033        let lines = vec![
2034            "PREBOX1",
2035            "{\"con_spec_version\":2,\"validate\":\"yes\",\"sections\":[]}",
2036            "10.0 20.0 30.0",
2037            "90.0 90.0 90.0",
2038            "POSTBOX1",
2039            "POSTBOX2",
2040            "1",
2041            "1",
2042            "12.011",
2043        ];
2044        let mut line_it = lines.iter().copied();
2045        let err = parse_frame_header(&mut line_it).unwrap_err();
2046
2047        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2048        assert!(err.to_string().contains("validate"));
2049    }
2050
2051    #[test]
2052    fn test_validate_true_requires_sections_key() {
2053        let lines = vec![
2054            "PREBOX1",
2055            "{\"con_spec_version\":2,\"validate\":true}",
2056            "10.0 20.0 30.0",
2057            "90.0 90.0 90.0",
2058            "POSTBOX1",
2059            "POSTBOX2",
2060            "1",
2061            "1",
2062            "12.011",
2063        ];
2064        let mut line_it = lines.iter().copied();
2065        let err = parse_frame_header(&mut line_it).unwrap_err();
2066
2067        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2068        assert!(err.to_string().contains("sections"));
2069    }
2070
2071    #[test]
2072    fn test_sections_must_be_string_array_when_present() {
2073        let lines = vec![
2074            "PREBOX1",
2075            "{\"con_spec_version\":2,\"sections\":[\"velocities\",7]}",
2076            "10.0 20.0 30.0",
2077            "90.0 90.0 90.0",
2078            "POSTBOX1",
2079            "POSTBOX2",
2080            "1",
2081            "1",
2082            "12.011",
2083        ];
2084        let mut line_it = lines.iter().copied();
2085        let err = parse_frame_header(&mut line_it).unwrap_err();
2086
2087        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2088        assert!(err.to_string().contains("sections"));
2089    }
2090
2091    #[test]
2092    fn test_validate_true_rejects_non_integer_coordinate_identity_columns() {
2093        let text = r#"
2094PREBOX1
2095{"con_spec_version":2,"sections":[],"validate":true}
209610.0 20.0 30.0
209790.0 90.0 90.0
2098POSTBOX1
2099POSTBOX2
21001
21011
210263.546
2103Cu
2104Coordinates of Component 1
21050.0 0.0 0.0 5.0 0
2106"#;
2107        let mut iter = ConFrameIterator::new(text.trim());
2108        let err = iter.next().unwrap().unwrap_err();
2109
2110        assert!(matches!(err, ParseError::ValidationError(_)));
2111        assert!(err.to_string().contains("fixed_flag"));
2112    }
2113
2114    #[test]
2115    fn test_validate_true_rejects_non_exact_coordinate_label() {
2116        let text = r#"
2117PREBOX1
2118{"con_spec_version":2,"sections":[],"validate":true}
211910.0 20.0 30.0
212090.0 90.0 90.0
2121POSTBOX1
2122POSTBOX2
21231
21241
212563.546
2126Cu
2127Coordinates Component 1
21280.0 0.0 0.0 5 0
2129"#;
2130        let mut iter = ConFrameIterator::new(text.trim());
2131        let err = iter.next().unwrap().unwrap_err();
2132
2133        assert!(matches!(err, ParseError::ValidationError(_)));
2134        assert!(err.to_string().contains("Coordinates of Component 1"));
2135    }
2136
2137    #[test]
2138    fn test_validate_true_rejects_unknown_component_symbol() {
2139        let text = r#"
2140PREBOX1
2141{"con_spec_version":2,"sections":[],"validate":true}
214210.0 20.0 30.0
214390.0 90.0 90.0
2144POSTBOX1
2145POSTBOX2
21461
21471
214863.546
2149Qq
2150Coordinates of Component 1
21510.0 0.0 0.0 0 0
2152"#;
2153        let mut iter = ConFrameIterator::new(text.trim());
2154        let err = iter.next().unwrap().unwrap_err();
2155
2156        assert!(matches!(err, ParseError::ValidationError(_)));
2157        assert!(err.to_string().contains("symbol"));
2158    }
2159
2160    #[test]
2161    fn test_declared_section_must_be_present() {
2162        let text = r#"
2163PREBOX1
2164{"con_spec_version":2,"sections":["velocities"]}
216510.0 20.0 30.0
216690.0 90.0 90.0
2167POSTBOX1
2168POSTBOX2
21691
21701
217163.546
2172Cu
2173Coordinates of Component 1
21740.0 0.0 0.0 0 0
2175"#;
2176        let mut iter = ConFrameIterator::new(text.trim());
2177        let err = iter.next().unwrap().unwrap_err();
2178
2179        assert!(matches!(err, ParseError::IncompleteVelocitySection));
2180    }
2181
2182    #[test]
2183    fn test_non_finite_cell_geometry_rejected_in_strict_mode() {
2184        let lines = vec![
2185            "PREBOX1",
2186            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
2187            "10.0 NaN 30.0",
2188            "90.0 90.0 90.0",
2189            "POSTBOX1",
2190            "POSTBOX2",
2191            "1",
2192            "1",
2193            "12.011",
2194        ];
2195        let mut line_it = lines.iter().copied();
2196        let err = parse_frame_header(&mut line_it).unwrap_err();
2197
2198        assert!(matches!(err, ParseError::ValidationError(_)));
2199        assert!(err.to_string().contains("cell"));
2200    }
2201
2202    #[test]
2203    fn test_validate_true_rejects_non_physical_cell_geometry() {
2204        let lines = vec![
2205            "PREBOX1",
2206            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
2207            "0.0 20.0 30.0",
2208            "90.0 180.0 90.0",
2209            "POSTBOX1",
2210            "POSTBOX2",
2211            "1",
2212            "1",
2213            "12.011",
2214        ];
2215        let mut line_it = lines.iter().copied();
2216        let err = parse_frame_header(&mut line_it).unwrap_err();
2217
2218        assert!(matches!(err, ParseError::ValidationError(_)));
2219        assert!(err.to_string().contains("cell"));
2220    }
2221
2222    #[test]
2223    fn test_validate_true_rejects_reserved_metadata_type_mismatch() {
2224        let lines = vec![
2225            "PREBOX1",
2226            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"energy\":\"low\"}",
2227            "10.0 20.0 30.0",
2228            "90.0 90.0 90.0",
2229            "POSTBOX1",
2230            "POSTBOX2",
2231            "1",
2232            "1",
2233            "12.011",
2234        ];
2235        let mut line_it = lines.iter().copied();
2236        let err = parse_frame_header(&mut line_it).unwrap_err();
2237
2238        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2239        assert!(err.to_string().contains("energy"));
2240    }
2241
2242    #[test]
2243    fn test_validate_true_rejects_malformed_bonds() {
2244        let lines = vec![
2245            "PREBOX1",
2246            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"bonds\":[[0]]}",
2247            "10.0 20.0 30.0",
2248            "90.0 90.0 90.0",
2249            "POSTBOX1",
2250            "POSTBOX2",
2251            "1",
2252            "1",
2253            "12.011",
2254        ];
2255        let mut line_it = lines.iter().copied();
2256        let err = parse_frame_header(&mut line_it).unwrap_err();
2257        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2258        assert!(err.to_string().contains("bonds"));
2259    }
2260
2261    #[test]
2262    fn test_bonds_metadata_round_trip_in_header() {
2263        use crate::types::{Bond, meta};
2264        let lines = vec![
2265            "PREBOX1",
2266            "{\"con_spec_version\":2,\"bonds\":[[0,1],{\"i\":0,\"j\":2,\"order\":1}]}",
2267            "10.0 20.0 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 header = parse_frame_header(&mut line_it).expect("header");
2277        let bonds = header.bonds();
2278        assert_eq!(bonds.len(), 2);
2279        assert_eq!(bonds[0], Bond::new(0, 1));
2280        assert_eq!(bonds[1].i, 0);
2281        assert_eq!(bonds[1].j, 2);
2282        assert_eq!(bonds[1].order, Some(1));
2283        assert!(header.metadata.contains_key(meta::BONDS));
2284    }
2285
2286    #[test]
2287    fn test_parse_velocity_section_absent() {
2288        let lines = vec![
2289            "PREBOX1",
2290            "{\"con_spec_version\":2}",
2291            "10.0 20.0 30.0",
2292            "90.0 90.0 90.0",
2293            "POSTBOX1",
2294            "POSTBOX2",
2295            "1",
2296            "1",
2297            "12.011",
2298            "C",
2299            "Coordinates of Component 1",
2300            "0.0 0.0 0.0 0.0 1",
2301        ];
2302        let mut line_it = lines.iter().copied().peekable();
2303        let mut frame = parse_single_frame(&mut line_it).expect("parse should succeed");
2304        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
2305            .expect("should succeed with no velocities");
2306        assert!(!has_vel);
2307        assert_eq!(frame.atom_data[0].velocity, None);
2308    }
2309
2310    #[test]
2311    fn test_parse_line_of_range_f64_exact() {
2312        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0 42", 4, 5, &[0.0; 5]).unwrap();
2313        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 42.0]);
2314    }
2315
2316    #[test]
2317    fn test_byte_scan_stack_parses_realistic_coord_line() {
2318        // Typical CON atom line: x y z fixed_mask [atom_id]
2319        let line = "   0.63939999999999997    0.90449999999999997   -0.00009999999999977 1    0";
2320        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
2321        let mut buf = [0.0f64; 5];
2322        let n = parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
2323        assert_eq!(n, 5);
2324        assert!((buf[0] - 0.6394).abs() < 1e-4);
2325        assert!((buf[1] - 0.9045).abs() < 1e-4);
2326        assert_eq!(buf[3] as u8, 1);
2327        assert_eq!(buf[4] as u64, 0);
2328        // trailing junk must error
2329        let bad = "1.0 2.0 3.0 1 0 EXTRA";
2330        assert!(parse_line_of_range_f64_stack(bad, 4, 5, &defaults, &mut buf).is_err());
2331        // partial non-boundary token must error
2332        let glued = "1.0 2.0 3.0abc 1 0";
2333        assert!(parse_line_of_range_f64_stack(glued, 4, 5, &defaults, &mut buf).is_err());
2334    }
2335
2336    #[test]
2337    fn test_parse_line_of_range_f64_stack_matches_vec_api() {
2338        let defaults = [0.0, 0.0, 0.0, 0.0, 7.0];
2339        let line = "1.0 2.0 3.0 0.0";
2340        let mut buf = [0.0f64; 5];
2341        parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
2342        let via_vec = parse_line_of_range_f64(line, 4, 5, &defaults).unwrap();
2343        assert_eq!(&buf[..5], via_vec.as_slice());
2344        assert_eq!(buf[4], 7.0);
2345    }
2346
2347    #[test]
2348    fn test_parse_line_of_range_f64_padded() {
2349        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
2350        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0", 4, 5, &defaults).unwrap();
2351        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 99.0]);
2352    }
2353
2354    #[test]
2355    fn test_parse_line_of_range_f64_too_few() {
2356        let result = parse_line_of_range_f64("1.0 2.0 3.0", 4, 5, &[0.0; 5]);
2357        assert!(result.is_err());
2358    }
2359
2360    #[test]
2361    fn test_parse_line_of_range_f64_too_many() {
2362        let result = parse_line_of_range_f64("1.0 2.0 3.0 0.0 5.0 6.0", 4, 5, &[0.0; 5]);
2363        assert!(result.is_err());
2364    }
2365
2366    #[test]
2367    fn test_parse_all_four_column_lines() {
2368        // All atom lines have only 4 columns; atom_index defaults to sequential.
2369        let lines = vec![
2370            "PREBOX1",
2371            "{\"con_spec_version\":2}",
2372            "10.0 10.0 10.0",
2373            "90.0 90.0 90.0",
2374            "POSTBOX1",
2375            "POSTBOX2",
2376            "1",
2377            "3",
2378            "12.011",
2379            "C",
2380            "Coordinates of Component 1",
2381            "0.0 0.0 0.0 0",
2382            "1.0 0.0 0.0 0",
2383            "2.0 0.0 0.0 1",
2384        ];
2385        let mut line_it = lines.iter().copied();
2386        let frame = parse_single_frame(&mut line_it).unwrap();
2387        assert_eq!(frame.atom_data[0].atom_id, 0);
2388        assert_eq!(frame.atom_data[1].atom_id, 1);
2389        assert_eq!(frame.atom_data[2].atom_id, 2);
2390        assert!(frame.atom_data[2].is_fixed());
2391    }
2392}