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_ENERGIES, SECTION_FORCES,
5    SECTION_VELOCITIES,
6    decode_fixed_bitmask, meta,
7};
8use serde_json::Value;
9use std::collections::BTreeMap;
10use std::iter::Peekable;
11use std::sync::Arc;
12
13/// Parses a line of whitespace-separated f64 values using fast-float2.
14///
15/// This is the hot-path parser for coordinate and velocity lines. It uses
16/// `fast_float2::parse` instead of `str::parse::<f64>()` for better throughput
17/// on the numeric-heavy atom data lines.
18///
19/// # Arguments
20///
21/// * `line` - A string slice representing a single line of data.
22/// * `n` - The exact number of f64 values expected on the line.
23pub fn parse_line_of_n_f64(line: &str, n: usize) -> Result<Vec<f64>, ParseError> {
24    let mut values = Vec::with_capacity(n);
25    for token in line.split_ascii_whitespace() {
26        let val: f64 = fast_float2::parse(token)
27            .map_err(|_| ParseError::InvalidNumberFormat(format!("invalid float: {token}")))?;
28        values.push(val);
29    }
30    if values.len() == n {
31        Ok(values)
32    } else {
33        Err(ParseError::InvalidVectorLength {
34            expected: n,
35            found: values.len(),
36        })
37    }
38}
39
40/// Parses a line of whitespace-separated f64 values, accepting between `min`
41/// and `max` values (inclusive). Returns a vector of exactly `max` elements,
42/// padding with values from `defaults` when fewer than `max` are present.
43///
44/// Used for atom lines where column 5 (atom_index) is optional.
45pub fn parse_line_of_range_f64(
46    line: &str,
47    min: usize,
48    max: usize,
49    defaults: &[f64],
50) -> Result<Vec<f64>, ParseError> {
51    let mut values = Vec::with_capacity(max);
52    for token in line.split_ascii_whitespace() {
53        let val: f64 = fast_float2::parse(token)
54            .map_err(|_| ParseError::InvalidNumberFormat(format!("invalid float: {token}")))?;
55        values.push(val);
56    }
57    if values.len() < min || values.len() > max {
58        return Err(ParseError::InvalidVectorLength {
59            expected: max,
60            found: values.len(),
61        });
62    }
63    // Pad missing columns from defaults
64    while values.len() < max {
65        let idx = values.len();
66        values.push(defaults[idx]);
67    }
68    Ok(values)
69}
70
71fn metadata_json_error(message: impl Into<String>) -> ParseError {
72    ParseError::InvalidMetadataJson(message.into())
73}
74
75fn validate_metadata_number(key: &str, value: &Value) -> Result<(), ParseError> {
76    if value.as_f64().is_some() {
77        Ok(())
78    } else {
79        Err(metadata_json_error(format!(
80            "{key} must be a finite number"
81        )))
82    }
83}
84
85fn validate_metadata_integer(key: &str, value: &Value) -> Result<(), ParseError> {
86    if value.as_u64().is_some() {
87        Ok(())
88    } else {
89        Err(metadata_json_error(format!(
90            "{key} must be a non-negative integer"
91        )))
92    }
93}
94
95/// Validates a parsed metadata JSON object against the spec v2 schema.
96///
97/// Type-checks the `validate` and `sections` keys, then runs per-key
98/// schema checks for the recommended metadata keys. Used by the
99/// builder/Python `set_metadata_json` paths to fail fast on malformed
100/// input, and by the parser when the file requested strict validation
101/// via `"validate": true`.
102pub fn validate_metadata_schema(
103    json_obj: &serde_json::Map<String, Value>,
104) -> Result<(), ParseError> {
105    let strict_requested = match json_obj.get(meta::VALIDATE) {
106        Some(Value::Bool(b)) => *b,
107        Some(_) => return Err(metadata_json_error("validate must be a boolean")),
108        None => false,
109    };
110
111    match json_obj.get(meta::SECTIONS) {
112        Some(Value::Array(values)) => {
113            if values.iter().any(|entry| !entry.is_string()) {
114                return Err(metadata_json_error("sections must be an array of strings"));
115            }
116        }
117        Some(_) => return Err(metadata_json_error("sections must be an array of strings")),
118        None if strict_requested => {
119            return Err(metadata_json_error(
120                "validate=true requires a sections array, even when empty",
121            ));
122        }
123        None => {}
124    }
125
126    for (key, value) in json_obj {
127        match key.as_str() {
128            meta::CON_SPEC_VERSION | meta::SECTIONS | meta::VALIDATE => {}
129            meta::ENERGY
130            | meta::TIME
131            | meta::TIMESTEP
132            | meta::CONVERGENCE_FMAX
133            | meta::CONVERGENCE_ENERGY
134            | meta::FMAX => validate_metadata_number(key, value)?,
135            meta::FRAME_INDEX | meta::NEB_BEAD | meta::NEB_BAND => {
136                validate_metadata_integer(key, value)?
137            }
138            meta::GENERATOR if !value.is_string() => {
139                return Err(metadata_json_error("generator must be a string"));
140            }
141            meta::GENERATOR => {}
142            meta::UNITS | meta::POTENTIAL if !value.is_object() => {
143                return Err(metadata_json_error(format!("{key} must be an object")));
144            }
145            meta::POTENTIAL => {
146                if let Some(potential_type) = value.get("type")
147                    && !potential_type.is_string()
148                {
149                    return Err(metadata_json_error("potential.type must be a string"));
150                }
151            }
152            meta::UNITS => {}
153            meta::PBC => validate_pbc_metadata(value)?,
154            meta::BONDS => validate_bonds_metadata(value)?,
155            meta::LATTICE_VECTORS => validate_lattice_vectors_metadata(value)?,
156            meta::CONVERGED if !value.is_boolean() => {
157                return Err(metadata_json_error("converged must be a boolean"));
158            }
159            meta::CONVERGED => {}
160            _ => {}
161        }
162    }
163
164    Ok(())
165}
166
167fn validate_pbc_metadata(value: &Value) -> Result<(), ParseError> {
168    let Some(values) = value.as_array() else {
169        return Err(metadata_json_error("pbc must be a length-3 boolean array"));
170    };
171    if values.len() != 3 || values.iter().any(|entry| !entry.is_boolean()) {
172        return Err(metadata_json_error("pbc must be a length-3 boolean array"));
173    }
174    Ok(())
175}
176
177/// Validate optional `bonds` frame topology metadata.
178///
179/// Each element is either a length-2 non-negative integer pair `[i, j]` or an
180/// object `{"i": i, "j": j, "order"?: integer}`. Indices are 0-based into
181/// `atom_data` order (parser does not yet know atom count at metadata time;
182/// bounds are enforced when projecting to chemfiles / selection).
183fn validate_bonds_metadata(value: &Value) -> Result<(), ParseError> {
184    let Some(items) = value.as_array() else {
185        return Err(metadata_json_error("bonds must be an array"));
186    };
187    for (idx, item) in items.iter().enumerate() {
188        if let Some(pair) = item.as_array() {
189            if pair.len() != 2 {
190                return Err(metadata_json_error(format!(
191                    "bonds[{idx}] pair must have exactly two indices"
192                )));
193            }
194            for (k, entry) in pair.iter().enumerate() {
195                let Some(n) = entry.as_u64() else {
196                    return Err(metadata_json_error(format!(
197                        "bonds[{idx}][{k}] must be a non-negative integer"
198                    )));
199                };
200                if n > u32::MAX as u64 {
201                    return Err(metadata_json_error(format!(
202                        "bonds[{idx}][{k}] index exceeds u32"
203                    )));
204                }
205            }
206            continue;
207        }
208        if let Some(obj) = item.as_object() {
209            for key in ["i", "j"] {
210                let Some(entry) = obj.get(key) else {
211                    return Err(metadata_json_error(format!(
212                        "bonds[{idx}] object must include \"{key}\""
213                    )));
214                };
215                let Some(n) = entry.as_u64() else {
216                    return Err(metadata_json_error(format!(
217                        "bonds[{idx}].{key} must be a non-negative integer"
218                    )));
219                };
220                if n > u32::MAX as u64 {
221                    return Err(metadata_json_error(format!(
222                        "bonds[{idx}].{key} index exceeds u32"
223                    )));
224                }
225            }
226            if let Some(order) = obj.get("order")
227                && order.as_i64().is_none()
228            {
229                return Err(metadata_json_error(format!(
230                    "bonds[{idx}].order must be an integer when present"
231                )));
232            }
233            continue;
234        }
235        return Err(metadata_json_error(format!(
236            "bonds[{idx}] must be [i, j] or {{\"i\": i, \"j\": j, \"order\"?: ...}}"
237        )));
238    }
239    Ok(())
240}
241
242fn validate_lattice_vectors_metadata(value: &Value) -> Result<(), ParseError> {
243    let Some(rows) = value.as_array() else {
244        return Err(metadata_json_error(
245            "lattice_vectors must be a 3x3 numeric array",
246        ));
247    };
248    if rows.len() != 3 {
249        return Err(metadata_json_error(
250            "lattice_vectors must be a 3x3 numeric array",
251        ));
252    }
253    for row in rows {
254        let Some(entries) = row.as_array() else {
255            return Err(metadata_json_error(
256                "lattice_vectors must be a 3x3 numeric array",
257            ));
258        };
259        if entries.len() != 3 || entries.iter().any(|entry| entry.as_f64().is_none()) {
260            return Err(metadata_json_error(
261                "lattice_vectors must be a 3x3 numeric array",
262            ));
263        }
264    }
265    Ok(())
266}
267
268/// Parses a line of whitespace-separated values into a vector of a specific type.
269///
270/// This generic helper function takes a string slice, splits it by whitespace,
271/// and attempts to parse each substring into the target type `T`. The type `T`
272/// must implement `std::str::FromStr`.
273///
274/// # Arguments
275///
276/// * `line` - A string slice representing a single line of data.
277/// * `n` - The exact number of values expected on the line.
278///
279/// # Errors
280///
281/// * `ParseError::InvalidVectorLength` if the number of parsed values is not equal to `n`.
282/// * Propagates any error from the `parse()` method of the target type `T`.
283///
284/// # Example
285///
286/// ```
287/// use readcon_core::parser::parse_line_of_n;
288/// let line = "10.5 20.0 30.5";
289/// let values: Vec<f64> = parse_line_of_n(line, 3).unwrap();
290/// assert_eq!(values, vec![10.5, 20.0, 30.5]);
291///
292/// let result = parse_line_of_n::<i32>(line, 2);
293/// assert!(result.is_err());
294/// ```
295pub fn parse_line_of_n<T: std::str::FromStr>(line: &str, n: usize) -> Result<Vec<T>, ParseError>
296where
297    ParseError: From<<T as std::str::FromStr>::Err>,
298{
299    let values: Vec<T> = line
300        .split_whitespace()
301        .map(|s| s.parse::<T>())
302        .collect::<Result<_, _>>()?;
303
304    if values.len() == n {
305        Ok(values)
306    } else {
307        Err(ParseError::InvalidVectorLength {
308            expected: n,
309            found: values.len(),
310        })
311    }
312}
313
314/// Parses the 9-line header of a `.con` file frame from an iterator.
315///
316/// This function consumes the next 9 lines from the given line iterator to
317/// construct a `FrameHeader`. The iterator is advanced by 9 lines on success.
318///
319/// # Arguments
320///
321/// * `lines` - A mutable reference to an iterator that yields string slices.
322///
323/// # Errors
324///
325/// * `ParseError::IncompleteHeader` if the iterator has fewer than 9 lines remaining.
326/// * Propagates any errors from `parse_line_of_n` if the numeric data within
327///   the header is malformed.
328///
329/// # Panics
330///
331/// This function will panic if the intermediate vectors for box dimensions or angles,
332/// after being successfully parsed, cannot be converted into fixed-size arrays.
333/// This should not happen if `parse_line_of_n` is used correctly with `n=3`.
334pub fn parse_frame_header<'a>(
335    lines: &mut impl Iterator<Item = &'a str>,
336) -> Result<FrameHeader, ParseError> {
337    let prebox1 = lines
338        .next()
339        .ok_or(ParseError::IncompleteHeader)?
340        .to_string();
341    let prebox2_raw = lines.next().ok_or(ParseError::IncompleteHeader)?;
342
343    // Line 2: if it starts with '{', parse as JSON metadata (spec v2+).
344    // Otherwise treat as a legacy (pre-v2) file with spec_version = 1.
345    let trimmed = prebox2_raw.trim();
346    let (spec_version, metadata, sections, validate, sections_declared) = if trimmed.starts_with('{') {
347        let json_val: serde_json::Value = serde_json::from_str(trimmed)
348            .map_err(|e| ParseError::InvalidMetadataJson(e.to_string()))?;
349        let json_obj = json_val
350            .as_object()
351            .ok_or_else(|| ParseError::InvalidMetadataJson("expected a JSON object".to_string()))?;
352        let ver = json_obj
353            .get(meta::CON_SPEC_VERSION)
354            .and_then(|v| v.as_u64())
355            .ok_or(ParseError::MissingSpecVersion)? as u32;
356        if ver > crate::CON_SPEC_VERSION {
357            return Err(ParseError::UnsupportedSpecVersion(ver));
358        }
359
360        // Single pass over the JSON object: collect sections, capture the
361        // validate flag, copy the rest into metadata. Folds the previous
362        // pre-extract get(validate) + re-iterate pattern into one walk.
363        let mut sections: Vec<String> = Vec::new();
364        let mut metadata = BTreeMap::new();
365        let mut sections_declared = false;
366        let mut validate = false;
367        for (k, v) in json_obj {
368            match k.as_str() {
369                meta::CON_SPEC_VERSION => {}
370                meta::SECTIONS => {
371                    sections_declared = true;
372                    let arr = v.as_array().ok_or_else(|| {
373                        metadata_json_error("sections must be an array of strings")
374                    })?;
375                    sections.reserve(arr.len());
376                    for entry in arr {
377                        let s = entry.as_str().ok_or_else(|| {
378                            metadata_json_error("sections must be an array of strings")
379                        })?;
380                        sections.push(s.to_string());
381                    }
382                }
383                meta::VALIDATE => {
384                    validate = match v {
385                        Value::Bool(b) => *b,
386                        _ => return Err(metadata_json_error("validate must be a boolean")),
387                    };
388                    metadata.insert(k.clone(), v.clone());
389                }
390                _ => {
391                    metadata.insert(k.clone(), v.clone());
392                }
393            }
394        }
395
396        // Strict-mode schema check fires only when the file requested
397        // it. Hot-path parses (validate=false) skip the per-key match.
398        if validate {
399            validate_metadata_schema(json_obj)?;
400        }
401
402        (ver, metadata, sections, validate, sections_declared)
403    } else {
404        // Legacy file: no JSON metadata line.
405        (1_u32, BTreeMap::new(), Vec::new(), false, false)
406    };
407    let prebox2 = prebox2_raw.to_string();
408
409    let boxl_vec = parse_line_of_n_f64(lines.next().ok_or(ParseError::IncompleteHeader)?, 3)?;
410    let angles_vec = parse_line_of_n_f64(lines.next().ok_or(ParseError::IncompleteHeader)?, 3)?;
411    let postbox1 = lines
412        .next()
413        .ok_or(ParseError::IncompleteHeader)?
414        .to_string();
415    let postbox2 = lines
416        .next()
417        .ok_or(ParseError::IncompleteHeader)?
418        .to_string();
419    let natm_types =
420        parse_line_of_n::<usize>(lines.next().ok_or(ParseError::IncompleteHeader)?, 1)?[0];
421    let natms_per_type = parse_line_of_n::<usize>(
422        lines.next().ok_or(ParseError::IncompleteHeader)?,
423        natm_types,
424    )?;
425    let masses_per_type = parse_line_of_n_f64(
426        lines.next().ok_or(ParseError::IncompleteHeader)?,
427        natm_types,
428    )?;
429    if validate {
430        validate_header_geometry(&boxl_vec, &angles_vec, natm_types, &natms_per_type)?;
431        validate_masses(&masses_per_type)?;
432    }
433    Ok(FrameHeader {
434        prebox_header: PreboxHeader {
435            user: prebox1,
436            metadata_line: prebox2,
437        },
438        boxl: boxl_vec.try_into().unwrap(),
439        angles: angles_vec.try_into().unwrap(),
440        postbox_header: [postbox1, postbox2],
441        natm_types,
442        natms_per_type,
443        masses_per_type,
444        spec_version,
445        metadata,
446        sections,
447        strict_validation: validate,
448        sections_declared,
449    })
450}
451
452/// Parses a complete frame from a `.con` file, including its header and atomic data.
453///
454/// This function first parses the complete frame header and then uses the information within it
455/// (specifically the number of atom types and atoms per type) to parse the subsequent
456/// atom coordinate blocks.
457///
458/// # Arguments
459///
460/// * `lines` - A mutable reference to an iterator that yields string slices for the frame.
461///
462/// # Errors
463///
464/// * `ParseError::IncompleteFrame` if the iterator ends before all expected
465///   atomic data has been read.
466/// * Propagates any errors from the underlying calls to `parse_frame_header` and
467///   `parse_line_of_n`.
468///
469/// # Example
470///
471/// ```
472/// use readcon_core::parser::parse_single_frame;
473///
474/// let frame_text = r#"
475///Generated by test
476///{"con_spec_version":2}
477///10.0 10.0 10.0
478///90.0 90.0 90.0
479///POSTBOX LINE 1
480///POSTBOX LINE 2
481///2
482///1 1
483///12.011 1.008
484///C
485///Coordinates of Component 1
486///1.0 1.0 1.0 0.0 1
487///H
488///Coordinates of Component 2
489///2.0 2.0 2.0 0.0 2
490/// "#;
491///
492/// let mut lines = frame_text.trim().lines();
493/// let con_frame = parse_single_frame(&mut lines).unwrap();
494///
495/// assert_eq!(con_frame.header.natm_types, 2);
496/// assert_eq!(con_frame.atom_data.len(), 2);
497/// assert_eq!(&*con_frame.atom_data[0].symbol, "C");
498/// assert_eq!(con_frame.atom_data[1].atom_id, 2);
499/// ```
500pub fn parse_single_frame<'a>(
501    lines: &mut impl Iterator<Item = &'a str>,
502) -> Result<ConFrame, ParseError> {
503    let header = parse_frame_header(lines)?;
504    let validate = header.strict_validation;
505    let total_atoms: usize = header.natms_per_type.iter().sum();
506    let mut atom_data = Vec::with_capacity(total_atoms);
507
508    let mut global_atom_idx: u64 = 0;
509    for (type_idx, num_atoms) in header.natms_per_type.iter().enumerate() {
510        // Allocate the per-component Arc<str> directly from the trimmed
511        // line; going through a String intermediate would add a second
512        // allocation and copy for no semantic gain.
513        let symbol_line = lines.next().ok_or(ParseError::IncompleteFrame)?;
514        let symbol: Arc<str> = Arc::from(symbol_line.trim());
515        let coord_label = lines.next().ok_or(ParseError::IncompleteFrame)?;
516        if validate {
517            validate_coordinate_component(type_idx, symbol.as_ref(), coord_label)?;
518        }
519        for _ in 0..*num_atoms {
520            let coord_line = lines.next().ok_or(ParseError::IncompleteFrame)?;
521            // Column 5 (atom_index) is optional; defaults to sequential index.
522            let defaults = [0.0, 0.0, 0.0, 0.0, global_atom_idx as f64];
523            let vals = parse_line_of_range_f64(coord_line, 4, 5, &defaults)?;
524            let (fixed, atom_id) = if validate {
525                parse_identity_columns(coord_line, "coordinate", 3, 4, 5)?
526            } else {
527                (decode_fixed_bitmask(vals[3] as u8), vals[4] as u64)
528            };
529            atom_data.push(AtomDatum {
530                // This is a cheap reference-count increment, not a full string clone.
531                symbol: Arc::clone(&symbol),
532                x: vals[0],
533                y: vals[1],
534                z: vals[2],
535                fixed,
536                atom_id,
537                velocity: None,
538                force: None,
539                energy: None,
540            });
541            global_atom_idx += 1;
542        }
543    }
544    Ok(ConFrame { header, atom_data })
545}
546
547fn validate_header_geometry(
548    boxl: &[f64],
549    angles: &[f64],
550    natm_types: usize,
551    natms_per_type: &[usize],
552) -> Result<(), ParseError> {
553    if boxl.iter().any(|length| !length.is_finite() || *length <= 0.0)
554        || angles
555            .iter()
556            .any(|angle| !angle.is_finite() || *angle <= 0.0 || *angle >= 180.0)
557    {
558        return Err(ParseError::ValidationError(
559            "cell geometry must have positive lengths and angles between 0 and 180 degrees"
560                .to_string(),
561        ));
562    }
563    if natm_types == 0 || natms_per_type.contains(&0) {
564        return Err(ParseError::ValidationError(
565            "atom counts must contain at least one atom per component".to_string(),
566        ));
567    }
568    Ok(())
569}
570
571fn validate_masses(masses_per_type: &[f64]) -> Result<(), ParseError> {
572    if masses_per_type
573        .iter()
574        .any(|mass| !mass.is_finite() || *mass <= 0.0)
575    {
576        return Err(ParseError::ValidationError(
577            "component masses must be positive".to_string(),
578        ));
579    }
580    Ok(())
581}
582
583fn validate_coordinate_component(
584    type_idx: usize,
585    symbol: &str,
586    label: &str,
587) -> Result<(), ParseError> {
588    let expected_label = format!("Coordinates of Component {}", type_idx + 1);
589    if label.trim() != expected_label {
590        return Err(ParseError::ValidationError(format!(
591            "expected coordinate label {expected_label:?}, found {label:?}"
592        )));
593    }
594    if symbol != "X" && symbol_to_atomic_number(symbol) == 0 {
595        return Err(ParseError::ValidationError(format!(
596            "unknown component symbol {symbol}"
597        )));
598    }
599    Ok(())
600}
601
602/// Strict-validation parser for the per-row identity columns
603/// (fixed bitmask + atom_id) used by every section type.
604///
605/// `n_cols` is the total whitespace-separated column count expected on
606/// the row in strict mode, and `(fixed_idx, atom_id_idx)` are the
607/// 0-based positions of the fixed bitmask and atom_id columns inside
608/// that layout. Each section calls in with its own values:
609///
610/// - coordinates / velocities / forces: 5 cols, fixed=3, atom_id=4
611/// - energies: 3 cols, fixed=1, atom_id=2
612///
613/// String-based parsing on purpose: strict v2 mode rejects values that
614/// are not in the canonical integer form (e.g. `5.0` for a bitmask),
615/// which an f64 round-trip would silently accept.
616fn parse_identity_columns(
617    line: &str,
618    row_kind: &str,
619    fixed_idx: usize,
620    atom_id_idx: usize,
621    n_cols: usize,
622) -> Result<([bool; 3], u64), ParseError> {
623    let columns = line.split_ascii_whitespace().collect::<Vec<_>>();
624    if columns.len() != n_cols {
625        return Err(ParseError::ValidationError(format!(
626            "{row_kind} rows require {n_cols} columns including fixed_flag and atom_id in validate mode"
627        )));
628    }
629    let fixed_flag = columns[fixed_idx].parse::<u8>().map_err(|_| {
630        ParseError::ValidationError(format!("{row_kind} fixed_flag must be an integer bitmask"))
631    })?;
632    if fixed_flag > 7 {
633        return Err(ParseError::ValidationError(format!(
634            "{row_kind} fixed_flag must be between 0 and 7"
635        )));
636    }
637    let atom_id = columns[atom_id_idx].parse::<u64>().map_err(|_| {
638        ParseError::ValidationError(format!("{row_kind} atom_id must be an integer"))
639    })?;
640    Ok((decode_fixed_bitmask(fixed_flag), atom_id))
641}
642
643
644fn validate_section_component(
645    section: &str,
646    type_idx: usize,
647    atom_idx: usize,
648    symbol: &str,
649    label: &str,
650    header: &FrameHeader,
651    atom_data: &[AtomDatum],
652) -> Result<(), ParseError> {
653    let expected_label = format!("{section} of Component {}", type_idx + 1);
654    if label.trim() != expected_label {
655        return Err(ParseError::ValidationError(format!(
656            "expected section label {expected_label:?}, found {label:?}"
657        )));
658    }
659
660    if header.natms_per_type[type_idx] == 0 {
661        return Ok(());
662    }
663
664    let expected_symbol = atom_data
665        .get(atom_idx)
666        .map(|atom| atom.symbol.as_ref())
667        .ok_or_else(|| {
668            ParseError::ValidationError(format!(
669                "{section} component {} has no coordinate atom to validate against",
670                type_idx + 1
671            ))
672        })?;
673    if symbol != expected_symbol {
674        return Err(ParseError::ValidationError(format!(
675            "{section} component {} symbol mismatch: expected {expected_symbol}, found {symbol}",
676            type_idx + 1
677        )));
678    }
679
680    Ok(())
681}
682
683fn validate_section_atom_identity(
684    section: &str,
685    atom_idx: usize,
686    fixed: [bool; 3],
687    atom_id: u64,
688    atom_data: &[AtomDatum],
689) -> Result<(), ParseError> {
690    let atom = atom_data.get(atom_idx).ok_or_else(|| {
691        ParseError::ValidationError(format!(
692            "{section} row {atom_idx} has no coordinate atom to validate against"
693        ))
694    })?;
695
696    if atom.fixed != fixed {
697        return Err(ParseError::ValidationError(format!(
698            "{section} row {atom_idx} fixed mask mismatch for atom_id {}",
699            atom.atom_id
700        )));
701    }
702    if atom.atom_id != atom_id {
703        return Err(ParseError::ValidationError(format!(
704            "{section} row {atom_idx} atom_id mismatch: expected {}, found {atom_id}",
705            atom.atom_id
706        )));
707    }
708
709    Ok(())
710}
711
712/// Attempts to parse an optional velocity section following coordinate blocks.
713///
714/// In `.convel` files, after all coordinate blocks there is a blank separator line
715/// followed by per-component velocity blocks with the same structure as coordinate
716/// blocks (symbol line, "Velocities of Component N" line, then atom lines with
717/// `vx vy vz fixed atomID`).
718///
719/// This function peeks at the next line. If it is blank (or contains only whitespace),
720/// it consumes the blank line and parses velocity data into the existing `atom_data`.
721/// If the next line is not blank (or is absent), no velocities are parsed.
722///
723/// Returns `Ok(true)` if velocities were found and parsed, `Ok(false)` otherwise.
724pub fn parse_velocity_section<'a, I>(
725    lines: &mut Peekable<I>,
726    header: &FrameHeader,
727    atom_data: &mut [AtomDatum],
728) -> Result<bool, ParseError>
729where
730    I: Iterator<Item = &'a str>,
731{
732    let validate = header.strict_validation;
733    // Peek at the next line to check for blank separator
734    match lines.peek() {
735        Some(line) if line.trim().is_empty() => {
736            // Consume the blank separator
737            lines.next();
738        }
739        _ => return Ok(false),
740    }
741
742    let mut atom_idx: usize = 0;
743    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
744        // Symbol line
745        let symbol = lines
746            .next()
747            .ok_or(ParseError::IncompleteVelocitySection)?
748            .trim();
749
750        // "Velocities of Component N" line
751        let comp_line = lines.next().ok_or(ParseError::IncompleteVelocitySection)?;
752        // Validate it looks like a velocity header (optional strictness)
753        if !comp_line.contains("Velocities of Component") {
754            return Err(ParseError::IncompleteVelocitySection);
755        }
756        if validate {
757            validate_section_component(
758                "Velocities",
759                type_idx,
760                atom_idx,
761                symbol,
762                comp_line,
763                header,
764                atom_data,
765            )?;
766        }
767
768        for _ in 0..num_atoms {
769            let vel_line = lines.next().ok_or(ParseError::IncompleteVelocitySection)?;
770            // Column 5 (atom_index) is optional in velocity lines too.
771            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
772            let vals = parse_line_of_range_f64(vel_line, 4, 5, &defaults)?;
773            if validate {
774                let (fixed, atom_id) =
775                    parse_identity_columns(vel_line, "velocities", 3, 4, 5)?;
776                validate_section_atom_identity("velocities", atom_idx, fixed, atom_id, atom_data)?;
777            }
778            if atom_idx < atom_data.len() {
779                atom_data[atom_idx].velocity = Some([vals[0], vals[1], vals[2]]);
780            }
781            atom_idx += 1;
782        }
783    }
784
785    Ok(true)
786}
787
788/// Attempts to parse a force section following coordinate (and optional velocity) blocks.
789///
790/// Force sections mirror velocity sections: a blank separator line followed by per-component
791/// force blocks (symbol line, "Forces of Component N" line, then atom lines with
792/// `fx fy fz fixed_flag atom_id`).
793///
794/// Returns `Ok(true)` if forces were found and parsed, `Ok(false)` otherwise.
795pub fn parse_force_section<'a, I>(
796    lines: &mut Peekable<I>,
797    header: &FrameHeader,
798    atom_data: &mut [AtomDatum],
799) -> Result<bool, ParseError>
800where
801    I: Iterator<Item = &'a str>,
802{
803    let validate = header.strict_validation;
804    // Peek at the next line to check for blank separator
805    match lines.peek() {
806        Some(line) if line.trim().is_empty() => {
807            lines.next();
808        }
809        _ => return Ok(false),
810    }
811
812    let mut atom_idx: usize = 0;
813    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
814        let symbol = lines
815            .next()
816            .ok_or(ParseError::IncompleteForceSection)?
817            .trim();
818
819        let comp_line = lines.next().ok_or(ParseError::IncompleteForceSection)?;
820        if !comp_line.contains("Forces of Component") {
821            return Err(ParseError::IncompleteForceSection);
822        }
823        if validate {
824            validate_section_component(
825                "Forces", type_idx, atom_idx, symbol, comp_line, header, atom_data,
826            )?;
827        }
828
829        for _ in 0..num_atoms {
830            let force_line = lines.next().ok_or(ParseError::IncompleteForceSection)?;
831            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
832            let vals = parse_line_of_range_f64(force_line, 4, 5, &defaults)?;
833            if validate {
834                let (fixed, atom_id) =
835                    parse_identity_columns(force_line, "forces", 3, 4, 5)?;
836                validate_section_atom_identity("forces", atom_idx, fixed, atom_id, atom_data)?;
837            }
838            if atom_idx < atom_data.len() {
839                atom_data[atom_idx].force = Some([vals[0], vals[1], vals[2]]);
840            }
841            atom_idx += 1;
842        }
843    }
844
845    Ok(true)
846}
847
848/// Attempts to parse an energies section following coordinate (and optional
849/// velocity / force) blocks.
850///
851/// Energy sections mirror force sections but with one scalar per atom:
852/// blank separator, then per-component blocks of (symbol, "Energies of
853/// Component N", and atom lines `e fixed_flag atom_id`). The two
854/// trailing identity columns are optional and used only for strict
855/// validation; in non-strict mode any whitespace after the energy is
856/// ignored.
857///
858/// Returns `Ok(true)` if energies were found and parsed, `Ok(false)`
859/// otherwise.
860pub fn parse_energy_section<'a, I>(
861    lines: &mut Peekable<I>,
862    header: &FrameHeader,
863    atom_data: &mut [AtomDatum],
864) -> Result<bool, ParseError>
865where
866    I: Iterator<Item = &'a str>,
867{
868    let validate = header.strict_validation;
869    match lines.peek() {
870        Some(line) if line.trim().is_empty() => {
871            lines.next();
872        }
873        _ => return Ok(false),
874    }
875
876    let mut atom_idx: usize = 0;
877    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
878        let symbol = lines
879            .next()
880            .ok_or(ParseError::IncompleteEnergySection)?
881            .trim();
882
883        let comp_line = lines.next().ok_or(ParseError::IncompleteEnergySection)?;
884        if !comp_line.contains("Energies of Component") {
885            return Err(ParseError::IncompleteEnergySection);
886        }
887        if validate {
888            validate_section_component(
889                "Energies", type_idx, atom_idx, symbol, comp_line, header, atom_data,
890            )?;
891        }
892
893        for _ in 0..num_atoms {
894            let energy_line = lines.next().ok_or(ParseError::IncompleteEnergySection)?;
895            // Single energy column, plus optional fixed flag and atom_id
896            // for round-trip identity checks.
897            let defaults = [0.0, 0.0, atom_idx as f64];
898            let vals = parse_line_of_range_f64(energy_line, 1, 3, &defaults)?;
899            if validate {
900                let (fixed, atom_id) =
901                    parse_identity_columns(energy_line, "energies", 1, 2, 3)?;
902                validate_section_atom_identity("energies", atom_idx, fixed, atom_id, atom_data)?;
903            }
904            if atom_idx < atom_data.len() {
905                atom_data[atom_idx].energy = Some(vals[0]);
906            }
907            atom_idx += 1;
908        }
909    }
910
911    Ok(true)
912}
913
914/// Parses declared sections from a frame's header metadata.
915///
916/// If `header.sections` is non-empty (v2 file with `"sections"` key in JSON),
917/// parses each declared section in order. Otherwise falls back to legacy
918/// blank-separator velocity detection.
919pub fn parse_declared_sections<'a, I>(
920    lines: &mut Peekable<I>,
921    header: &mut FrameHeader,
922    atom_data: &mut [AtomDatum],
923) -> Result<(), ParseError>
924where
925    I: Iterator<Item = &'a str>,
926{
927    if !header.sections_declared && header.sections.is_empty() {
928        // Legacy: try velocity detection via blank separator
929        let found = parse_velocity_section(lines, header, atom_data)?;
930        if found {
931            header.sections.push(SECTION_VELOCITIES.into());
932        }
933    } else {
934        let sections = std::mem::take(&mut header.sections);
935        for section in &sections {
936            match section.as_str() {
937                SECTION_VELOCITIES => {
938                    let found = parse_velocity_section(lines, header, atom_data)?;
939                    if !found {
940                        return Err(ParseError::IncompleteVelocitySection);
941                    }
942                }
943                SECTION_FORCES => {
944                    let found = parse_force_section(lines, header, atom_data)?;
945                    if !found {
946                        return Err(ParseError::IncompleteForceSection);
947                    }
948                }
949                SECTION_ENERGIES => {
950                    let found = parse_energy_section(lines, header, atom_data)?;
951                    if !found {
952                        return Err(ParseError::IncompleteEnergySection);
953                    }
954                }
955                other => return Err(ParseError::UnknownSection(other.to_string())),
956            }
957        }
958        header.sections = sections;
959    }
960    Ok(())
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use crate::iterators::ConFrameIterator;
967
968    #[test]
969    fn test_parse_line_of_n_success() {
970        let line = "1.0 2.5 -3.0";
971        let values = parse_line_of_n::<f64>(line, 3).unwrap();
972        assert_eq!(values, vec![1.0, 2.5, -3.0]);
973    }
974
975    #[test]
976    fn test_parse_line_of_n_too_short() {
977        let line = "1.0 2.5";
978        let result = parse_line_of_n::<f64>(line, 3);
979        assert!(result.is_err());
980        assert!(matches!(
981            result.unwrap_err(),
982            ParseError::InvalidVectorLength {
983                expected: 3,
984                found: 2
985            }
986        ));
987    }
988
989    #[test]
990    fn test_parse_line_of_n_too_long() {
991        let line = "1.0 2.5 -3.0 4.0";
992        let result = parse_line_of_n::<f64>(line, 3);
993        assert!(result.is_err());
994        assert!(matches!(
995            result.unwrap_err(),
996            ParseError::InvalidVectorLength {
997                expected: 3,
998                found: 4
999            }
1000        ));
1001    }
1002
1003    #[test]
1004    fn test_parse_line_of_n_invalid_float() {
1005        let line = "1.0 abc -3.0";
1006        let result = parse_line_of_n::<f64>(line, 3);
1007        assert!(result.is_err());
1008        assert!(matches!(
1009            result.unwrap_err(),
1010            ParseError::InvalidNumberFormat(_)
1011        ));
1012    }
1013
1014    #[test]
1015    fn test_parse_frame_header_success() {
1016        let lines = [
1017            "PREBOX1",
1018            "{\"con_spec_version\":2}",
1019            "10.0 20.0 30.0",
1020            "90.0 90.0 90.0",
1021            "POSTBOX1",
1022            "POSTBOX2",
1023            "2",
1024            "1 1",
1025            "12.011 1.008",
1026        ];
1027        let mut line_it = lines.iter().copied();
1028        match parse_frame_header(&mut line_it) {
1029            Ok(header) => {
1030                assert_eq!(header.prebox_header.user, "PREBOX1");
1031                assert_eq!(header.spec_version, 2);
1032                assert_eq!(header.boxl, [10.0, 20.0, 30.0]);
1033                assert_eq!(header.angles, [90.0, 90.0, 90.0]);
1034                assert_eq!(header.postbox_header, ["POSTBOX1", "POSTBOX2"]);
1035                assert_eq!(header.natm_types, 2);
1036                assert_eq!(header.natms_per_type, vec![1, 1]);
1037                assert_eq!(header.masses_per_type, vec![12.011, 1.008]);
1038            }
1039            Err(e) => {
1040                panic!(
1041                    "Parsing failed when it should have succeeded. Error: {:?}",
1042                    e
1043                );
1044            }
1045        }
1046    }
1047
1048    #[test]
1049    fn test_parse_frame_header_missing_line() {
1050        let lines = [
1051            "PREBOX1",
1052            "{\"con_spec_version\":2}",
1053            "10.0 20.0 30.0",
1054            "90.0 90.0 90.0",
1055            "POSTBOX1",
1056            "POSTBOX2",
1057            "2",
1058            "1 1",
1059            // Missing masses_per_type
1060        ];
1061        let mut line_it = lines.iter().copied();
1062        let result = parse_frame_header(&mut line_it);
1063        assert!(result.is_err());
1064        assert!(matches!(result.unwrap_err(), ParseError::IncompleteHeader));
1065    }
1066
1067    #[test]
1068    fn test_parse_frame_header_missing_spec_version() {
1069        let lines = vec![
1070            "PREBOX1",
1071            "{}",
1072            "10.0 20.0 30.0",
1073            "90.0 90.0 90.0",
1074            "POSTBOX1",
1075            "POSTBOX2",
1076            "2",
1077            "1 1",
1078            "12.011 1.008",
1079        ];
1080        let mut line_it = lines.iter().copied();
1081        let result = parse_frame_header(&mut line_it);
1082        assert!(result.is_err());
1083        assert!(matches!(
1084            result.unwrap_err(),
1085            ParseError::MissingSpecVersion
1086        ));
1087    }
1088
1089    #[test]
1090    fn test_parse_frame_header_legacy_no_json() {
1091        // A non-JSON line 2 is treated as a legacy (v1) file.
1092        let lines = vec![
1093            "PREBOX1",
1094            "0.0000 TIME",
1095            "10.0 20.0 30.0",
1096            "90.0 90.0 90.0",
1097            "POSTBOX1",
1098            "POSTBOX2",
1099            "2",
1100            "1 1",
1101            "12.011 1.008",
1102        ];
1103        let mut line_it = lines.iter().copied();
1104        let header = parse_frame_header(&mut line_it).unwrap();
1105        assert_eq!(header.spec_version, 1);
1106        assert!(header.metadata.is_empty());
1107    }
1108
1109    #[test]
1110    fn test_parse_frame_header_malformed_json() {
1111        // Line 2 starts with '{' but is not valid JSON -- this IS an error.
1112        let lines = vec![
1113            "PREBOX1",
1114            "{broken json",
1115            "10.0 20.0 30.0",
1116            "90.0 90.0 90.0",
1117            "POSTBOX1",
1118            "POSTBOX2",
1119            "2",
1120            "1 1",
1121            "12.011 1.008",
1122        ];
1123        let mut line_it = lines.iter().copied();
1124        let result = parse_frame_header(&mut line_it);
1125        assert!(result.is_err());
1126        assert!(matches!(
1127            result.unwrap_err(),
1128            ParseError::InvalidMetadataJson(_)
1129        ));
1130    }
1131
1132    #[test]
1133    fn test_parse_frame_header_unsupported_version() {
1134        let lines = vec![
1135            "PREBOX1",
1136            "{\"con_spec_version\":999}",
1137            "10.0 20.0 30.0",
1138            "90.0 90.0 90.0",
1139            "POSTBOX1",
1140            "POSTBOX2",
1141            "2",
1142            "1 1",
1143            "12.011 1.008",
1144        ];
1145        let mut line_it = lines.iter().copied();
1146        let result = parse_frame_header(&mut line_it);
1147        assert!(result.is_err());
1148        assert!(matches!(
1149            result.unwrap_err(),
1150            ParseError::UnsupportedSpecVersion(999)
1151        ));
1152    }
1153
1154    #[test]
1155    fn test_parse_frame_header_extra_metadata_preserved() {
1156        let lines = vec![
1157            "PREBOX1",
1158            "{\"con_spec_version\":2,\"generator\":\"test\"}",
1159            "10.0 20.0 30.0",
1160            "90.0 90.0 90.0",
1161            "POSTBOX1",
1162            "POSTBOX2",
1163            "2",
1164            "1 1",
1165            "12.011 1.008",
1166        ];
1167        let mut line_it = lines.iter().copied();
1168        let header = parse_frame_header(&mut line_it).unwrap();
1169        assert_eq!(header.spec_version, 2);
1170        assert_eq!(
1171            header.metadata.get("generator"),
1172            Some(&serde_json::Value::String("test".to_string()))
1173        );
1174    }
1175
1176    #[test]
1177    fn test_parse_frame_header_invalid_natms_per_type() {
1178        let lines = vec![
1179            "PREBOX1",
1180            "{\"con_spec_version\":2}",
1181            "10.0 20.0 30.0",
1182            "90.0 90.0 90.0",
1183            "POSTBOX1",
1184            "POSTBOX2",
1185            "2",
1186            "1 1 1", // 3 values, but natm_types is 2
1187            "12.011 1.008",
1188        ];
1189        let mut line_it = lines.iter().copied();
1190        let result = parse_frame_header(&mut line_it);
1191        assert!(result.is_err());
1192        assert!(matches!(
1193            result.unwrap_err(),
1194            ParseError::InvalidVectorLength {
1195                expected: 2,
1196                found: 3
1197            }
1198        ));
1199    }
1200
1201    #[test]
1202    fn test_parse_single_frame_success() {
1203        let lines = vec![
1204            "PREBOX1",
1205            "{\"con_spec_version\":2}",
1206            "10.0 20.0 30.0",
1207            "90.0 90.0 90.0",
1208            "POSTBOX1",
1209            "POSTBOX2",
1210            "2",
1211            "3 3",
1212            "12.011 1.008",
1213            "1",
1214            "Coordinates of Component 1",
1215            "0.0 0.0 0.0 0.0 1",
1216            "1.0940 0.0 0.0 0.0 2",
1217            "-0.5470 0.9499 0.0 0.0 3",
1218            "2",
1219            "Coordinates of Component 2",
1220            "5.0 5.0 5.0 0.0 4",
1221            "6.0940 5.0 5.0 0.0 5",
1222            "5.5470 5.9499 5.0 0.0 6",
1223        ];
1224        let mut line_it = lines.iter().copied();
1225        let frame = parse_single_frame(&mut line_it).unwrap();
1226
1227        assert_eq!(frame.header.natm_types, 2);
1228        assert_eq!(frame.header.natms_per_type, vec![3, 3]);
1229        assert_eq!(frame.header.masses_per_type, vec![12.011, 1.008]);
1230        assert_eq!(frame.atom_data.len(), 6);
1231        assert_eq!(&*frame.atom_data[0].symbol, "1");
1232        assert_eq!(frame.atom_data[0].atom_id, 1);
1233        assert_eq!(&*frame.atom_data[5].symbol, "2");
1234        assert_eq!(frame.atom_data[5].atom_id, 6);
1235    }
1236
1237    #[test]
1238    fn test_parse_single_frame_missing_line() {
1239        // With a valid header but truncated atom data, we get IncompleteFrame.
1240        let lines = vec![
1241            "PREBOX1",
1242            "{\"con_spec_version\":2}",
1243            "10.0 20.0 30.0",
1244            "90.0 90.0 90.0",
1245            "POSTBOX1",
1246            "POSTBOX2",
1247            "2",
1248            "3 3",
1249            "12.011 1.008",
1250            "1",
1251            "Coordinates of Component 1",
1252            "0.0 0.0 0.0 0.0 1",
1253            "1.0940 0.0 0.0 0.0 2",
1254            "-0.5470 0.9499 0.0 0.0 3",
1255            // Missing Component 2 entirely
1256        ];
1257        let mut line_it = lines.iter().copied();
1258        let result = parse_single_frame(&mut line_it);
1259        assert!(result.is_err());
1260        assert!(matches!(result.unwrap_err(), ParseError::IncompleteFrame));
1261    }
1262
1263    #[test]
1264    fn test_parse_single_frame_missing_atom_index_defaults_sequential() {
1265        // Column 5 (atom_index) is optional; when absent, defaults to sequential.
1266        let lines = vec![
1267            "PREBOX1",
1268            "{\"con_spec_version\":2}",
1269            "10.0 20.0 30.0",
1270            "90.0 90.0 90.0",
1271            "POSTBOX1",
1272            "POSTBOX2",
1273            "2",
1274            "3 3",
1275            "12.011 1.008",
1276            "1",
1277            "Coordinates of Component 1",
1278            "0.0 0.0 0.0 0.0 1",
1279            "1.0940 0.0 0.0 0.0 2",
1280            "-0.5470 0.9499 0.0 0.0 3",
1281            "2",
1282            "Coordinates of Component 2",
1283            "5.0 5.0 5.0 0.0",       // No atom_index: defaults to 3
1284            "6.0940 5.0 5.0 0.0 10", // Explicit atom_index: 10
1285            "5.5470 5.9499 5.0 0.0", // No atom_index: defaults to 5
1286        ];
1287        let mut line_it = lines.iter().copied();
1288        let frame = parse_single_frame(&mut line_it).unwrap();
1289        assert_eq!(frame.atom_data.len(), 6);
1290        // First type: explicit atom_index values
1291        assert_eq!(frame.atom_data[0].atom_id, 1);
1292        assert_eq!(frame.atom_data[1].atom_id, 2);
1293        assert_eq!(frame.atom_data[2].atom_id, 3);
1294        // Second type: mixed explicit and defaulted
1295        assert_eq!(frame.atom_data[3].atom_id, 3); // defaulted (global idx 3)
1296        assert_eq!(frame.atom_data[4].atom_id, 10); // explicit
1297        assert_eq!(frame.atom_data[5].atom_id, 5); // defaulted (global idx 5)
1298    }
1299
1300    #[test]
1301    fn test_parse_single_frame_too_few_columns_fails() {
1302        // Only 3 columns (missing fixed_flag too) should still fail.
1303        let lines = vec![
1304            "PREBOX1",
1305            "{\"con_spec_version\":2}",
1306            "10.0 20.0 30.0",
1307            "90.0 90.0 90.0",
1308            "POSTBOX1",
1309            "POSTBOX2",
1310            "1",
1311            "1",
1312            "12.011",
1313            "C",
1314            "Coordinates of Component 1",
1315            "0.0 0.0 0.0", // Only 3 values
1316        ];
1317        let mut line_it = lines.iter().copied();
1318        let result = parse_single_frame(&mut line_it);
1319        assert!(result.is_err());
1320        assert!(matches!(
1321            result.unwrap_err(),
1322            ParseError::InvalidVectorLength {
1323                expected: 5,
1324                found: 3
1325            }
1326        ));
1327    }
1328
1329    #[test]
1330    fn test_parse_velocity_section_present() {
1331        let lines = vec![
1332            "PREBOX1",
1333            "{\"con_spec_version\":2}",
1334            "10.0 20.0 30.0",
1335            "90.0 90.0 90.0",
1336            "POSTBOX1",
1337            "POSTBOX2",
1338            "2",
1339            "1 1",
1340            "63.546 1.008",
1341            "Cu",
1342            "Coordinates of Component 1",
1343            "0.0 0.0 0.0 1.0 0",
1344            "H",
1345            "Coordinates of Component 2",
1346            "1.0 2.0 3.0 0.0 1",
1347            "",
1348            "Cu",
1349            "Velocities of Component 1",
1350            "0.1 0.2 0.3 1.0 0",
1351            "H",
1352            "Velocities of Component 2",
1353            "0.4 0.5 0.6 0.0 1",
1354        ];
1355        let mut line_it = lines.iter().copied().peekable();
1356        // Parse the frame first (consuming 15 lines)
1357        let mut frame =
1358            parse_single_frame(&mut line_it).expect("coordinate parsing should succeed");
1359        assert!(!frame.has_velocities());
1360
1361        // Now parse the velocity section
1362        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
1363            .expect("velocity parsing should succeed");
1364        assert!(has_vel);
1365        assert_eq!(frame.atom_data[0].velocity, Some([0.1, 0.2, 0.3]));
1366        assert_eq!(frame.atom_data[1].velocity, Some([0.4, 0.5, 0.6]));
1367    }
1368
1369    #[test]
1370    fn test_validate_true_accepts_matching_section_identity() {
1371        let text = r#"
1372PREBOX1
1373{"con_spec_version":2,"sections":["velocities"],"validate":true}
137410.0 20.0 30.0
137590.0 90.0 90.0
1376POSTBOX1
1377POSTBOX2
13782
13791 1
138063.546 1.008
1381Cu
1382Coordinates of Component 1
13830.0 0.0 0.0 5 0
1384H
1385Coordinates of Component 2
13861.0 2.0 3.0 0 1
1387
1388Cu
1389Velocities of Component 1
13900.1 0.2 0.3 5 0
1391H
1392Velocities of Component 2
13930.4 0.5 0.6 0 1
1394"#;
1395        let mut iter = ConFrameIterator::new(text.trim());
1396        let frame = iter.next().unwrap().unwrap();
1397
1398        assert!(frame.has_velocities());
1399        assert_eq!(
1400            frame
1401                .header
1402                .metadata
1403                .get("validate")
1404                .and_then(|v| v.as_bool()),
1405            Some(true)
1406        );
1407    }
1408
1409    #[test]
1410    fn test_validate_true_rejects_section_atom_id_mismatch() {
1411        let text = r#"
1412PREBOX1
1413{"con_spec_version":2,"sections":["velocities"],"validate":true}
141410.0 20.0 30.0
141590.0 90.0 90.0
1416POSTBOX1
1417POSTBOX2
14181
14191
142063.546
1421Cu
1422Coordinates of Component 1
14230.0 0.0 0.0 5 0
1424
1425Cu
1426Velocities of Component 1
14270.1 0.2 0.3 5 99
1428"#;
1429        let mut iter = ConFrameIterator::new(text.trim());
1430        let err = iter.next().unwrap().unwrap_err();
1431
1432        assert!(matches!(err, ParseError::ValidationError(_)));
1433        assert!(err.to_string().contains("atom_id mismatch"));
1434    }
1435
1436    #[test]
1437    fn test_validate_true_rejects_section_symbol_mismatch() {
1438        let text = r#"
1439PREBOX1
1440{"con_spec_version":2,"sections":["forces"],"validate":true}
144110.0 20.0 30.0
144290.0 90.0 90.0
1443POSTBOX1
1444POSTBOX2
14451
14461
144763.546
1448Cu
1449Coordinates of Component 1
14500.0 0.0 0.0 5 0
1451
1452H
1453Forces of Component 1
14540.1 0.2 0.3 5 0
1455"#;
1456        let mut iter = ConFrameIterator::new(text.trim());
1457        let err = iter.next().unwrap().unwrap_err();
1458
1459        assert!(matches!(err, ParseError::ValidationError(_)));
1460        assert!(err.to_string().contains("symbol mismatch"));
1461    }
1462
1463    #[test]
1464    fn test_validate_absent_allows_legacy_duplicate_identity_mismatch() {
1465        let text = r#"
1466PREBOX1
1467{"con_spec_version":2,"sections":["velocities"]}
146810.0 20.0 30.0
146990.0 90.0 90.0
1470POSTBOX1
1471POSTBOX2
14721
14731
147463.546
1475Cu
1476Coordinates of Component 1
14770.0 0.0 0.0 5 0
1478
1479Cu
1480Velocities of Component 1
14810.1 0.2 0.3 0 99
1482"#;
1483        let mut iter = ConFrameIterator::new(text.trim());
1484        let frame = iter.next().unwrap().unwrap();
1485
1486        assert!(frame.has_velocities());
1487        assert_eq!(frame.atom_data[0].atom_id, 0);
1488        assert_eq!(frame.atom_data[0].fixed, [true, false, true]);
1489    }
1490
1491    #[test]
1492    fn test_validate_must_be_boolean_when_present() {
1493        let lines = vec![
1494            "PREBOX1",
1495            "{\"con_spec_version\":2,\"validate\":\"yes\",\"sections\":[]}",
1496            "10.0 20.0 30.0",
1497            "90.0 90.0 90.0",
1498            "POSTBOX1",
1499            "POSTBOX2",
1500            "1",
1501            "1",
1502            "12.011",
1503        ];
1504        let mut line_it = lines.iter().copied();
1505        let err = parse_frame_header(&mut line_it).unwrap_err();
1506
1507        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1508        assert!(err.to_string().contains("validate"));
1509    }
1510
1511    #[test]
1512    fn test_validate_true_requires_sections_key() {
1513        let lines = vec![
1514            "PREBOX1",
1515            "{\"con_spec_version\":2,\"validate\":true}",
1516            "10.0 20.0 30.0",
1517            "90.0 90.0 90.0",
1518            "POSTBOX1",
1519            "POSTBOX2",
1520            "1",
1521            "1",
1522            "12.011",
1523        ];
1524        let mut line_it = lines.iter().copied();
1525        let err = parse_frame_header(&mut line_it).unwrap_err();
1526
1527        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1528        assert!(err.to_string().contains("sections"));
1529    }
1530
1531    #[test]
1532    fn test_sections_must_be_string_array_when_present() {
1533        let lines = vec![
1534            "PREBOX1",
1535            "{\"con_spec_version\":2,\"sections\":[\"velocities\",7]}",
1536            "10.0 20.0 30.0",
1537            "90.0 90.0 90.0",
1538            "POSTBOX1",
1539            "POSTBOX2",
1540            "1",
1541            "1",
1542            "12.011",
1543        ];
1544        let mut line_it = lines.iter().copied();
1545        let err = parse_frame_header(&mut line_it).unwrap_err();
1546
1547        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1548        assert!(err.to_string().contains("sections"));
1549    }
1550
1551    #[test]
1552    fn test_validate_true_rejects_non_integer_coordinate_identity_columns() {
1553        let text = r#"
1554PREBOX1
1555{"con_spec_version":2,"sections":[],"validate":true}
155610.0 20.0 30.0
155790.0 90.0 90.0
1558POSTBOX1
1559POSTBOX2
15601
15611
156263.546
1563Cu
1564Coordinates of Component 1
15650.0 0.0 0.0 5.0 0
1566"#;
1567        let mut iter = ConFrameIterator::new(text.trim());
1568        let err = iter.next().unwrap().unwrap_err();
1569
1570        assert!(matches!(err, ParseError::ValidationError(_)));
1571        assert!(err.to_string().contains("fixed_flag"));
1572    }
1573
1574    #[test]
1575    fn test_validate_true_rejects_non_exact_coordinate_label() {
1576        let text = r#"
1577PREBOX1
1578{"con_spec_version":2,"sections":[],"validate":true}
157910.0 20.0 30.0
158090.0 90.0 90.0
1581POSTBOX1
1582POSTBOX2
15831
15841
158563.546
1586Cu
1587Coordinates Component 1
15880.0 0.0 0.0 5 0
1589"#;
1590        let mut iter = ConFrameIterator::new(text.trim());
1591        let err = iter.next().unwrap().unwrap_err();
1592
1593        assert!(matches!(err, ParseError::ValidationError(_)));
1594        assert!(err.to_string().contains("Coordinates of Component 1"));
1595    }
1596
1597    #[test]
1598    fn test_validate_true_rejects_unknown_component_symbol() {
1599        let text = r#"
1600PREBOX1
1601{"con_spec_version":2,"sections":[],"validate":true}
160210.0 20.0 30.0
160390.0 90.0 90.0
1604POSTBOX1
1605POSTBOX2
16061
16071
160863.546
1609Qq
1610Coordinates of Component 1
16110.0 0.0 0.0 0 0
1612"#;
1613        let mut iter = ConFrameIterator::new(text.trim());
1614        let err = iter.next().unwrap().unwrap_err();
1615
1616        assert!(matches!(err, ParseError::ValidationError(_)));
1617        assert!(err.to_string().contains("symbol"));
1618    }
1619
1620    #[test]
1621    fn test_declared_section_must_be_present() {
1622        let text = r#"
1623PREBOX1
1624{"con_spec_version":2,"sections":["velocities"]}
162510.0 20.0 30.0
162690.0 90.0 90.0
1627POSTBOX1
1628POSTBOX2
16291
16301
163163.546
1632Cu
1633Coordinates of Component 1
16340.0 0.0 0.0 0 0
1635"#;
1636        let mut iter = ConFrameIterator::new(text.trim());
1637        let err = iter.next().unwrap().unwrap_err();
1638
1639        assert!(matches!(err, ParseError::IncompleteVelocitySection));
1640    }
1641
1642    #[test]
1643    fn test_non_finite_cell_geometry_rejected_in_strict_mode() {
1644        let lines = vec![
1645            "PREBOX1",
1646            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
1647            "10.0 NaN 30.0",
1648            "90.0 90.0 90.0",
1649            "POSTBOX1",
1650            "POSTBOX2",
1651            "1",
1652            "1",
1653            "12.011",
1654        ];
1655        let mut line_it = lines.iter().copied();
1656        let err = parse_frame_header(&mut line_it).unwrap_err();
1657
1658        assert!(matches!(err, ParseError::ValidationError(_)));
1659        assert!(err.to_string().contains("cell"));
1660    }
1661
1662    #[test]
1663    fn test_validate_true_rejects_non_physical_cell_geometry() {
1664        let lines = vec![
1665            "PREBOX1",
1666            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
1667            "0.0 20.0 30.0",
1668            "90.0 180.0 90.0",
1669            "POSTBOX1",
1670            "POSTBOX2",
1671            "1",
1672            "1",
1673            "12.011",
1674        ];
1675        let mut line_it = lines.iter().copied();
1676        let err = parse_frame_header(&mut line_it).unwrap_err();
1677
1678        assert!(matches!(err, ParseError::ValidationError(_)));
1679        assert!(err.to_string().contains("cell"));
1680    }
1681
1682    #[test]
1683    fn test_validate_true_rejects_reserved_metadata_type_mismatch() {
1684        let lines = vec![
1685            "PREBOX1",
1686            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"energy\":\"low\"}",
1687            "10.0 20.0 30.0",
1688            "90.0 90.0 90.0",
1689            "POSTBOX1",
1690            "POSTBOX2",
1691            "1",
1692            "1",
1693            "12.011",
1694        ];
1695        let mut line_it = lines.iter().copied();
1696        let err = parse_frame_header(&mut line_it).unwrap_err();
1697
1698        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1699        assert!(err.to_string().contains("energy"));
1700    }
1701
1702    #[test]
1703    fn test_validate_true_rejects_malformed_bonds() {
1704        let lines = vec![
1705            "PREBOX1",
1706            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"bonds\":[[0]]}",
1707            "10.0 20.0 30.0",
1708            "90.0 90.0 90.0",
1709            "POSTBOX1",
1710            "POSTBOX2",
1711            "1",
1712            "1",
1713            "12.011",
1714        ];
1715        let mut line_it = lines.iter().copied();
1716        let err = parse_frame_header(&mut line_it).unwrap_err();
1717        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1718        assert!(err.to_string().contains("bonds"));
1719    }
1720
1721    #[test]
1722    fn test_bonds_metadata_round_trip_in_header() {
1723        use crate::types::{meta, Bond};
1724        let lines = vec![
1725            "PREBOX1",
1726            "{\"con_spec_version\":2,\"bonds\":[[0,1],{\"i\":0,\"j\":2,\"order\":1}]}",
1727            "10.0 20.0 30.0",
1728            "90.0 90.0 90.0",
1729            "POSTBOX1",
1730            "POSTBOX2",
1731            "1",
1732            "1",
1733            "12.011",
1734        ];
1735        let mut line_it = lines.iter().copied();
1736        let header = parse_frame_header(&mut line_it).expect("header");
1737        let bonds = header.bonds();
1738        assert_eq!(bonds.len(), 2);
1739        assert_eq!(bonds[0], Bond::new(0, 1));
1740        assert_eq!(bonds[1].i, 0);
1741        assert_eq!(bonds[1].j, 2);
1742        assert_eq!(bonds[1].order, Some(1));
1743        assert!(header.metadata.contains_key(meta::BONDS));
1744    }
1745
1746    #[test]
1747    fn test_parse_velocity_section_absent() {
1748        let lines = vec![
1749            "PREBOX1",
1750            "{\"con_spec_version\":2}",
1751            "10.0 20.0 30.0",
1752            "90.0 90.0 90.0",
1753            "POSTBOX1",
1754            "POSTBOX2",
1755            "1",
1756            "1",
1757            "12.011",
1758            "C",
1759            "Coordinates of Component 1",
1760            "0.0 0.0 0.0 0.0 1",
1761        ];
1762        let mut line_it = lines.iter().copied().peekable();
1763        let mut frame = parse_single_frame(&mut line_it).expect("parse should succeed");
1764        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
1765            .expect("should succeed with no velocities");
1766        assert!(!has_vel);
1767        assert_eq!(frame.atom_data[0].velocity, None);
1768    }
1769
1770    #[test]
1771    fn test_parse_line_of_range_f64_exact() {
1772        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0 42", 4, 5, &[0.0; 5]).unwrap();
1773        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 42.0]);
1774    }
1775
1776    #[test]
1777    fn test_parse_line_of_range_f64_padded() {
1778        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
1779        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0", 4, 5, &defaults).unwrap();
1780        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 99.0]);
1781    }
1782
1783    #[test]
1784    fn test_parse_line_of_range_f64_too_few() {
1785        let result = parse_line_of_range_f64("1.0 2.0 3.0", 4, 5, &[0.0; 5]);
1786        assert!(result.is_err());
1787    }
1788
1789    #[test]
1790    fn test_parse_line_of_range_f64_too_many() {
1791        let result = parse_line_of_range_f64("1.0 2.0 3.0 0.0 5.0 6.0", 4, 5, &[0.0; 5]);
1792        assert!(result.is_err());
1793    }
1794
1795    #[test]
1796    fn test_parse_all_four_column_lines() {
1797        // All atom lines have only 4 columns; atom_index defaults to sequential.
1798        let lines = vec![
1799            "PREBOX1",
1800            "{\"con_spec_version\":2}",
1801            "10.0 10.0 10.0",
1802            "90.0 90.0 90.0",
1803            "POSTBOX1",
1804            "POSTBOX2",
1805            "1",
1806            "3",
1807            "12.011",
1808            "C",
1809            "Coordinates of Component 1",
1810            "0.0 0.0 0.0 0",
1811            "1.0 0.0 0.0 0",
1812            "2.0 0.0 0.0 1",
1813        ];
1814        let mut line_it = lines.iter().copied();
1815        let frame = parse_single_frame(&mut line_it).unwrap();
1816        assert_eq!(frame.atom_data[0].atom_id, 0);
1817        assert_eq!(frame.atom_data[1].atom_id, 1);
1818        assert_eq!(frame.atom_data[2].atom_id, 2);
1819        assert!(frame.atom_data[2].is_fixed());
1820    }
1821}