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::LATTICE_VECTORS => validate_lattice_vectors_metadata(value)?,
155            meta::CONVERGED if !value.is_boolean() => {
156                return Err(metadata_json_error("converged must be a boolean"));
157            }
158            meta::CONVERGED => {}
159            _ => {}
160        }
161    }
162
163    Ok(())
164}
165
166fn validate_pbc_metadata(value: &Value) -> Result<(), ParseError> {
167    let Some(values) = value.as_array() else {
168        return Err(metadata_json_error("pbc must be a length-3 boolean array"));
169    };
170    if values.len() != 3 || values.iter().any(|entry| !entry.is_boolean()) {
171        return Err(metadata_json_error("pbc must be a length-3 boolean array"));
172    }
173    Ok(())
174}
175
176fn validate_lattice_vectors_metadata(value: &Value) -> Result<(), ParseError> {
177    let Some(rows) = value.as_array() else {
178        return Err(metadata_json_error(
179            "lattice_vectors must be a 3x3 numeric array",
180        ));
181    };
182    if rows.len() != 3 {
183        return Err(metadata_json_error(
184            "lattice_vectors must be a 3x3 numeric array",
185        ));
186    }
187    for row in rows {
188        let Some(entries) = row.as_array() else {
189            return Err(metadata_json_error(
190                "lattice_vectors must be a 3x3 numeric array",
191            ));
192        };
193        if entries.len() != 3 || entries.iter().any(|entry| entry.as_f64().is_none()) {
194            return Err(metadata_json_error(
195                "lattice_vectors must be a 3x3 numeric array",
196            ));
197        }
198    }
199    Ok(())
200}
201
202/// Parses a line of whitespace-separated values into a vector of a specific type.
203///
204/// This generic helper function takes a string slice, splits it by whitespace,
205/// and attempts to parse each substring into the target type `T`. The type `T`
206/// must implement `std::str::FromStr`.
207///
208/// # Arguments
209///
210/// * `line` - A string slice representing a single line of data.
211/// * `n` - The exact number of values expected on the line.
212///
213/// # Errors
214///
215/// * `ParseError::InvalidVectorLength` if the number of parsed values is not equal to `n`.
216/// * Propagates any error from the `parse()` method of the target type `T`.
217///
218/// # Example
219///
220/// ```
221/// use readcon_core::parser::parse_line_of_n;
222/// let line = "10.5 20.0 30.5";
223/// let values: Vec<f64> = parse_line_of_n(line, 3).unwrap();
224/// assert_eq!(values, vec![10.5, 20.0, 30.5]);
225///
226/// let result = parse_line_of_n::<i32>(line, 2);
227/// assert!(result.is_err());
228/// ```
229pub fn parse_line_of_n<T: std::str::FromStr>(line: &str, n: usize) -> Result<Vec<T>, ParseError>
230where
231    ParseError: From<<T as std::str::FromStr>::Err>,
232{
233    let values: Vec<T> = line
234        .split_whitespace()
235        .map(|s| s.parse::<T>())
236        .collect::<Result<_, _>>()?;
237
238    if values.len() == n {
239        Ok(values)
240    } else {
241        Err(ParseError::InvalidVectorLength {
242            expected: n,
243            found: values.len(),
244        })
245    }
246}
247
248/// Parses the 9-line header of a `.con` file frame from an iterator.
249///
250/// This function consumes the next 9 lines from the given line iterator to
251/// construct a `FrameHeader`. The iterator is advanced by 9 lines on success.
252///
253/// # Arguments
254///
255/// * `lines` - A mutable reference to an iterator that yields string slices.
256///
257/// # Errors
258///
259/// * `ParseError::IncompleteHeader` if the iterator has fewer than 9 lines remaining.
260/// * Propagates any errors from `parse_line_of_n` if the numeric data within
261///   the header is malformed.
262///
263/// # Panics
264///
265/// This function will panic if the intermediate vectors for box dimensions or angles,
266/// after being successfully parsed, cannot be converted into fixed-size arrays.
267/// This should not happen if `parse_line_of_n` is used correctly with `n=3`.
268pub fn parse_frame_header<'a>(
269    lines: &mut impl Iterator<Item = &'a str>,
270) -> Result<FrameHeader, ParseError> {
271    let prebox1 = lines
272        .next()
273        .ok_or(ParseError::IncompleteHeader)?
274        .to_string();
275    let prebox2_raw = lines.next().ok_or(ParseError::IncompleteHeader)?;
276
277    // Line 2: if it starts with '{', parse as JSON metadata (spec v2+).
278    // Otherwise treat as a legacy (pre-v2) file with spec_version = 1.
279    let trimmed = prebox2_raw.trim();
280    let (spec_version, metadata, sections, validate, sections_declared) = if trimmed.starts_with('{') {
281        let json_val: serde_json::Value = serde_json::from_str(trimmed)
282            .map_err(|e| ParseError::InvalidMetadataJson(e.to_string()))?;
283        let json_obj = json_val
284            .as_object()
285            .ok_or_else(|| ParseError::InvalidMetadataJson("expected a JSON object".to_string()))?;
286        let ver = json_obj
287            .get(meta::CON_SPEC_VERSION)
288            .and_then(|v| v.as_u64())
289            .ok_or(ParseError::MissingSpecVersion)? as u32;
290        if ver > crate::CON_SPEC_VERSION {
291            return Err(ParseError::UnsupportedSpecVersion(ver));
292        }
293
294        // Single pass over the JSON object: collect sections, capture the
295        // validate flag, copy the rest into metadata. Folds the previous
296        // pre-extract get(validate) + re-iterate pattern into one walk.
297        let mut sections: Vec<String> = Vec::new();
298        let mut metadata = BTreeMap::new();
299        let mut sections_declared = false;
300        let mut validate = false;
301        for (k, v) in json_obj {
302            match k.as_str() {
303                meta::CON_SPEC_VERSION => {}
304                meta::SECTIONS => {
305                    sections_declared = true;
306                    let arr = v.as_array().ok_or_else(|| {
307                        metadata_json_error("sections must be an array of strings")
308                    })?;
309                    sections.reserve(arr.len());
310                    for entry in arr {
311                        let s = entry.as_str().ok_or_else(|| {
312                            metadata_json_error("sections must be an array of strings")
313                        })?;
314                        sections.push(s.to_string());
315                    }
316                }
317                meta::VALIDATE => {
318                    validate = match v {
319                        Value::Bool(b) => *b,
320                        _ => return Err(metadata_json_error("validate must be a boolean")),
321                    };
322                    metadata.insert(k.clone(), v.clone());
323                }
324                _ => {
325                    metadata.insert(k.clone(), v.clone());
326                }
327            }
328        }
329
330        // Strict-mode schema check fires only when the file requested
331        // it. Hot-path parses (validate=false) skip the per-key match.
332        if validate {
333            validate_metadata_schema(json_obj)?;
334        }
335
336        (ver, metadata, sections, validate, sections_declared)
337    } else {
338        // Legacy file: no JSON metadata line.
339        (1_u32, BTreeMap::new(), Vec::new(), false, false)
340    };
341    let prebox2 = prebox2_raw.to_string();
342
343    let boxl_vec = parse_line_of_n_f64(lines.next().ok_or(ParseError::IncompleteHeader)?, 3)?;
344    let angles_vec = parse_line_of_n_f64(lines.next().ok_or(ParseError::IncompleteHeader)?, 3)?;
345    let postbox1 = lines
346        .next()
347        .ok_or(ParseError::IncompleteHeader)?
348        .to_string();
349    let postbox2 = lines
350        .next()
351        .ok_or(ParseError::IncompleteHeader)?
352        .to_string();
353    let natm_types =
354        parse_line_of_n::<usize>(lines.next().ok_or(ParseError::IncompleteHeader)?, 1)?[0];
355    let natms_per_type = parse_line_of_n::<usize>(
356        lines.next().ok_or(ParseError::IncompleteHeader)?,
357        natm_types,
358    )?;
359    let masses_per_type = parse_line_of_n_f64(
360        lines.next().ok_or(ParseError::IncompleteHeader)?,
361        natm_types,
362    )?;
363    if validate {
364        validate_header_geometry(&boxl_vec, &angles_vec, natm_types, &natms_per_type)?;
365        validate_masses(&masses_per_type)?;
366    }
367    Ok(FrameHeader {
368        prebox_header: PreboxHeader {
369            user: prebox1,
370            metadata_line: prebox2,
371        },
372        boxl: boxl_vec.try_into().unwrap(),
373        angles: angles_vec.try_into().unwrap(),
374        postbox_header: [postbox1, postbox2],
375        natm_types,
376        natms_per_type,
377        masses_per_type,
378        spec_version,
379        metadata,
380        sections,
381        strict_validation: validate,
382        sections_declared,
383    })
384}
385
386/// Parses a complete frame from a `.con` file, including its header and atomic data.
387///
388/// This function first parses the complete frame header and then uses the information within it
389/// (specifically the number of atom types and atoms per type) to parse the subsequent
390/// atom coordinate blocks.
391///
392/// # Arguments
393///
394/// * `lines` - A mutable reference to an iterator that yields string slices for the frame.
395///
396/// # Errors
397///
398/// * `ParseError::IncompleteFrame` if the iterator ends before all expected
399///   atomic data has been read.
400/// * Propagates any errors from the underlying calls to `parse_frame_header` and
401///   `parse_line_of_n`.
402///
403/// # Example
404///
405/// ```
406/// use readcon_core::parser::parse_single_frame;
407///
408/// let frame_text = r#"
409///Generated by test
410///{"con_spec_version":2}
411///10.0 10.0 10.0
412///90.0 90.0 90.0
413///POSTBOX LINE 1
414///POSTBOX LINE 2
415///2
416///1 1
417///12.011 1.008
418///C
419///Coordinates of Component 1
420///1.0 1.0 1.0 0.0 1
421///H
422///Coordinates of Component 2
423///2.0 2.0 2.0 0.0 2
424/// "#;
425///
426/// let mut lines = frame_text.trim().lines();
427/// let con_frame = parse_single_frame(&mut lines).unwrap();
428///
429/// assert_eq!(con_frame.header.natm_types, 2);
430/// assert_eq!(con_frame.atom_data.len(), 2);
431/// assert_eq!(&*con_frame.atom_data[0].symbol, "C");
432/// assert_eq!(con_frame.atom_data[1].atom_id, 2);
433/// ```
434pub fn parse_single_frame<'a>(
435    lines: &mut impl Iterator<Item = &'a str>,
436) -> Result<ConFrame, ParseError> {
437    let header = parse_frame_header(lines)?;
438    let validate = header.strict_validation;
439    let total_atoms: usize = header.natms_per_type.iter().sum();
440    let mut atom_data = Vec::with_capacity(total_atoms);
441
442    let mut global_atom_idx: u64 = 0;
443    for (type_idx, num_atoms) in header.natms_per_type.iter().enumerate() {
444        // Allocate the per-component Arc<str> directly from the trimmed
445        // line; going through a String intermediate would add a second
446        // allocation and copy for no semantic gain.
447        let symbol_line = lines.next().ok_or(ParseError::IncompleteFrame)?;
448        let symbol: Arc<str> = Arc::from(symbol_line.trim());
449        let coord_label = lines.next().ok_or(ParseError::IncompleteFrame)?;
450        if validate {
451            validate_coordinate_component(type_idx, symbol.as_ref(), coord_label)?;
452        }
453        for _ in 0..*num_atoms {
454            let coord_line = lines.next().ok_or(ParseError::IncompleteFrame)?;
455            // Column 5 (atom_index) is optional; defaults to sequential index.
456            let defaults = [0.0, 0.0, 0.0, 0.0, global_atom_idx as f64];
457            let vals = parse_line_of_range_f64(coord_line, 4, 5, &defaults)?;
458            let (fixed, atom_id) = if validate {
459                parse_identity_columns(coord_line, "coordinate", 3, 4, 5)?
460            } else {
461                (decode_fixed_bitmask(vals[3] as u8), vals[4] as u64)
462            };
463            atom_data.push(AtomDatum {
464                // This is a cheap reference-count increment, not a full string clone.
465                symbol: Arc::clone(&symbol),
466                x: vals[0],
467                y: vals[1],
468                z: vals[2],
469                fixed,
470                atom_id,
471                velocity: None,
472                force: None,
473                energy: None,
474            });
475            global_atom_idx += 1;
476        }
477    }
478    Ok(ConFrame { header, atom_data })
479}
480
481fn validate_header_geometry(
482    boxl: &[f64],
483    angles: &[f64],
484    natm_types: usize,
485    natms_per_type: &[usize],
486) -> Result<(), ParseError> {
487    if boxl.iter().any(|length| !length.is_finite() || *length <= 0.0)
488        || angles
489            .iter()
490            .any(|angle| !angle.is_finite() || *angle <= 0.0 || *angle >= 180.0)
491    {
492        return Err(ParseError::ValidationError(
493            "cell geometry must have positive lengths and angles between 0 and 180 degrees"
494                .to_string(),
495        ));
496    }
497    if natm_types == 0 || natms_per_type.contains(&0) {
498        return Err(ParseError::ValidationError(
499            "atom counts must contain at least one atom per component".to_string(),
500        ));
501    }
502    Ok(())
503}
504
505fn validate_masses(masses_per_type: &[f64]) -> Result<(), ParseError> {
506    if masses_per_type
507        .iter()
508        .any(|mass| !mass.is_finite() || *mass <= 0.0)
509    {
510        return Err(ParseError::ValidationError(
511            "component masses must be positive".to_string(),
512        ));
513    }
514    Ok(())
515}
516
517fn validate_coordinate_component(
518    type_idx: usize,
519    symbol: &str,
520    label: &str,
521) -> Result<(), ParseError> {
522    let expected_label = format!("Coordinates of Component {}", type_idx + 1);
523    if label.trim() != expected_label {
524        return Err(ParseError::ValidationError(format!(
525            "expected coordinate label {expected_label:?}, found {label:?}"
526        )));
527    }
528    if symbol != "X" && symbol_to_atomic_number(symbol) == 0 {
529        return Err(ParseError::ValidationError(format!(
530            "unknown component symbol {symbol}"
531        )));
532    }
533    Ok(())
534}
535
536/// Strict-validation parser for the per-row identity columns
537/// (fixed bitmask + atom_id) used by every section type.
538///
539/// `n_cols` is the total whitespace-separated column count expected on
540/// the row in strict mode, and `(fixed_idx, atom_id_idx)` are the
541/// 0-based positions of the fixed bitmask and atom_id columns inside
542/// that layout. Each section calls in with its own values:
543///
544/// - coordinates / velocities / forces: 5 cols, fixed=3, atom_id=4
545/// - energies: 3 cols, fixed=1, atom_id=2
546///
547/// String-based parsing on purpose: strict v2 mode rejects values that
548/// are not in the canonical integer form (e.g. `5.0` for a bitmask),
549/// which an f64 round-trip would silently accept.
550fn parse_identity_columns(
551    line: &str,
552    row_kind: &str,
553    fixed_idx: usize,
554    atom_id_idx: usize,
555    n_cols: usize,
556) -> Result<([bool; 3], u64), ParseError> {
557    let columns = line.split_ascii_whitespace().collect::<Vec<_>>();
558    if columns.len() != n_cols {
559        return Err(ParseError::ValidationError(format!(
560            "{row_kind} rows require {n_cols} columns including fixed_flag and atom_id in validate mode"
561        )));
562    }
563    let fixed_flag = columns[fixed_idx].parse::<u8>().map_err(|_| {
564        ParseError::ValidationError(format!("{row_kind} fixed_flag must be an integer bitmask"))
565    })?;
566    if fixed_flag > 7 {
567        return Err(ParseError::ValidationError(format!(
568            "{row_kind} fixed_flag must be between 0 and 7"
569        )));
570    }
571    let atom_id = columns[atom_id_idx].parse::<u64>().map_err(|_| {
572        ParseError::ValidationError(format!("{row_kind} atom_id must be an integer"))
573    })?;
574    Ok((decode_fixed_bitmask(fixed_flag), atom_id))
575}
576
577
578fn validate_section_component(
579    section: &str,
580    type_idx: usize,
581    atom_idx: usize,
582    symbol: &str,
583    label: &str,
584    header: &FrameHeader,
585    atom_data: &[AtomDatum],
586) -> Result<(), ParseError> {
587    let expected_label = format!("{section} of Component {}", type_idx + 1);
588    if label.trim() != expected_label {
589        return Err(ParseError::ValidationError(format!(
590            "expected section label {expected_label:?}, found {label:?}"
591        )));
592    }
593
594    if header.natms_per_type[type_idx] == 0 {
595        return Ok(());
596    }
597
598    let expected_symbol = atom_data
599        .get(atom_idx)
600        .map(|atom| atom.symbol.as_ref())
601        .ok_or_else(|| {
602            ParseError::ValidationError(format!(
603                "{section} component {} has no coordinate atom to validate against",
604                type_idx + 1
605            ))
606        })?;
607    if symbol != expected_symbol {
608        return Err(ParseError::ValidationError(format!(
609            "{section} component {} symbol mismatch: expected {expected_symbol}, found {symbol}",
610            type_idx + 1
611        )));
612    }
613
614    Ok(())
615}
616
617fn validate_section_atom_identity(
618    section: &str,
619    atom_idx: usize,
620    fixed: [bool; 3],
621    atom_id: u64,
622    atom_data: &[AtomDatum],
623) -> Result<(), ParseError> {
624    let atom = atom_data.get(atom_idx).ok_or_else(|| {
625        ParseError::ValidationError(format!(
626            "{section} row {atom_idx} has no coordinate atom to validate against"
627        ))
628    })?;
629
630    if atom.fixed != fixed {
631        return Err(ParseError::ValidationError(format!(
632            "{section} row {atom_idx} fixed mask mismatch for atom_id {}",
633            atom.atom_id
634        )));
635    }
636    if atom.atom_id != atom_id {
637        return Err(ParseError::ValidationError(format!(
638            "{section} row {atom_idx} atom_id mismatch: expected {}, found {atom_id}",
639            atom.atom_id
640        )));
641    }
642
643    Ok(())
644}
645
646/// Attempts to parse an optional velocity section following coordinate blocks.
647///
648/// In `.convel` files, after all coordinate blocks there is a blank separator line
649/// followed by per-component velocity blocks with the same structure as coordinate
650/// blocks (symbol line, "Velocities of Component N" line, then atom lines with
651/// `vx vy vz fixed atomID`).
652///
653/// This function peeks at the next line. If it is blank (or contains only whitespace),
654/// it consumes the blank line and parses velocity data into the existing `atom_data`.
655/// If the next line is not blank (or is absent), no velocities are parsed.
656///
657/// Returns `Ok(true)` if velocities were found and parsed, `Ok(false)` otherwise.
658pub fn parse_velocity_section<'a, I>(
659    lines: &mut Peekable<I>,
660    header: &FrameHeader,
661    atom_data: &mut [AtomDatum],
662) -> Result<bool, ParseError>
663where
664    I: Iterator<Item = &'a str>,
665{
666    let validate = header.strict_validation;
667    // Peek at the next line to check for blank separator
668    match lines.peek() {
669        Some(line) if line.trim().is_empty() => {
670            // Consume the blank separator
671            lines.next();
672        }
673        _ => return Ok(false),
674    }
675
676    let mut atom_idx: usize = 0;
677    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
678        // Symbol line
679        let symbol = lines
680            .next()
681            .ok_or(ParseError::IncompleteVelocitySection)?
682            .trim();
683
684        // "Velocities of Component N" line
685        let comp_line = lines.next().ok_or(ParseError::IncompleteVelocitySection)?;
686        // Validate it looks like a velocity header (optional strictness)
687        if !comp_line.contains("Velocities of Component") {
688            return Err(ParseError::IncompleteVelocitySection);
689        }
690        if validate {
691            validate_section_component(
692                "Velocities",
693                type_idx,
694                atom_idx,
695                symbol,
696                comp_line,
697                header,
698                atom_data,
699            )?;
700        }
701
702        for _ in 0..num_atoms {
703            let vel_line = lines.next().ok_or(ParseError::IncompleteVelocitySection)?;
704            // Column 5 (atom_index) is optional in velocity lines too.
705            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
706            let vals = parse_line_of_range_f64(vel_line, 4, 5, &defaults)?;
707            if validate {
708                let (fixed, atom_id) =
709                    parse_identity_columns(vel_line, "velocities", 3, 4, 5)?;
710                validate_section_atom_identity("velocities", atom_idx, fixed, atom_id, atom_data)?;
711            }
712            if atom_idx < atom_data.len() {
713                atom_data[atom_idx].velocity = Some([vals[0], vals[1], vals[2]]);
714            }
715            atom_idx += 1;
716        }
717    }
718
719    Ok(true)
720}
721
722/// Attempts to parse a force section following coordinate (and optional velocity) blocks.
723///
724/// Force sections mirror velocity sections: a blank separator line followed by per-component
725/// force blocks (symbol line, "Forces of Component N" line, then atom lines with
726/// `fx fy fz fixed_flag atom_id`).
727///
728/// Returns `Ok(true)` if forces were found and parsed, `Ok(false)` otherwise.
729pub fn parse_force_section<'a, I>(
730    lines: &mut Peekable<I>,
731    header: &FrameHeader,
732    atom_data: &mut [AtomDatum],
733) -> Result<bool, ParseError>
734where
735    I: Iterator<Item = &'a str>,
736{
737    let validate = header.strict_validation;
738    // Peek at the next line to check for blank separator
739    match lines.peek() {
740        Some(line) if line.trim().is_empty() => {
741            lines.next();
742        }
743        _ => return Ok(false),
744    }
745
746    let mut atom_idx: usize = 0;
747    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
748        let symbol = lines
749            .next()
750            .ok_or(ParseError::IncompleteForceSection)?
751            .trim();
752
753        let comp_line = lines.next().ok_or(ParseError::IncompleteForceSection)?;
754        if !comp_line.contains("Forces of Component") {
755            return Err(ParseError::IncompleteForceSection);
756        }
757        if validate {
758            validate_section_component(
759                "Forces", type_idx, atom_idx, symbol, comp_line, header, atom_data,
760            )?;
761        }
762
763        for _ in 0..num_atoms {
764            let force_line = lines.next().ok_or(ParseError::IncompleteForceSection)?;
765            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
766            let vals = parse_line_of_range_f64(force_line, 4, 5, &defaults)?;
767            if validate {
768                let (fixed, atom_id) =
769                    parse_identity_columns(force_line, "forces", 3, 4, 5)?;
770                validate_section_atom_identity("forces", atom_idx, fixed, atom_id, atom_data)?;
771            }
772            if atom_idx < atom_data.len() {
773                atom_data[atom_idx].force = Some([vals[0], vals[1], vals[2]]);
774            }
775            atom_idx += 1;
776        }
777    }
778
779    Ok(true)
780}
781
782/// Attempts to parse an energies section following coordinate (and optional
783/// velocity / force) blocks.
784///
785/// Energy sections mirror force sections but with one scalar per atom:
786/// blank separator, then per-component blocks of (symbol, "Energies of
787/// Component N", and atom lines `e fixed_flag atom_id`). The two
788/// trailing identity columns are optional and used only for strict
789/// validation; in non-strict mode any whitespace after the energy is
790/// ignored.
791///
792/// Returns `Ok(true)` if energies were found and parsed, `Ok(false)`
793/// otherwise.
794pub fn parse_energy_section<'a, I>(
795    lines: &mut Peekable<I>,
796    header: &FrameHeader,
797    atom_data: &mut [AtomDatum],
798) -> Result<bool, ParseError>
799where
800    I: Iterator<Item = &'a str>,
801{
802    let validate = header.strict_validation;
803    match lines.peek() {
804        Some(line) if line.trim().is_empty() => {
805            lines.next();
806        }
807        _ => return Ok(false),
808    }
809
810    let mut atom_idx: usize = 0;
811    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
812        let symbol = lines
813            .next()
814            .ok_or(ParseError::IncompleteEnergySection)?
815            .trim();
816
817        let comp_line = lines.next().ok_or(ParseError::IncompleteEnergySection)?;
818        if !comp_line.contains("Energies of Component") {
819            return Err(ParseError::IncompleteEnergySection);
820        }
821        if validate {
822            validate_section_component(
823                "Energies", type_idx, atom_idx, symbol, comp_line, header, atom_data,
824            )?;
825        }
826
827        for _ in 0..num_atoms {
828            let energy_line = lines.next().ok_or(ParseError::IncompleteEnergySection)?;
829            // Single energy column, plus optional fixed flag and atom_id
830            // for round-trip identity checks.
831            let defaults = [0.0, 0.0, atom_idx as f64];
832            let vals = parse_line_of_range_f64(energy_line, 1, 3, &defaults)?;
833            if validate {
834                let (fixed, atom_id) =
835                    parse_identity_columns(energy_line, "energies", 1, 2, 3)?;
836                validate_section_atom_identity("energies", atom_idx, fixed, atom_id, atom_data)?;
837            }
838            if atom_idx < atom_data.len() {
839                atom_data[atom_idx].energy = Some(vals[0]);
840            }
841            atom_idx += 1;
842        }
843    }
844
845    Ok(true)
846}
847
848/// Parses declared sections from a frame's header metadata.
849///
850/// If `header.sections` is non-empty (v2 file with `"sections"` key in JSON),
851/// parses each declared section in order. Otherwise falls back to legacy
852/// blank-separator velocity detection.
853pub fn parse_declared_sections<'a, I>(
854    lines: &mut Peekable<I>,
855    header: &mut FrameHeader,
856    atom_data: &mut [AtomDatum],
857) -> Result<(), ParseError>
858where
859    I: Iterator<Item = &'a str>,
860{
861    if !header.sections_declared && header.sections.is_empty() {
862        // Legacy: try velocity detection via blank separator
863        let found = parse_velocity_section(lines, header, atom_data)?;
864        if found {
865            header.sections.push(SECTION_VELOCITIES.into());
866        }
867    } else {
868        let sections = std::mem::take(&mut header.sections);
869        for section in &sections {
870            match section.as_str() {
871                SECTION_VELOCITIES => {
872                    let found = parse_velocity_section(lines, header, atom_data)?;
873                    if !found {
874                        return Err(ParseError::IncompleteVelocitySection);
875                    }
876                }
877                SECTION_FORCES => {
878                    let found = parse_force_section(lines, header, atom_data)?;
879                    if !found {
880                        return Err(ParseError::IncompleteForceSection);
881                    }
882                }
883                SECTION_ENERGIES => {
884                    let found = parse_energy_section(lines, header, atom_data)?;
885                    if !found {
886                        return Err(ParseError::IncompleteEnergySection);
887                    }
888                }
889                other => return Err(ParseError::UnknownSection(other.to_string())),
890            }
891        }
892        header.sections = sections;
893    }
894    Ok(())
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use crate::iterators::ConFrameIterator;
901
902    #[test]
903    fn test_parse_line_of_n_success() {
904        let line = "1.0 2.5 -3.0";
905        let values = parse_line_of_n::<f64>(line, 3).unwrap();
906        assert_eq!(values, vec![1.0, 2.5, -3.0]);
907    }
908
909    #[test]
910    fn test_parse_line_of_n_too_short() {
911        let line = "1.0 2.5";
912        let result = parse_line_of_n::<f64>(line, 3);
913        assert!(result.is_err());
914        assert!(matches!(
915            result.unwrap_err(),
916            ParseError::InvalidVectorLength {
917                expected: 3,
918                found: 2
919            }
920        ));
921    }
922
923    #[test]
924    fn test_parse_line_of_n_too_long() {
925        let line = "1.0 2.5 -3.0 4.0";
926        let result = parse_line_of_n::<f64>(line, 3);
927        assert!(result.is_err());
928        assert!(matches!(
929            result.unwrap_err(),
930            ParseError::InvalidVectorLength {
931                expected: 3,
932                found: 4
933            }
934        ));
935    }
936
937    #[test]
938    fn test_parse_line_of_n_invalid_float() {
939        let line = "1.0 abc -3.0";
940        let result = parse_line_of_n::<f64>(line, 3);
941        assert!(result.is_err());
942        assert!(matches!(
943            result.unwrap_err(),
944            ParseError::InvalidNumberFormat(_)
945        ));
946    }
947
948    #[test]
949    fn test_parse_frame_header_success() {
950        let lines = [
951            "PREBOX1",
952            "{\"con_spec_version\":2}",
953            "10.0 20.0 30.0",
954            "90.0 90.0 90.0",
955            "POSTBOX1",
956            "POSTBOX2",
957            "2",
958            "1 1",
959            "12.011 1.008",
960        ];
961        let mut line_it = lines.iter().copied();
962        match parse_frame_header(&mut line_it) {
963            Ok(header) => {
964                assert_eq!(header.prebox_header.user, "PREBOX1");
965                assert_eq!(header.spec_version, 2);
966                assert_eq!(header.boxl, [10.0, 20.0, 30.0]);
967                assert_eq!(header.angles, [90.0, 90.0, 90.0]);
968                assert_eq!(header.postbox_header, ["POSTBOX1", "POSTBOX2"]);
969                assert_eq!(header.natm_types, 2);
970                assert_eq!(header.natms_per_type, vec![1, 1]);
971                assert_eq!(header.masses_per_type, vec![12.011, 1.008]);
972            }
973            Err(e) => {
974                panic!(
975                    "Parsing failed when it should have succeeded. Error: {:?}",
976                    e
977                );
978            }
979        }
980    }
981
982    #[test]
983    fn test_parse_frame_header_missing_line() {
984        let lines = [
985            "PREBOX1",
986            "{\"con_spec_version\":2}",
987            "10.0 20.0 30.0",
988            "90.0 90.0 90.0",
989            "POSTBOX1",
990            "POSTBOX2",
991            "2",
992            "1 1",
993            // Missing masses_per_type
994        ];
995        let mut line_it = lines.iter().copied();
996        let result = parse_frame_header(&mut line_it);
997        assert!(result.is_err());
998        assert!(matches!(result.unwrap_err(), ParseError::IncompleteHeader));
999    }
1000
1001    #[test]
1002    fn test_parse_frame_header_missing_spec_version() {
1003        let lines = vec![
1004            "PREBOX1",
1005            "{}",
1006            "10.0 20.0 30.0",
1007            "90.0 90.0 90.0",
1008            "POSTBOX1",
1009            "POSTBOX2",
1010            "2",
1011            "1 1",
1012            "12.011 1.008",
1013        ];
1014        let mut line_it = lines.iter().copied();
1015        let result = parse_frame_header(&mut line_it);
1016        assert!(result.is_err());
1017        assert!(matches!(
1018            result.unwrap_err(),
1019            ParseError::MissingSpecVersion
1020        ));
1021    }
1022
1023    #[test]
1024    fn test_parse_frame_header_legacy_no_json() {
1025        // A non-JSON line 2 is treated as a legacy (v1) file.
1026        let lines = vec![
1027            "PREBOX1",
1028            "0.0000 TIME",
1029            "10.0 20.0 30.0",
1030            "90.0 90.0 90.0",
1031            "POSTBOX1",
1032            "POSTBOX2",
1033            "2",
1034            "1 1",
1035            "12.011 1.008",
1036        ];
1037        let mut line_it = lines.iter().copied();
1038        let header = parse_frame_header(&mut line_it).unwrap();
1039        assert_eq!(header.spec_version, 1);
1040        assert!(header.metadata.is_empty());
1041    }
1042
1043    #[test]
1044    fn test_parse_frame_header_malformed_json() {
1045        // Line 2 starts with '{' but is not valid JSON -- this IS an error.
1046        let lines = vec![
1047            "PREBOX1",
1048            "{broken json",
1049            "10.0 20.0 30.0",
1050            "90.0 90.0 90.0",
1051            "POSTBOX1",
1052            "POSTBOX2",
1053            "2",
1054            "1 1",
1055            "12.011 1.008",
1056        ];
1057        let mut line_it = lines.iter().copied();
1058        let result = parse_frame_header(&mut line_it);
1059        assert!(result.is_err());
1060        assert!(matches!(
1061            result.unwrap_err(),
1062            ParseError::InvalidMetadataJson(_)
1063        ));
1064    }
1065
1066    #[test]
1067    fn test_parse_frame_header_unsupported_version() {
1068        let lines = vec![
1069            "PREBOX1",
1070            "{\"con_spec_version\":999}",
1071            "10.0 20.0 30.0",
1072            "90.0 90.0 90.0",
1073            "POSTBOX1",
1074            "POSTBOX2",
1075            "2",
1076            "1 1",
1077            "12.011 1.008",
1078        ];
1079        let mut line_it = lines.iter().copied();
1080        let result = parse_frame_header(&mut line_it);
1081        assert!(result.is_err());
1082        assert!(matches!(
1083            result.unwrap_err(),
1084            ParseError::UnsupportedSpecVersion(999)
1085        ));
1086    }
1087
1088    #[test]
1089    fn test_parse_frame_header_extra_metadata_preserved() {
1090        let lines = vec![
1091            "PREBOX1",
1092            "{\"con_spec_version\":2,\"generator\":\"test\"}",
1093            "10.0 20.0 30.0",
1094            "90.0 90.0 90.0",
1095            "POSTBOX1",
1096            "POSTBOX2",
1097            "2",
1098            "1 1",
1099            "12.011 1.008",
1100        ];
1101        let mut line_it = lines.iter().copied();
1102        let header = parse_frame_header(&mut line_it).unwrap();
1103        assert_eq!(header.spec_version, 2);
1104        assert_eq!(
1105            header.metadata.get("generator"),
1106            Some(&serde_json::Value::String("test".to_string()))
1107        );
1108    }
1109
1110    #[test]
1111    fn test_parse_frame_header_invalid_natms_per_type() {
1112        let lines = vec![
1113            "PREBOX1",
1114            "{\"con_spec_version\":2}",
1115            "10.0 20.0 30.0",
1116            "90.0 90.0 90.0",
1117            "POSTBOX1",
1118            "POSTBOX2",
1119            "2",
1120            "1 1 1", // 3 values, but natm_types is 2
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::InvalidVectorLength {
1129                expected: 2,
1130                found: 3
1131            }
1132        ));
1133    }
1134
1135    #[test]
1136    fn test_parse_single_frame_success() {
1137        let lines = vec![
1138            "PREBOX1",
1139            "{\"con_spec_version\":2}",
1140            "10.0 20.0 30.0",
1141            "90.0 90.0 90.0",
1142            "POSTBOX1",
1143            "POSTBOX2",
1144            "2",
1145            "3 3",
1146            "12.011 1.008",
1147            "1",
1148            "Coordinates of Component 1",
1149            "0.0 0.0 0.0 0.0 1",
1150            "1.0940 0.0 0.0 0.0 2",
1151            "-0.5470 0.9499 0.0 0.0 3",
1152            "2",
1153            "Coordinates of Component 2",
1154            "5.0 5.0 5.0 0.0 4",
1155            "6.0940 5.0 5.0 0.0 5",
1156            "5.5470 5.9499 5.0 0.0 6",
1157        ];
1158        let mut line_it = lines.iter().copied();
1159        let frame = parse_single_frame(&mut line_it).unwrap();
1160
1161        assert_eq!(frame.header.natm_types, 2);
1162        assert_eq!(frame.header.natms_per_type, vec![3, 3]);
1163        assert_eq!(frame.header.masses_per_type, vec![12.011, 1.008]);
1164        assert_eq!(frame.atom_data.len(), 6);
1165        assert_eq!(&*frame.atom_data[0].symbol, "1");
1166        assert_eq!(frame.atom_data[0].atom_id, 1);
1167        assert_eq!(&*frame.atom_data[5].symbol, "2");
1168        assert_eq!(frame.atom_data[5].atom_id, 6);
1169    }
1170
1171    #[test]
1172    fn test_parse_single_frame_missing_line() {
1173        // With a valid header but truncated atom data, we get IncompleteFrame.
1174        let lines = vec![
1175            "PREBOX1",
1176            "{\"con_spec_version\":2}",
1177            "10.0 20.0 30.0",
1178            "90.0 90.0 90.0",
1179            "POSTBOX1",
1180            "POSTBOX2",
1181            "2",
1182            "3 3",
1183            "12.011 1.008",
1184            "1",
1185            "Coordinates of Component 1",
1186            "0.0 0.0 0.0 0.0 1",
1187            "1.0940 0.0 0.0 0.0 2",
1188            "-0.5470 0.9499 0.0 0.0 3",
1189            // Missing Component 2 entirely
1190        ];
1191        let mut line_it = lines.iter().copied();
1192        let result = parse_single_frame(&mut line_it);
1193        assert!(result.is_err());
1194        assert!(matches!(result.unwrap_err(), ParseError::IncompleteFrame));
1195    }
1196
1197    #[test]
1198    fn test_parse_single_frame_missing_atom_index_defaults_sequential() {
1199        // Column 5 (atom_index) is optional; when absent, defaults to sequential.
1200        let lines = vec![
1201            "PREBOX1",
1202            "{\"con_spec_version\":2}",
1203            "10.0 20.0 30.0",
1204            "90.0 90.0 90.0",
1205            "POSTBOX1",
1206            "POSTBOX2",
1207            "2",
1208            "3 3",
1209            "12.011 1.008",
1210            "1",
1211            "Coordinates of Component 1",
1212            "0.0 0.0 0.0 0.0 1",
1213            "1.0940 0.0 0.0 0.0 2",
1214            "-0.5470 0.9499 0.0 0.0 3",
1215            "2",
1216            "Coordinates of Component 2",
1217            "5.0 5.0 5.0 0.0",       // No atom_index: defaults to 3
1218            "6.0940 5.0 5.0 0.0 10", // Explicit atom_index: 10
1219            "5.5470 5.9499 5.0 0.0", // No atom_index: defaults to 5
1220        ];
1221        let mut line_it = lines.iter().copied();
1222        let frame = parse_single_frame(&mut line_it).unwrap();
1223        assert_eq!(frame.atom_data.len(), 6);
1224        // First type: explicit atom_index values
1225        assert_eq!(frame.atom_data[0].atom_id, 1);
1226        assert_eq!(frame.atom_data[1].atom_id, 2);
1227        assert_eq!(frame.atom_data[2].atom_id, 3);
1228        // Second type: mixed explicit and defaulted
1229        assert_eq!(frame.atom_data[3].atom_id, 3); // defaulted (global idx 3)
1230        assert_eq!(frame.atom_data[4].atom_id, 10); // explicit
1231        assert_eq!(frame.atom_data[5].atom_id, 5); // defaulted (global idx 5)
1232    }
1233
1234    #[test]
1235    fn test_parse_single_frame_too_few_columns_fails() {
1236        // Only 3 columns (missing fixed_flag too) should still fail.
1237        let lines = vec![
1238            "PREBOX1",
1239            "{\"con_spec_version\":2}",
1240            "10.0 20.0 30.0",
1241            "90.0 90.0 90.0",
1242            "POSTBOX1",
1243            "POSTBOX2",
1244            "1",
1245            "1",
1246            "12.011",
1247            "C",
1248            "Coordinates of Component 1",
1249            "0.0 0.0 0.0", // Only 3 values
1250        ];
1251        let mut line_it = lines.iter().copied();
1252        let result = parse_single_frame(&mut line_it);
1253        assert!(result.is_err());
1254        assert!(matches!(
1255            result.unwrap_err(),
1256            ParseError::InvalidVectorLength {
1257                expected: 5,
1258                found: 3
1259            }
1260        ));
1261    }
1262
1263    #[test]
1264    fn test_parse_velocity_section_present() {
1265        let lines = vec![
1266            "PREBOX1",
1267            "{\"con_spec_version\":2}",
1268            "10.0 20.0 30.0",
1269            "90.0 90.0 90.0",
1270            "POSTBOX1",
1271            "POSTBOX2",
1272            "2",
1273            "1 1",
1274            "63.546 1.008",
1275            "Cu",
1276            "Coordinates of Component 1",
1277            "0.0 0.0 0.0 1.0 0",
1278            "H",
1279            "Coordinates of Component 2",
1280            "1.0 2.0 3.0 0.0 1",
1281            "",
1282            "Cu",
1283            "Velocities of Component 1",
1284            "0.1 0.2 0.3 1.0 0",
1285            "H",
1286            "Velocities of Component 2",
1287            "0.4 0.5 0.6 0.0 1",
1288        ];
1289        let mut line_it = lines.iter().copied().peekable();
1290        // Parse the frame first (consuming 15 lines)
1291        let mut frame =
1292            parse_single_frame(&mut line_it).expect("coordinate parsing should succeed");
1293        assert!(!frame.has_velocities());
1294
1295        // Now parse the velocity section
1296        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
1297            .expect("velocity parsing should succeed");
1298        assert!(has_vel);
1299        assert_eq!(frame.atom_data[0].velocity, Some([0.1, 0.2, 0.3]));
1300        assert_eq!(frame.atom_data[1].velocity, Some([0.4, 0.5, 0.6]));
1301    }
1302
1303    #[test]
1304    fn test_validate_true_accepts_matching_section_identity() {
1305        let text = r#"
1306PREBOX1
1307{"con_spec_version":2,"sections":["velocities"],"validate":true}
130810.0 20.0 30.0
130990.0 90.0 90.0
1310POSTBOX1
1311POSTBOX2
13122
13131 1
131463.546 1.008
1315Cu
1316Coordinates of Component 1
13170.0 0.0 0.0 5 0
1318H
1319Coordinates of Component 2
13201.0 2.0 3.0 0 1
1321
1322Cu
1323Velocities of Component 1
13240.1 0.2 0.3 5 0
1325H
1326Velocities of Component 2
13270.4 0.5 0.6 0 1
1328"#;
1329        let mut iter = ConFrameIterator::new(text.trim());
1330        let frame = iter.next().unwrap().unwrap();
1331
1332        assert!(frame.has_velocities());
1333        assert_eq!(
1334            frame
1335                .header
1336                .metadata
1337                .get("validate")
1338                .and_then(|v| v.as_bool()),
1339            Some(true)
1340        );
1341    }
1342
1343    #[test]
1344    fn test_validate_true_rejects_section_atom_id_mismatch() {
1345        let text = r#"
1346PREBOX1
1347{"con_spec_version":2,"sections":["velocities"],"validate":true}
134810.0 20.0 30.0
134990.0 90.0 90.0
1350POSTBOX1
1351POSTBOX2
13521
13531
135463.546
1355Cu
1356Coordinates of Component 1
13570.0 0.0 0.0 5 0
1358
1359Cu
1360Velocities of Component 1
13610.1 0.2 0.3 5 99
1362"#;
1363        let mut iter = ConFrameIterator::new(text.trim());
1364        let err = iter.next().unwrap().unwrap_err();
1365
1366        assert!(matches!(err, ParseError::ValidationError(_)));
1367        assert!(err.to_string().contains("atom_id mismatch"));
1368    }
1369
1370    #[test]
1371    fn test_validate_true_rejects_section_symbol_mismatch() {
1372        let text = r#"
1373PREBOX1
1374{"con_spec_version":2,"sections":["forces"],"validate":true}
137510.0 20.0 30.0
137690.0 90.0 90.0
1377POSTBOX1
1378POSTBOX2
13791
13801
138163.546
1382Cu
1383Coordinates of Component 1
13840.0 0.0 0.0 5 0
1385
1386H
1387Forces of Component 1
13880.1 0.2 0.3 5 0
1389"#;
1390        let mut iter = ConFrameIterator::new(text.trim());
1391        let err = iter.next().unwrap().unwrap_err();
1392
1393        assert!(matches!(err, ParseError::ValidationError(_)));
1394        assert!(err.to_string().contains("symbol mismatch"));
1395    }
1396
1397    #[test]
1398    fn test_validate_absent_allows_legacy_duplicate_identity_mismatch() {
1399        let text = r#"
1400PREBOX1
1401{"con_spec_version":2,"sections":["velocities"]}
140210.0 20.0 30.0
140390.0 90.0 90.0
1404POSTBOX1
1405POSTBOX2
14061
14071
140863.546
1409Cu
1410Coordinates of Component 1
14110.0 0.0 0.0 5 0
1412
1413Cu
1414Velocities of Component 1
14150.1 0.2 0.3 0 99
1416"#;
1417        let mut iter = ConFrameIterator::new(text.trim());
1418        let frame = iter.next().unwrap().unwrap();
1419
1420        assert!(frame.has_velocities());
1421        assert_eq!(frame.atom_data[0].atom_id, 0);
1422        assert_eq!(frame.atom_data[0].fixed, [true, false, true]);
1423    }
1424
1425    #[test]
1426    fn test_validate_must_be_boolean_when_present() {
1427        let lines = vec![
1428            "PREBOX1",
1429            "{\"con_spec_version\":2,\"validate\":\"yes\",\"sections\":[]}",
1430            "10.0 20.0 30.0",
1431            "90.0 90.0 90.0",
1432            "POSTBOX1",
1433            "POSTBOX2",
1434            "1",
1435            "1",
1436            "12.011",
1437        ];
1438        let mut line_it = lines.iter().copied();
1439        let err = parse_frame_header(&mut line_it).unwrap_err();
1440
1441        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1442        assert!(err.to_string().contains("validate"));
1443    }
1444
1445    #[test]
1446    fn test_validate_true_requires_sections_key() {
1447        let lines = vec![
1448            "PREBOX1",
1449            "{\"con_spec_version\":2,\"validate\":true}",
1450            "10.0 20.0 30.0",
1451            "90.0 90.0 90.0",
1452            "POSTBOX1",
1453            "POSTBOX2",
1454            "1",
1455            "1",
1456            "12.011",
1457        ];
1458        let mut line_it = lines.iter().copied();
1459        let err = parse_frame_header(&mut line_it).unwrap_err();
1460
1461        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1462        assert!(err.to_string().contains("sections"));
1463    }
1464
1465    #[test]
1466    fn test_sections_must_be_string_array_when_present() {
1467        let lines = vec![
1468            "PREBOX1",
1469            "{\"con_spec_version\":2,\"sections\":[\"velocities\",7]}",
1470            "10.0 20.0 30.0",
1471            "90.0 90.0 90.0",
1472            "POSTBOX1",
1473            "POSTBOX2",
1474            "1",
1475            "1",
1476            "12.011",
1477        ];
1478        let mut line_it = lines.iter().copied();
1479        let err = parse_frame_header(&mut line_it).unwrap_err();
1480
1481        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1482        assert!(err.to_string().contains("sections"));
1483    }
1484
1485    #[test]
1486    fn test_validate_true_rejects_non_integer_coordinate_identity_columns() {
1487        let text = r#"
1488PREBOX1
1489{"con_spec_version":2,"sections":[],"validate":true}
149010.0 20.0 30.0
149190.0 90.0 90.0
1492POSTBOX1
1493POSTBOX2
14941
14951
149663.546
1497Cu
1498Coordinates of Component 1
14990.0 0.0 0.0 5.0 0
1500"#;
1501        let mut iter = ConFrameIterator::new(text.trim());
1502        let err = iter.next().unwrap().unwrap_err();
1503
1504        assert!(matches!(err, ParseError::ValidationError(_)));
1505        assert!(err.to_string().contains("fixed_flag"));
1506    }
1507
1508    #[test]
1509    fn test_validate_true_rejects_non_exact_coordinate_label() {
1510        let text = r#"
1511PREBOX1
1512{"con_spec_version":2,"sections":[],"validate":true}
151310.0 20.0 30.0
151490.0 90.0 90.0
1515POSTBOX1
1516POSTBOX2
15171
15181
151963.546
1520Cu
1521Coordinates Component 1
15220.0 0.0 0.0 5 0
1523"#;
1524        let mut iter = ConFrameIterator::new(text.trim());
1525        let err = iter.next().unwrap().unwrap_err();
1526
1527        assert!(matches!(err, ParseError::ValidationError(_)));
1528        assert!(err.to_string().contains("Coordinates of Component 1"));
1529    }
1530
1531    #[test]
1532    fn test_validate_true_rejects_unknown_component_symbol() {
1533        let text = r#"
1534PREBOX1
1535{"con_spec_version":2,"sections":[],"validate":true}
153610.0 20.0 30.0
153790.0 90.0 90.0
1538POSTBOX1
1539POSTBOX2
15401
15411
154263.546
1543Qq
1544Coordinates of Component 1
15450.0 0.0 0.0 0 0
1546"#;
1547        let mut iter = ConFrameIterator::new(text.trim());
1548        let err = iter.next().unwrap().unwrap_err();
1549
1550        assert!(matches!(err, ParseError::ValidationError(_)));
1551        assert!(err.to_string().contains("symbol"));
1552    }
1553
1554    #[test]
1555    fn test_declared_section_must_be_present() {
1556        let text = r#"
1557PREBOX1
1558{"con_spec_version":2,"sections":["velocities"]}
155910.0 20.0 30.0
156090.0 90.0 90.0
1561POSTBOX1
1562POSTBOX2
15631
15641
156563.546
1566Cu
1567Coordinates of Component 1
15680.0 0.0 0.0 0 0
1569"#;
1570        let mut iter = ConFrameIterator::new(text.trim());
1571        let err = iter.next().unwrap().unwrap_err();
1572
1573        assert!(matches!(err, ParseError::IncompleteVelocitySection));
1574    }
1575
1576    #[test]
1577    fn test_non_finite_cell_geometry_rejected_in_strict_mode() {
1578        let lines = vec![
1579            "PREBOX1",
1580            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
1581            "10.0 NaN 30.0",
1582            "90.0 90.0 90.0",
1583            "POSTBOX1",
1584            "POSTBOX2",
1585            "1",
1586            "1",
1587            "12.011",
1588        ];
1589        let mut line_it = lines.iter().copied();
1590        let err = parse_frame_header(&mut line_it).unwrap_err();
1591
1592        assert!(matches!(err, ParseError::ValidationError(_)));
1593        assert!(err.to_string().contains("cell"));
1594    }
1595
1596    #[test]
1597    fn test_validate_true_rejects_non_physical_cell_geometry() {
1598        let lines = vec![
1599            "PREBOX1",
1600            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
1601            "0.0 20.0 30.0",
1602            "90.0 180.0 90.0",
1603            "POSTBOX1",
1604            "POSTBOX2",
1605            "1",
1606            "1",
1607            "12.011",
1608        ];
1609        let mut line_it = lines.iter().copied();
1610        let err = parse_frame_header(&mut line_it).unwrap_err();
1611
1612        assert!(matches!(err, ParseError::ValidationError(_)));
1613        assert!(err.to_string().contains("cell"));
1614    }
1615
1616    #[test]
1617    fn test_validate_true_rejects_reserved_metadata_type_mismatch() {
1618        let lines = vec![
1619            "PREBOX1",
1620            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"energy\":\"low\"}",
1621            "10.0 20.0 30.0",
1622            "90.0 90.0 90.0",
1623            "POSTBOX1",
1624            "POSTBOX2",
1625            "1",
1626            "1",
1627            "12.011",
1628        ];
1629        let mut line_it = lines.iter().copied();
1630        let err = parse_frame_header(&mut line_it).unwrap_err();
1631
1632        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1633        assert!(err.to_string().contains("energy"));
1634    }
1635
1636    #[test]
1637    fn test_parse_velocity_section_absent() {
1638        let lines = vec![
1639            "PREBOX1",
1640            "{\"con_spec_version\":2}",
1641            "10.0 20.0 30.0",
1642            "90.0 90.0 90.0",
1643            "POSTBOX1",
1644            "POSTBOX2",
1645            "1",
1646            "1",
1647            "12.011",
1648            "C",
1649            "Coordinates of Component 1",
1650            "0.0 0.0 0.0 0.0 1",
1651        ];
1652        let mut line_it = lines.iter().copied().peekable();
1653        let mut frame = parse_single_frame(&mut line_it).expect("parse should succeed");
1654        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
1655            .expect("should succeed with no velocities");
1656        assert!(!has_vel);
1657        assert_eq!(frame.atom_data[0].velocity, None);
1658    }
1659
1660    #[test]
1661    fn test_parse_line_of_range_f64_exact() {
1662        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0 42", 4, 5, &[0.0; 5]).unwrap();
1663        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 42.0]);
1664    }
1665
1666    #[test]
1667    fn test_parse_line_of_range_f64_padded() {
1668        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
1669        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0", 4, 5, &defaults).unwrap();
1670        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 99.0]);
1671    }
1672
1673    #[test]
1674    fn test_parse_line_of_range_f64_too_few() {
1675        let result = parse_line_of_range_f64("1.0 2.0 3.0", 4, 5, &[0.0; 5]);
1676        assert!(result.is_err());
1677    }
1678
1679    #[test]
1680    fn test_parse_line_of_range_f64_too_many() {
1681        let result = parse_line_of_range_f64("1.0 2.0 3.0 0.0 5.0 6.0", 4, 5, &[0.0; 5]);
1682        assert!(result.is_err());
1683    }
1684
1685    #[test]
1686    fn test_parse_all_four_column_lines() {
1687        // All atom lines have only 4 columns; atom_index defaults to sequential.
1688        let lines = vec![
1689            "PREBOX1",
1690            "{\"con_spec_version\":2}",
1691            "10.0 10.0 10.0",
1692            "90.0 90.0 90.0",
1693            "POSTBOX1",
1694            "POSTBOX2",
1695            "1",
1696            "3",
1697            "12.011",
1698            "C",
1699            "Coordinates of Component 1",
1700            "0.0 0.0 0.0 0",
1701            "1.0 0.0 0.0 0",
1702            "2.0 0.0 0.0 1",
1703        ];
1704        let mut line_it = lines.iter().copied();
1705        let frame = parse_single_frame(&mut line_it).unwrap();
1706        assert_eq!(frame.atom_data[0].atom_id, 0);
1707        assert_eq!(frame.atom_data[1].atom_id, 1);
1708        assert_eq!(frame.atom_data[2].atom_id, 2);
1709        assert!(frame.atom_data[2].is_fixed());
1710    }
1711}