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