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