Skip to main content

readcon_core/
parser.rs

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