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_for_spec, 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(
671                    coord_line,
672                    "coordinate",
673                    3,
674                    4,
675                    5,
676                    header.spec_version,
677                )?
678            } else {
679                (
680                    decode_fixed_bitmask_for_spec(vals[3] as u8, header.spec_version),
681                    vals[4] as u64,
682                )
683            };
684            let xyz = [vals[0], vals[1], vals[2]];
685            if f64_positions {
686                let o = atom_i * 3;
687                pos_flat[o] = xyz[0];
688                pos_flat[o + 1] = xyz[1];
689                pos_flat[o + 2] = xyz[2];
690            } else if let Some(ref mut pos) = positions_other {
691                pos.set_f64_row(atom_i, xyz);
692            }
693            atom_data.push(AtomDatum {
694                // This is a cheap reference-count increment, not a full string clone.
695                symbol: Arc::clone(&symbol),
696                x: xyz[0],
697                y: xyz[1],
698                z: xyz[2],
699                fixed,
700                atom_id,
701                velocity: None,
702                force: None,
703                energy: None,
704                charge: None,
705                spin: None,
706                magmom: None,
707            });
708            global_atom_idx += 1;
709            atom_i += 1;
710        }
711    }
712    let positions = if f64_positions {
713        FloatArray2::from_f64_row_major(total_atoms, 3, pos_flat)
714    } else {
715        positions_other.expect("non-f64 positions allocated")
716    };
717    // Sections still attach to AoS; assemble uses prefilled positions (no second pos pass).
718    Ok(crate::types::con_frame_from_atom_data_with_positions(
719        header, atom_data, positions,
720    ))
721}
722
723fn validate_header_geometry(
724    boxl: &[f64],
725    angles: &[f64],
726    natm_types: usize,
727    natms_per_type: &[usize],
728) -> Result<(), ParseError> {
729    if boxl.iter().any(|length| !length.is_finite() || *length <= 0.0)
730        || angles
731            .iter()
732            .any(|angle| !angle.is_finite() || *angle <= 0.0 || *angle >= 180.0)
733    {
734        return Err(ParseError::ValidationError(
735            "cell geometry must have positive lengths and angles between 0 and 180 degrees"
736                .to_string(),
737        ));
738    }
739    if natm_types == 0 || natms_per_type.contains(&0) {
740        return Err(ParseError::ValidationError(
741            "atom counts must contain at least one atom per component".to_string(),
742        ));
743    }
744    Ok(())
745}
746
747fn validate_masses(masses_per_type: &[f64]) -> Result<(), ParseError> {
748    if masses_per_type
749        .iter()
750        .any(|mass| !mass.is_finite() || *mass <= 0.0)
751    {
752        return Err(ParseError::ValidationError(
753            "component masses must be positive".to_string(),
754        ));
755    }
756    Ok(())
757}
758
759fn validate_coordinate_component(
760    type_idx: usize,
761    symbol: &str,
762    label: &str,
763) -> Result<(), ParseError> {
764    let expected_label = format!("Coordinates of Component {}", type_idx + 1);
765    if label.trim() != expected_label {
766        return Err(ParseError::ValidationError(format!(
767            "expected coordinate label {expected_label:?}, found {label:?}"
768        )));
769    }
770    if symbol != "X" && symbol_to_atomic_number(symbol) == 0 {
771        return Err(ParseError::ValidationError(format!(
772            "unknown component symbol {symbol}"
773        )));
774    }
775    Ok(())
776}
777
778/// Strict-validation parser for the per-row identity columns
779/// (fixed bitmask + atom_id) used by every section type.
780///
781/// `n_cols` is the total whitespace-separated column count expected on
782/// the row in strict mode, and `(fixed_idx, atom_id_idx)` are the
783/// 0-based positions of the fixed bitmask and atom_id columns inside
784/// that layout. Each section calls in with its own values:
785///
786/// - coordinates / velocities / forces: 5 cols, fixed=3, atom_id=4
787/// - energies: 3 cols, fixed=1, atom_id=2
788///
789/// String-based parsing on purpose: strict v2 mode rejects values that
790/// are not in the canonical integer form (e.g. `5.0` for a bitmask),
791/// which an f64 round-trip would silently accept.
792fn parse_identity_columns(
793    line: &str,
794    row_kind: &str,
795    fixed_idx: usize,
796    atom_id_idx: usize,
797    n_cols: usize,
798    spec_version: u32,
799) -> Result<([bool; 3], u64), ParseError> {
800    let columns = line.split_ascii_whitespace().collect::<Vec<_>>();
801    if columns.len() != n_cols {
802        return Err(ParseError::ValidationError(format!(
803            "{row_kind} rows require {n_cols} columns including fixed_flag and atom_id in validate mode"
804        )));
805    }
806    let fixed_flag = columns[fixed_idx].parse::<u8>().map_err(|_| {
807        ParseError::ValidationError(format!("{row_kind} fixed_flag must be an integer bitmask"))
808    })?;
809    if fixed_flag > 7 {
810        return Err(ParseError::ValidationError(format!(
811            "{row_kind} fixed_flag must be between 0 and 7"
812        )));
813    }
814    let atom_id = columns[atom_id_idx].parse::<u64>().map_err(|_| {
815        ParseError::ValidationError(format!("{row_kind} atom_id must be an integer"))
816    })?;
817    Ok((decode_fixed_bitmask_for_spec(fixed_flag, spec_version), atom_id))
818}
819
820
821fn validate_section_component(
822    section: &str,
823    type_idx: usize,
824    atom_idx: usize,
825    symbol: &str,
826    label: &str,
827    header: &FrameHeader,
828    atom_data: &[AtomDatum],
829) -> Result<(), ParseError> {
830    let expected_label = format!("{section} of Component {}", type_idx + 1);
831    if label.trim() != expected_label {
832        return Err(ParseError::ValidationError(format!(
833            "expected section label {expected_label:?}, found {label:?}"
834        )));
835    }
836
837    if header.natms_per_type[type_idx] == 0 {
838        return Ok(());
839    }
840
841    let expected_symbol = atom_data
842        .get(atom_idx)
843        .map(|atom| atom.symbol.as_ref())
844        .ok_or_else(|| {
845            ParseError::ValidationError(format!(
846                "{section} component {} has no coordinate atom to validate against",
847                type_idx + 1
848            ))
849        })?;
850    if symbol != expected_symbol {
851        return Err(ParseError::ValidationError(format!(
852            "{section} component {} symbol mismatch: expected {expected_symbol}, found {symbol}",
853            type_idx + 1
854        )));
855    }
856
857    Ok(())
858}
859
860fn validate_section_atom_identity(
861    section: &str,
862    atom_idx: usize,
863    fixed: [bool; 3],
864    atom_id: u64,
865    atom_data: &[AtomDatum],
866) -> Result<(), ParseError> {
867    let atom = atom_data.get(atom_idx).ok_or_else(|| {
868        ParseError::ValidationError(format!(
869            "{section} row {atom_idx} has no coordinate atom to validate against"
870        ))
871    })?;
872
873    if atom.fixed != fixed {
874        return Err(ParseError::ValidationError(format!(
875            "{section} row {atom_idx} fixed mask mismatch for atom_id {}",
876            atom.atom_id
877        )));
878    }
879    if atom.atom_id != atom_id {
880        return Err(ParseError::ValidationError(format!(
881            "{section} row {atom_idx} atom_id mismatch: expected {}, found {atom_id}",
882            atom.atom_id
883        )));
884    }
885
886    Ok(())
887}
888
889/// Attempts to parse an optional velocity section following coordinate blocks.
890///
891/// In `.convel` files, after all coordinate blocks there is a blank separator line
892/// followed by per-component velocity blocks with the same structure as coordinate
893/// blocks (symbol line, "Velocities of Component N" line, then atom lines with
894/// `vx vy vz fixed atomID`).
895///
896/// This function peeks at the next line. If it is blank (or contains only whitespace),
897/// it consumes the blank line and parses velocity data into the existing `atom_data`.
898/// If the next line is not blank (or is absent), no velocities are parsed.
899///
900/// Returns `Ok(true)` if velocities were found and parsed, `Ok(false)` otherwise.
901pub fn parse_velocity_section<'a>(
902    lines: &mut impl LineStream<'a>,
903    header: &FrameHeader,
904    atom_data: &mut [AtomDatum],
905) -> Result<bool, ParseError> {
906    let validate = header.strict_validation;
907    // Peek at the next line to check for blank separator
908    match lines.peek_line() {
909        Some(line) if line.trim().is_empty() => {
910            // Consume the blank separator
911            lines.next_line();
912        }
913        _ => return Ok(false),
914    }
915
916    let mut atom_idx: usize = 0;
917    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
918        // Symbol line
919        let symbol = lines
920            .next_line()
921            .ok_or(ParseError::IncompleteVelocitySection)?
922            .trim();
923
924        // "Velocities of Component N" line
925        let comp_line = lines.next_line().ok_or(ParseError::IncompleteVelocitySection)?;
926        // Validate it looks like a velocity header (optional strictness)
927        if !comp_line.contains("Velocities of Component") {
928            return Err(ParseError::IncompleteVelocitySection);
929        }
930        if validate {
931            validate_section_component(
932                "Velocities",
933                type_idx,
934                atom_idx,
935                symbol,
936                comp_line,
937                header,
938                atom_data,
939            )?;
940        }
941
942        for _ in 0..num_atoms {
943            let vel_line = lines.next_line().ok_or(ParseError::IncompleteVelocitySection)?;
944            // Column 5 (atom_index) is optional in velocity lines too.
945            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
946            let mut vals = [0.0f64; 5];
947            parse_line_of_range_f64_stack(vel_line, 4, 5, &defaults, &mut vals)?;
948            if validate {
949                let (fixed, atom_id) = parse_identity_columns(
950                    vel_line,
951                    "velocities",
952                    3,
953                    4,
954                    5,
955                    header.spec_version,
956                )?;
957                validate_section_atom_identity("velocities", atom_idx, fixed, atom_id, atom_data)?;
958            }
959            if atom_idx < atom_data.len() {
960                atom_data[atom_idx].velocity = Some([vals[0], vals[1], vals[2]]);
961            }
962            atom_idx += 1;
963        }
964    }
965
966    Ok(true)
967}
968
969/// Attempts to parse a force section following coordinate (and optional velocity) blocks.
970///
971/// Force sections mirror velocity sections: a blank separator line followed by per-component
972/// force blocks (symbol line, "Forces of Component N" line, then atom lines with
973/// `fx fy fz fixed_flag atom_id`).
974///
975/// Returns `Ok(true)` if forces were found and parsed, `Ok(false)` otherwise.
976pub fn parse_force_section<'a>(
977    lines: &mut impl LineStream<'a>,
978    header: &FrameHeader,
979    atom_data: &mut [AtomDatum],
980) -> Result<bool, ParseError> {
981    let validate = header.strict_validation;
982    // Peek at the next line to check for blank separator
983    match lines.peek_line() {
984        Some(line) if line.trim().is_empty() => {
985            lines.next_line();
986        }
987        _ => return Ok(false),
988    }
989
990    let mut atom_idx: usize = 0;
991    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
992        let symbol = lines
993            .next_line()
994            .ok_or(ParseError::IncompleteForceSection)?
995            .trim();
996
997        let comp_line = lines.next_line().ok_or(ParseError::IncompleteForceSection)?;
998        if !comp_line.contains("Forces of Component") {
999            return Err(ParseError::IncompleteForceSection);
1000        }
1001        if validate {
1002            validate_section_component(
1003                "Forces", type_idx, atom_idx, symbol, comp_line, header, atom_data,
1004            )?;
1005        }
1006
1007        for _ in 0..num_atoms {
1008            let force_line = lines.next_line().ok_or(ParseError::IncompleteForceSection)?;
1009            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
1010            let mut vals = [0.0f64; 5];
1011            parse_line_of_range_f64_stack(force_line, 4, 5, &defaults, &mut vals)?;
1012            if validate {
1013                let (fixed, atom_id) = parse_identity_columns(
1014                    force_line,
1015                    "forces",
1016                    3,
1017                    4,
1018                    5,
1019                    header.spec_version,
1020                )?;
1021                validate_section_atom_identity("forces", atom_idx, fixed, atom_id, atom_data)?;
1022            }
1023            if atom_idx < atom_data.len() {
1024                atom_data[atom_idx].force = Some([vals[0], vals[1], vals[2]]);
1025            }
1026            atom_idx += 1;
1027        }
1028    }
1029
1030    Ok(true)
1031}
1032
1033/// Attempts to parse an energies section following coordinate (and optional
1034/// velocity / force) blocks.
1035///
1036/// Energy sections mirror force sections but with one scalar per atom:
1037/// blank separator, then per-component blocks of (symbol, "Energies of
1038/// Component N", and atom lines `e fixed_flag atom_id`). The two
1039/// trailing identity columns are optional and used only for strict
1040/// validation; in non-strict mode any whitespace after the energy is
1041/// ignored.
1042///
1043/// Returns `Ok(true)` if energies were found and parsed, `Ok(false)`
1044/// otherwise.
1045pub fn parse_energy_section<'a>(
1046    lines: &mut impl LineStream<'a>,
1047    header: &FrameHeader,
1048    atom_data: &mut [AtomDatum],
1049) -> Result<bool, ParseError> {
1050    let validate = header.strict_validation;
1051    match lines.peek_line() {
1052        Some(line) if line.trim().is_empty() => {
1053            lines.next_line();
1054        }
1055        _ => return Ok(false),
1056    }
1057
1058    let mut atom_idx: usize = 0;
1059    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1060        let symbol = lines
1061            .next_line()
1062            .ok_or(ParseError::IncompleteEnergySection)?
1063            .trim();
1064
1065        let comp_line = lines.next_line().ok_or(ParseError::IncompleteEnergySection)?;
1066        if !comp_line.contains("Energies of Component") {
1067            return Err(ParseError::IncompleteEnergySection);
1068        }
1069        if validate {
1070            validate_section_component(
1071                "Energies", type_idx, atom_idx, symbol, comp_line, header, atom_data,
1072            )?;
1073        }
1074
1075        for _ in 0..num_atoms {
1076            let energy_line = lines.next_line().ok_or(ParseError::IncompleteEnergySection)?;
1077            // Single energy column, plus optional fixed flag and atom_id
1078            // for round-trip identity checks.
1079            let defaults = [0.0, 0.0, atom_idx as f64];
1080            let vals = parse_line_of_range_f64(energy_line, 1, 3, &defaults)?;
1081            if validate {
1082                let (fixed, atom_id) = parse_identity_columns(
1083                    energy_line,
1084                    "energies",
1085                    1,
1086                    2,
1087                    3,
1088                    header.spec_version,
1089                )?;
1090                validate_section_atom_identity("energies", atom_idx, fixed, atom_id, atom_data)?;
1091            }
1092            if atom_idx < atom_data.len() {
1093                atom_data[atom_idx].energy = Some(vals[0]);
1094            }
1095            atom_idx += 1;
1096        }
1097    }
1098
1099    Ok(true)
1100}
1101
1102/// Parses declared sections from a frame's header metadata.
1103///
1104/// If `header.sections` is non-empty (v2 file with `"sections"` key in JSON),
1105/// parses each declared section in order. Otherwise falls back to legacy
1106/// blank-separator velocity detection.
1107pub fn parse_declared_sections<'a>(
1108    lines: &mut impl LineStream<'a>,
1109    header: &mut FrameHeader,
1110    atom_data: &mut [AtomDatum],
1111) -> Result<usize, ParseError> {
1112    let mut applied = 0usize;
1113    if !header.sections_declared && header.sections.is_empty() {
1114        // Legacy: try velocity detection via blank separator
1115        let found = parse_velocity_section(lines, header, atom_data)?;
1116        if found {
1117            header.sections.push(SECTION_VELOCITIES.into());
1118            applied = 1;
1119        }
1120    } else {
1121        let sections = std::mem::take(&mut header.sections);
1122        for section in &sections {
1123            match section.as_str() {
1124                SECTION_VELOCITIES => {
1125                    let found = parse_velocity_section(lines, header, atom_data)?;
1126                    if !found {
1127                        return Err(ParseError::IncompleteVelocitySection);
1128                    }
1129                    applied += 1;
1130                }
1131                SECTION_FORCES => {
1132                    let found = parse_force_section(lines, header, atom_data)?;
1133                    if !found {
1134                        return Err(ParseError::IncompleteForceSection);
1135                    }
1136                    applied += 1;
1137                }
1138                SECTION_ENERGIES => {
1139                    let found = parse_energy_section(lines, header, atom_data)?;
1140                    if !found {
1141                        return Err(ParseError::IncompleteEnergySection);
1142                    }
1143                    applied += 1;
1144                }
1145                SECTION_CHARGES => {
1146                    let found = parse_charge_section(lines, header, atom_data)?;
1147                    if !found {
1148                        return Err(ParseError::IncompleteSection(SECTION_CHARGES.into()));
1149                    }
1150                    applied += 1;
1151                }
1152                SECTION_SPINS => {
1153                    let found = parse_spin_section(lines, header, atom_data)?;
1154                    if !found {
1155                        return Err(ParseError::IncompleteSection(SECTION_SPINS.into()));
1156                    }
1157                    applied += 1;
1158                }
1159                SECTION_MAGMOMS => {
1160                    let found = parse_magmom_section(lines, header, atom_data)?;
1161                    if !found {
1162                        return Err(ParseError::IncompleteSection(SECTION_MAGMOMS.into()));
1163                    }
1164                    applied += 1;
1165                }
1166                other => return Err(ParseError::UnknownSection(other.to_string())),
1167            }
1168        }
1169        header.sections = sections;
1170    }
1171    Ok(applied)
1172}
1173
1174/// Scalar per-atom section: blank separator + per-type blocks
1175/// (`symbol`, `"X of Component N"`, lines `value fixed atom_id`).
1176fn parse_scalar_atom_section<'a>(
1177    lines: &mut impl LineStream<'a>,
1178    header: &FrameHeader,
1179    atom_data: &mut [AtomDatum],
1180    section_name: &str,
1181    component_label: &str,
1182    mut set_value: impl FnMut(&mut AtomDatum, f64),
1183) -> Result<bool, ParseError> {
1184    let validate = header.strict_validation;
1185    match lines.peek_line() {
1186        Some(line) if line.trim().is_empty() => {
1187            lines.next_line();
1188        }
1189        _ => return Ok(false),
1190    }
1191
1192    let mut atom_idx: usize = 0;
1193    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1194        let symbol = lines
1195            .next_line()
1196            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?
1197            .trim();
1198
1199        let comp_line = lines
1200            .next_line()
1201            .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
1202        if !comp_line.contains(component_label) {
1203            return Err(ParseError::IncompleteSection(section_name.into()));
1204        }
1205        if validate {
1206            validate_section_component(
1207                component_label.trim_end_matches(" of Component").trim(),
1208                type_idx,
1209                atom_idx,
1210                symbol,
1211                comp_line,
1212                header,
1213                atom_data,
1214            )?;
1215        }
1216
1217        for _ in 0..num_atoms {
1218            let data_line = lines
1219                .next_line()
1220                .ok_or_else(|| ParseError::IncompleteSection(section_name.into()))?;
1221            let defaults = [0.0, 0.0, atom_idx as f64];
1222            let vals = parse_line_of_range_f64(data_line, 1, 3, &defaults)?;
1223            if validate {
1224                let (fixed, atom_id) = parse_identity_columns(
1225                    data_line,
1226                    section_name,
1227                    1,
1228                    2,
1229                    3,
1230                    header.spec_version,
1231                )?;
1232                validate_section_atom_identity(section_name, atom_idx, fixed, atom_id, atom_data)?;
1233            }
1234            if atom_idx < atom_data.len() {
1235                set_value(&mut atom_data[atom_idx], vals[0]);
1236            }
1237            atom_idx += 1;
1238        }
1239    }
1240    Ok(true)
1241}
1242
1243pub fn parse_charge_section<'a>(
1244    lines: &mut impl LineStream<'a>,
1245    header: &FrameHeader,
1246    atom_data: &mut [AtomDatum],
1247) -> Result<bool, ParseError> {
1248    parse_scalar_atom_section(
1249        lines,
1250        header,
1251        atom_data,
1252        SECTION_CHARGES,
1253        "Charges of Component",
1254        |a, v| a.charge = Some(v),
1255    )
1256}
1257
1258pub fn parse_spin_section<'a>(
1259    lines: &mut impl LineStream<'a>,
1260    header: &FrameHeader,
1261    atom_data: &mut [AtomDatum],
1262) -> Result<bool, ParseError> {
1263    parse_scalar_atom_section(
1264        lines,
1265        header,
1266        atom_data,
1267        SECTION_SPINS,
1268        "Spins of Component",
1269        |a, v| a.spin = Some(v),
1270    )
1271}
1272
1273/// Magmoms: 3-vector per atom, same layout as velocities/forces.
1274pub fn parse_magmom_section<'a>(
1275    lines: &mut impl LineStream<'a>,
1276    header: &FrameHeader,
1277    atom_data: &mut [AtomDatum],
1278) -> Result<bool, ParseError> {
1279    let validate = header.strict_validation;
1280    match lines.peek_line() {
1281        Some(line) if line.trim().is_empty() => {
1282            lines.next_line();
1283        }
1284        _ => return Ok(false),
1285    }
1286
1287    let mut atom_idx: usize = 0;
1288    for (type_idx, &num_atoms) in header.natms_per_type.iter().enumerate() {
1289        let symbol = lines
1290            .next_line()
1291            .ok_or_else(|| ParseError::IncompleteSection(SECTION_MAGMOMS.into()))?
1292            .trim();
1293
1294        let comp_line = lines
1295            .next_line()
1296            .ok_or_else(|| ParseError::IncompleteSection(SECTION_MAGMOMS.into()))?;
1297        if !comp_line.contains("Magmoms of Component") {
1298            return Err(ParseError::IncompleteSection(SECTION_MAGMOMS.into()));
1299        }
1300        if validate {
1301            validate_section_component(
1302                "Magmoms",
1303                type_idx,
1304                atom_idx,
1305                symbol,
1306                comp_line,
1307                header,
1308                atom_data,
1309            )?;
1310        }
1311
1312        for _ in 0..num_atoms {
1313            let mm_line = lines
1314                .next_line()
1315                .ok_or_else(|| ParseError::IncompleteSection(SECTION_MAGMOMS.into()))?;
1316            let defaults = [0.0, 0.0, 0.0, 0.0, atom_idx as f64];
1317            let mut vals = [0.0f64; 5];
1318            parse_line_of_range_f64_stack(mm_line, 4, 5, &defaults, &mut vals)?;
1319            if validate {
1320                let (fixed, atom_id) = parse_identity_columns(
1321                    mm_line,
1322                    SECTION_MAGMOMS,
1323                    3,
1324                    4,
1325                    5,
1326                    header.spec_version,
1327                )?;
1328                validate_section_atom_identity(
1329                    SECTION_MAGMOMS,
1330                    atom_idx,
1331                    fixed,
1332                    atom_id,
1333                    atom_data,
1334                )?;
1335            }
1336            if atom_idx < atom_data.len() {
1337                atom_data[atom_idx].magmom = Some([vals[0], vals[1], vals[2]]);
1338            }
1339            atom_idx += 1;
1340        }
1341    }
1342    Ok(true)
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347    use super::*;
1348    use crate::iterators::ConFrameIterator;
1349
1350    #[test]
1351    fn test_parse_line_of_n_success() {
1352        let line = "1.0 2.5 -3.0";
1353        let values = parse_line_of_n::<f64>(line, 3).unwrap();
1354        assert_eq!(values, vec![1.0, 2.5, -3.0]);
1355    }
1356
1357    #[test]
1358    fn test_parse_line_of_n_too_short() {
1359        let line = "1.0 2.5";
1360        let result = parse_line_of_n::<f64>(line, 3);
1361        assert!(result.is_err());
1362        assert!(matches!(
1363            result.unwrap_err(),
1364            ParseError::InvalidVectorLength {
1365                expected: 3,
1366                found: 2
1367            }
1368        ));
1369    }
1370
1371    #[test]
1372    fn test_parse_line_of_n_too_long() {
1373        let line = "1.0 2.5 -3.0 4.0";
1374        let result = parse_line_of_n::<f64>(line, 3);
1375        assert!(result.is_err());
1376        assert!(matches!(
1377            result.unwrap_err(),
1378            ParseError::InvalidVectorLength {
1379                expected: 3,
1380                found: 4
1381            }
1382        ));
1383    }
1384
1385    #[test]
1386    fn test_parse_line_of_n_invalid_float() {
1387        let line = "1.0 abc -3.0";
1388        let result = parse_line_of_n::<f64>(line, 3);
1389        assert!(result.is_err());
1390        assert!(matches!(
1391            result.unwrap_err(),
1392            ParseError::InvalidNumberFormat(_)
1393        ));
1394    }
1395
1396    #[test]
1397    fn test_parse_frame_header_success() {
1398        let lines = [
1399            "PREBOX1",
1400            "{\"con_spec_version\":2}",
1401            "10.0 20.0 30.0",
1402            "90.0 90.0 90.0",
1403            "POSTBOX1",
1404            "POSTBOX2",
1405            "2",
1406            "1 1",
1407            "12.011 1.008",
1408        ];
1409        let mut line_it = lines.iter().copied();
1410        match parse_frame_header(&mut line_it) {
1411            Ok(header) => {
1412                assert_eq!(header.prebox_header.user, "PREBOX1");
1413                assert_eq!(header.spec_version, 2);
1414                assert_eq!(header.boxl, [10.0, 20.0, 30.0]);
1415                assert_eq!(header.angles, [90.0, 90.0, 90.0]);
1416                assert_eq!(header.postbox_header, ["POSTBOX1", "POSTBOX2"]);
1417                assert_eq!(header.natm_types, 2);
1418                assert_eq!(header.natms_per_type, vec![1, 1]);
1419                assert_eq!(header.masses_per_type, vec![12.011, 1.008]);
1420            }
1421            Err(e) => {
1422                panic!(
1423                    "Parsing failed when it should have succeeded. Error: {:?}",
1424                    e
1425                );
1426            }
1427        }
1428    }
1429
1430    #[test]
1431    fn test_parse_frame_header_missing_line() {
1432        let lines = [
1433            "PREBOX1",
1434            "{\"con_spec_version\":2}",
1435            "10.0 20.0 30.0",
1436            "90.0 90.0 90.0",
1437            "POSTBOX1",
1438            "POSTBOX2",
1439            "2",
1440            "1 1",
1441            // Missing masses_per_type
1442        ];
1443        let mut line_it = lines.iter().copied();
1444        let result = parse_frame_header(&mut line_it);
1445        assert!(result.is_err());
1446        assert!(matches!(result.unwrap_err(), ParseError::IncompleteHeader));
1447    }
1448
1449    #[test]
1450    fn test_parse_frame_header_missing_spec_version() {
1451        let lines = vec![
1452            "PREBOX1",
1453            "{}",
1454            "10.0 20.0 30.0",
1455            "90.0 90.0 90.0",
1456            "POSTBOX1",
1457            "POSTBOX2",
1458            "2",
1459            "1 1",
1460            "12.011 1.008",
1461        ];
1462        let mut line_it = lines.iter().copied();
1463        let result = parse_frame_header(&mut line_it);
1464        assert!(result.is_err());
1465        assert!(matches!(
1466            result.unwrap_err(),
1467            ParseError::MissingSpecVersion
1468        ));
1469    }
1470
1471    #[test]
1472    fn test_parse_frame_header_legacy_no_json() {
1473        // A non-JSON line 2 is treated as a legacy (v1) file.
1474        let lines = vec![
1475            "PREBOX1",
1476            "0.0000 TIME",
1477            "10.0 20.0 30.0",
1478            "90.0 90.0 90.0",
1479            "POSTBOX1",
1480            "POSTBOX2",
1481            "2",
1482            "1 1",
1483            "12.011 1.008",
1484        ];
1485        let mut line_it = lines.iter().copied();
1486        let header = parse_frame_header(&mut line_it).unwrap();
1487        assert_eq!(header.spec_version, 1);
1488        assert!(header.metadata.is_empty());
1489    }
1490
1491    #[test]
1492    fn test_parse_frame_header_malformed_json() {
1493        // Line 2 starts with '{' but is not valid JSON -- this IS an error.
1494        let lines = vec![
1495            "PREBOX1",
1496            "{broken json",
1497            "10.0 20.0 30.0",
1498            "90.0 90.0 90.0",
1499            "POSTBOX1",
1500            "POSTBOX2",
1501            "2",
1502            "1 1",
1503            "12.011 1.008",
1504        ];
1505        let mut line_it = lines.iter().copied();
1506        let result = parse_frame_header(&mut line_it);
1507        assert!(result.is_err());
1508        assert!(matches!(
1509            result.unwrap_err(),
1510            ParseError::InvalidMetadataJson(_)
1511        ));
1512    }
1513
1514    #[test]
1515    fn test_parse_frame_header_unsupported_version() {
1516        let lines = vec![
1517            "PREBOX1",
1518            "{\"con_spec_version\":999}",
1519            "10.0 20.0 30.0",
1520            "90.0 90.0 90.0",
1521            "POSTBOX1",
1522            "POSTBOX2",
1523            "2",
1524            "1 1",
1525            "12.011 1.008",
1526        ];
1527        let mut line_it = lines.iter().copied();
1528        let result = parse_frame_header(&mut line_it);
1529        assert!(result.is_err());
1530        assert!(matches!(
1531            result.unwrap_err(),
1532            ParseError::UnsupportedSpecVersion(999)
1533        ));
1534    }
1535
1536    #[test]
1537    fn test_v3_missing_units_rejected() {
1538        let lines = vec![
1539            "PREBOX1",
1540            "{\"con_spec_version\":3}",
1541            "10.0 20.0 30.0",
1542            "90.0 90.0 90.0",
1543            "POSTBOX1",
1544            "POSTBOX2",
1545            "1",
1546            "1",
1547            "1.0",
1548        ];
1549        let mut line_it = lines.iter().copied();
1550        let err = parse_frame_header(&mut line_it).unwrap_err();
1551        let msg = err.to_string();
1552        assert!(
1553            msg.contains("units") || msg.contains("v3"),
1554            "expected units error, got {msg}"
1555        );
1556    }
1557
1558    #[test]
1559    fn test_v3_invalid_units_rejected() {
1560        let lines = vec![
1561            "PREBOX1",
1562            r#"{"con_spec_version":3,"units":{"length":"eV","energy":"angstrom"}}"#,
1563            "10.0 20.0 30.0",
1564            "90.0 90.0 90.0",
1565            "POSTBOX1",
1566            "POSTBOX2",
1567            "1",
1568            "1",
1569            "1.0",
1570        ];
1571        let mut line_it = lines.iter().copied();
1572        assert!(parse_frame_header(&mut line_it).is_err());
1573    }
1574
1575    #[test]
1576    fn test_v3_valid_units_exposes_length_energy() {
1577        let lines = vec![
1578            "PREBOX1",
1579            r#"{"con_spec_version":3,"units":{"length":"angstrom","energy":"eV","mass":"amu","time":"fs"}}"#,
1580            "10.0 20.0 30.0",
1581            "90.0 90.0 90.0",
1582            "POSTBOX1",
1583            "POSTBOX2",
1584            "1",
1585            "1",
1586            "1.0",
1587        ];
1588        let mut line_it = lines.iter().copied();
1589        let header = parse_frame_header(&mut line_it).unwrap();
1590        assert_eq!(header.spec_version, 3);
1591        assert_eq!(header.length_unit(), Some("angstrom"));
1592        assert_eq!(header.energy_unit(), Some("eV"));
1593        let f = header.conversion_factor_to("length", "nm").unwrap();
1594        assert!((f - 0.1).abs() < 1e-12);
1595    }
1596
1597    #[test]
1598    fn test_parse_frame_header_extra_metadata_preserved() {
1599        let lines = vec![
1600            "PREBOX1",
1601            "{\"con_spec_version\":2,\"generator\":\"test\"}",
1602            "10.0 20.0 30.0",
1603            "90.0 90.0 90.0",
1604            "POSTBOX1",
1605            "POSTBOX2",
1606            "2",
1607            "1 1",
1608            "12.011 1.008",
1609        ];
1610        let mut line_it = lines.iter().copied();
1611        let header = parse_frame_header(&mut line_it).unwrap();
1612        assert_eq!(header.spec_version, 2);
1613        assert_eq!(
1614            header.metadata.get("generator"),
1615            Some(&serde_json::Value::String("test".to_string()))
1616        );
1617    }
1618
1619    #[test]
1620    fn test_parse_frame_header_invalid_natms_per_type() {
1621        let lines = vec![
1622            "PREBOX1",
1623            "{\"con_spec_version\":2}",
1624            "10.0 20.0 30.0",
1625            "90.0 90.0 90.0",
1626            "POSTBOX1",
1627            "POSTBOX2",
1628            "2",
1629            "1 1 1", // 3 values, but natm_types is 2
1630            "12.011 1.008",
1631        ];
1632        let mut line_it = lines.iter().copied();
1633        let result = parse_frame_header(&mut line_it);
1634        assert!(result.is_err());
1635        assert!(matches!(
1636            result.unwrap_err(),
1637            ParseError::InvalidVectorLength {
1638                expected: 2,
1639                found: 3
1640            }
1641        ));
1642    }
1643
1644    #[test]
1645    fn test_parse_single_frame_success() {
1646        let lines = vec![
1647            "PREBOX1",
1648            "{\"con_spec_version\":2}",
1649            "10.0 20.0 30.0",
1650            "90.0 90.0 90.0",
1651            "POSTBOX1",
1652            "POSTBOX2",
1653            "2",
1654            "3 3",
1655            "12.011 1.008",
1656            "1",
1657            "Coordinates of Component 1",
1658            "0.0 0.0 0.0 0.0 1",
1659            "1.0940 0.0 0.0 0.0 2",
1660            "-0.5470 0.9499 0.0 0.0 3",
1661            "2",
1662            "Coordinates of Component 2",
1663            "5.0 5.0 5.0 0.0 4",
1664            "6.0940 5.0 5.0 0.0 5",
1665            "5.5470 5.9499 5.0 0.0 6",
1666        ];
1667        let mut line_it = lines.iter().copied();
1668        let frame = parse_single_frame(&mut line_it).unwrap();
1669
1670        assert_eq!(frame.header.natm_types, 2);
1671        assert_eq!(frame.header.natms_per_type, vec![3, 3]);
1672        assert_eq!(frame.header.masses_per_type, vec![12.011, 1.008]);
1673        assert_eq!(frame.atom_data.len(), 6);
1674        assert_eq!(&*frame.atom_data[0].symbol, "1");
1675        assert_eq!(frame.atom_data[0].atom_id, 1);
1676        assert_eq!(&*frame.atom_data[5].symbol, "2");
1677        assert_eq!(frame.atom_data[5].atom_id, 6);
1678    }
1679
1680    #[test]
1681    fn test_column_4_value_1_is_x_only_on_spec_2() {
1682        let lines = vec![
1683            "Generated by probe",
1684            "{\"con_spec_version\":2}",
1685            "20.0 20.0 20.0",
1686            "90.0 90.0 90.0",
1687            "",
1688            "",
1689            "1",
1690            "1",
1691            "63.5",
1692            "Cu",
1693            "Coordinates of component   1",
1694            "1.0 2.0 3.0 1 1",
1695        ];
1696        let mut line_it = lines.iter().copied();
1697        let frame = parse_single_frame(&mut line_it).unwrap();
1698        assert_eq!(frame.header.spec_version, 2);
1699        assert_eq!(frame.atom_data[0].fixed, [true, false, false]);
1700    }
1701
1702    #[test]
1703    fn test_column_4_value_1_is_all_fixed_on_spec_1() {
1704        let lines = vec![
1705            "Generated by probe",
1706            "0.0000 TIME",
1707            "20.0 20.0 20.0",
1708            "90.0 90.0 90.0",
1709            "",
1710            "",
1711            "1",
1712            "1",
1713            "63.5",
1714            "Cu",
1715            "Coordinates of component   1",
1716            "1.0 2.0 3.0 1 1",
1717        ];
1718        let mut line_it = lines.iter().copied();
1719        let frame = parse_single_frame(&mut line_it).unwrap();
1720        assert_eq!(frame.header.spec_version, 1);
1721        assert_eq!(frame.atom_data[0].fixed, [true, true, true]);
1722    }
1723
1724    #[test]
1725    fn test_parse_single_frame_missing_line() {
1726        // With a valid header but truncated atom data, we get IncompleteFrame.
1727        let lines = vec![
1728            "PREBOX1",
1729            "{\"con_spec_version\":2}",
1730            "10.0 20.0 30.0",
1731            "90.0 90.0 90.0",
1732            "POSTBOX1",
1733            "POSTBOX2",
1734            "2",
1735            "3 3",
1736            "12.011 1.008",
1737            "1",
1738            "Coordinates of Component 1",
1739            "0.0 0.0 0.0 0.0 1",
1740            "1.0940 0.0 0.0 0.0 2",
1741            "-0.5470 0.9499 0.0 0.0 3",
1742            // Missing Component 2 entirely
1743        ];
1744        let mut line_it = lines.iter().copied();
1745        let result = parse_single_frame(&mut line_it);
1746        assert!(result.is_err());
1747        assert!(matches!(result.unwrap_err(), ParseError::IncompleteFrame));
1748    }
1749
1750    #[test]
1751    fn test_parse_single_frame_missing_atom_index_defaults_sequential() {
1752        // Column 5 (atom_index) is optional; when absent, defaults to sequential.
1753        let lines = vec![
1754            "PREBOX1",
1755            "{\"con_spec_version\":2}",
1756            "10.0 20.0 30.0",
1757            "90.0 90.0 90.0",
1758            "POSTBOX1",
1759            "POSTBOX2",
1760            "2",
1761            "3 3",
1762            "12.011 1.008",
1763            "1",
1764            "Coordinates of Component 1",
1765            "0.0 0.0 0.0 0.0 1",
1766            "1.0940 0.0 0.0 0.0 2",
1767            "-0.5470 0.9499 0.0 0.0 3",
1768            "2",
1769            "Coordinates of Component 2",
1770            "5.0 5.0 5.0 0.0",       // No atom_index: defaults to 3
1771            "6.0940 5.0 5.0 0.0 10", // Explicit atom_index: 10
1772            "5.5470 5.9499 5.0 0.0", // No atom_index: defaults to 5
1773        ];
1774        let mut line_it = lines.iter().copied();
1775        let frame = parse_single_frame(&mut line_it).unwrap();
1776        assert_eq!(frame.atom_data.len(), 6);
1777        // First type: explicit atom_index values
1778        assert_eq!(frame.atom_data[0].atom_id, 1);
1779        assert_eq!(frame.atom_data[1].atom_id, 2);
1780        assert_eq!(frame.atom_data[2].atom_id, 3);
1781        // Second type: mixed explicit and defaulted
1782        assert_eq!(frame.atom_data[3].atom_id, 3); // defaulted (global idx 3)
1783        assert_eq!(frame.atom_data[4].atom_id, 10); // explicit
1784        assert_eq!(frame.atom_data[5].atom_id, 5); // defaulted (global idx 5)
1785    }
1786
1787    #[test]
1788    fn test_parse_single_frame_too_few_columns_fails() {
1789        // Only 3 columns (missing fixed_flag too) should still fail.
1790        let lines = vec![
1791            "PREBOX1",
1792            "{\"con_spec_version\":2}",
1793            "10.0 20.0 30.0",
1794            "90.0 90.0 90.0",
1795            "POSTBOX1",
1796            "POSTBOX2",
1797            "1",
1798            "1",
1799            "12.011",
1800            "C",
1801            "Coordinates of Component 1",
1802            "0.0 0.0 0.0", // Only 3 values
1803        ];
1804        let mut line_it = lines.iter().copied();
1805        let result = parse_single_frame(&mut line_it);
1806        assert!(result.is_err());
1807        assert!(matches!(
1808            result.unwrap_err(),
1809            ParseError::InvalidVectorLength {
1810                expected: 5,
1811                found: 3
1812            }
1813        ));
1814    }
1815
1816    #[test]
1817    fn test_parse_velocity_section_present() {
1818        let lines = vec![
1819            "PREBOX1",
1820            "{\"con_spec_version\":2}",
1821            "10.0 20.0 30.0",
1822            "90.0 90.0 90.0",
1823            "POSTBOX1",
1824            "POSTBOX2",
1825            "2",
1826            "1 1",
1827            "63.546 1.008",
1828            "Cu",
1829            "Coordinates of Component 1",
1830            "0.0 0.0 0.0 1.0 0",
1831            "H",
1832            "Coordinates of Component 2",
1833            "1.0 2.0 3.0 0.0 1",
1834            "",
1835            "Cu",
1836            "Velocities of Component 1",
1837            "0.1 0.2 0.3 1.0 0",
1838            "H",
1839            "Velocities of Component 2",
1840            "0.4 0.5 0.6 0.0 1",
1841        ];
1842        let mut line_it = lines.iter().copied().peekable();
1843        // Parse the frame first (consuming 15 lines)
1844        let mut frame =
1845            parse_single_frame(&mut line_it).expect("coordinate parsing should succeed");
1846        assert!(!frame.has_velocities());
1847
1848        // Now parse the velocity section
1849        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
1850            .expect("velocity parsing should succeed");
1851        assert!(has_vel);
1852        assert_eq!(frame.atom_data[0].velocity, Some([0.1, 0.2, 0.3]));
1853        assert_eq!(frame.atom_data[1].velocity, Some([0.4, 0.5, 0.6]));
1854    }
1855
1856    #[test]
1857    fn test_validate_true_accepts_matching_section_identity() {
1858        let text = r#"
1859PREBOX1
1860{"con_spec_version":2,"sections":["velocities"],"validate":true}
186110.0 20.0 30.0
186290.0 90.0 90.0
1863POSTBOX1
1864POSTBOX2
18652
18661 1
186763.546 1.008
1868Cu
1869Coordinates of Component 1
18700.0 0.0 0.0 5 0
1871H
1872Coordinates of Component 2
18731.0 2.0 3.0 0 1
1874
1875Cu
1876Velocities of Component 1
18770.1 0.2 0.3 5 0
1878H
1879Velocities of Component 2
18800.4 0.5 0.6 0 1
1881"#;
1882        let mut iter = ConFrameIterator::new(text.trim());
1883        let frame = iter.next().unwrap().unwrap();
1884
1885        assert!(frame.has_velocities());
1886        assert_eq!(
1887            frame
1888                .header
1889                .metadata
1890                .get("validate")
1891                .and_then(|v| v.as_bool()),
1892            Some(true)
1893        );
1894    }
1895
1896    #[test]
1897    fn test_validate_true_rejects_section_atom_id_mismatch() {
1898        let text = r#"
1899PREBOX1
1900{"con_spec_version":2,"sections":["velocities"],"validate":true}
190110.0 20.0 30.0
190290.0 90.0 90.0
1903POSTBOX1
1904POSTBOX2
19051
19061
190763.546
1908Cu
1909Coordinates of Component 1
19100.0 0.0 0.0 5 0
1911
1912Cu
1913Velocities of Component 1
19140.1 0.2 0.3 5 99
1915"#;
1916        let mut iter = ConFrameIterator::new(text.trim());
1917        let err = iter.next().unwrap().unwrap_err();
1918
1919        assert!(matches!(err, ParseError::ValidationError(_)));
1920        assert!(err.to_string().contains("atom_id mismatch"));
1921    }
1922
1923    #[test]
1924    fn test_validate_true_rejects_section_symbol_mismatch() {
1925        let text = r#"
1926PREBOX1
1927{"con_spec_version":2,"sections":["forces"],"validate":true}
192810.0 20.0 30.0
192990.0 90.0 90.0
1930POSTBOX1
1931POSTBOX2
19321
19331
193463.546
1935Cu
1936Coordinates of Component 1
19370.0 0.0 0.0 5 0
1938
1939H
1940Forces of Component 1
19410.1 0.2 0.3 5 0
1942"#;
1943        let mut iter = ConFrameIterator::new(text.trim());
1944        let err = iter.next().unwrap().unwrap_err();
1945
1946        assert!(matches!(err, ParseError::ValidationError(_)));
1947        assert!(err.to_string().contains("symbol mismatch"));
1948    }
1949
1950    #[test]
1951    fn test_validate_absent_allows_legacy_duplicate_identity_mismatch() {
1952        let text = r#"
1953PREBOX1
1954{"con_spec_version":2,"sections":["velocities"]}
195510.0 20.0 30.0
195690.0 90.0 90.0
1957POSTBOX1
1958POSTBOX2
19591
19601
196163.546
1962Cu
1963Coordinates of Component 1
19640.0 0.0 0.0 5 0
1965
1966Cu
1967Velocities of Component 1
19680.1 0.2 0.3 0 99
1969"#;
1970        let mut iter = ConFrameIterator::new(text.trim());
1971        let frame = iter.next().unwrap().unwrap();
1972
1973        assert!(frame.has_velocities());
1974        assert_eq!(frame.atom_data[0].atom_id, 0);
1975        assert_eq!(frame.atom_data[0].fixed, [true, false, true]);
1976    }
1977
1978    #[test]
1979    fn test_validate_must_be_boolean_when_present() {
1980        let lines = vec![
1981            "PREBOX1",
1982            "{\"con_spec_version\":2,\"validate\":\"yes\",\"sections\":[]}",
1983            "10.0 20.0 30.0",
1984            "90.0 90.0 90.0",
1985            "POSTBOX1",
1986            "POSTBOX2",
1987            "1",
1988            "1",
1989            "12.011",
1990        ];
1991        let mut line_it = lines.iter().copied();
1992        let err = parse_frame_header(&mut line_it).unwrap_err();
1993
1994        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
1995        assert!(err.to_string().contains("validate"));
1996    }
1997
1998    #[test]
1999    fn test_validate_true_requires_sections_key() {
2000        let lines = vec![
2001            "PREBOX1",
2002            "{\"con_spec_version\":2,\"validate\":true}",
2003            "10.0 20.0 30.0",
2004            "90.0 90.0 90.0",
2005            "POSTBOX1",
2006            "POSTBOX2",
2007            "1",
2008            "1",
2009            "12.011",
2010        ];
2011        let mut line_it = lines.iter().copied();
2012        let err = parse_frame_header(&mut line_it).unwrap_err();
2013
2014        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2015        assert!(err.to_string().contains("sections"));
2016    }
2017
2018    #[test]
2019    fn test_sections_must_be_string_array_when_present() {
2020        let lines = vec![
2021            "PREBOX1",
2022            "{\"con_spec_version\":2,\"sections\":[\"velocities\",7]}",
2023            "10.0 20.0 30.0",
2024            "90.0 90.0 90.0",
2025            "POSTBOX1",
2026            "POSTBOX2",
2027            "1",
2028            "1",
2029            "12.011",
2030        ];
2031        let mut line_it = lines.iter().copied();
2032        let err = parse_frame_header(&mut line_it).unwrap_err();
2033
2034        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2035        assert!(err.to_string().contains("sections"));
2036    }
2037
2038    #[test]
2039    fn test_validate_true_rejects_non_integer_coordinate_identity_columns() {
2040        let text = r#"
2041PREBOX1
2042{"con_spec_version":2,"sections":[],"validate":true}
204310.0 20.0 30.0
204490.0 90.0 90.0
2045POSTBOX1
2046POSTBOX2
20471
20481
204963.546
2050Cu
2051Coordinates of Component 1
20520.0 0.0 0.0 5.0 0
2053"#;
2054        let mut iter = ConFrameIterator::new(text.trim());
2055        let err = iter.next().unwrap().unwrap_err();
2056
2057        assert!(matches!(err, ParseError::ValidationError(_)));
2058        assert!(err.to_string().contains("fixed_flag"));
2059    }
2060
2061    #[test]
2062    fn test_validate_true_rejects_non_exact_coordinate_label() {
2063        let text = r#"
2064PREBOX1
2065{"con_spec_version":2,"sections":[],"validate":true}
206610.0 20.0 30.0
206790.0 90.0 90.0
2068POSTBOX1
2069POSTBOX2
20701
20711
207263.546
2073Cu
2074Coordinates Component 1
20750.0 0.0 0.0 5 0
2076"#;
2077        let mut iter = ConFrameIterator::new(text.trim());
2078        let err = iter.next().unwrap().unwrap_err();
2079
2080        assert!(matches!(err, ParseError::ValidationError(_)));
2081        assert!(err.to_string().contains("Coordinates of Component 1"));
2082    }
2083
2084    #[test]
2085    fn test_validate_true_rejects_unknown_component_symbol() {
2086        let text = r#"
2087PREBOX1
2088{"con_spec_version":2,"sections":[],"validate":true}
208910.0 20.0 30.0
209090.0 90.0 90.0
2091POSTBOX1
2092POSTBOX2
20931
20941
209563.546
2096Qq
2097Coordinates of Component 1
20980.0 0.0 0.0 0 0
2099"#;
2100        let mut iter = ConFrameIterator::new(text.trim());
2101        let err = iter.next().unwrap().unwrap_err();
2102
2103        assert!(matches!(err, ParseError::ValidationError(_)));
2104        assert!(err.to_string().contains("symbol"));
2105    }
2106
2107    #[test]
2108    fn test_declared_section_must_be_present() {
2109        let text = r#"
2110PREBOX1
2111{"con_spec_version":2,"sections":["velocities"]}
211210.0 20.0 30.0
211390.0 90.0 90.0
2114POSTBOX1
2115POSTBOX2
21161
21171
211863.546
2119Cu
2120Coordinates of Component 1
21210.0 0.0 0.0 0 0
2122"#;
2123        let mut iter = ConFrameIterator::new(text.trim());
2124        let err = iter.next().unwrap().unwrap_err();
2125
2126        assert!(matches!(err, ParseError::IncompleteVelocitySection));
2127    }
2128
2129    #[test]
2130    fn test_non_finite_cell_geometry_rejected_in_strict_mode() {
2131        let lines = vec![
2132            "PREBOX1",
2133            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
2134            "10.0 NaN 30.0",
2135            "90.0 90.0 90.0",
2136            "POSTBOX1",
2137            "POSTBOX2",
2138            "1",
2139            "1",
2140            "12.011",
2141        ];
2142        let mut line_it = lines.iter().copied();
2143        let err = parse_frame_header(&mut line_it).unwrap_err();
2144
2145        assert!(matches!(err, ParseError::ValidationError(_)));
2146        assert!(err.to_string().contains("cell"));
2147    }
2148
2149    #[test]
2150    fn test_validate_true_rejects_non_physical_cell_geometry() {
2151        let lines = vec![
2152            "PREBOX1",
2153            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true}",
2154            "0.0 20.0 30.0",
2155            "90.0 180.0 90.0",
2156            "POSTBOX1",
2157            "POSTBOX2",
2158            "1",
2159            "1",
2160            "12.011",
2161        ];
2162        let mut line_it = lines.iter().copied();
2163        let err = parse_frame_header(&mut line_it).unwrap_err();
2164
2165        assert!(matches!(err, ParseError::ValidationError(_)));
2166        assert!(err.to_string().contains("cell"));
2167    }
2168
2169    #[test]
2170    fn test_validate_true_rejects_reserved_metadata_type_mismatch() {
2171        let lines = vec![
2172            "PREBOX1",
2173            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"energy\":\"low\"}",
2174            "10.0 20.0 30.0",
2175            "90.0 90.0 90.0",
2176            "POSTBOX1",
2177            "POSTBOX2",
2178            "1",
2179            "1",
2180            "12.011",
2181        ];
2182        let mut line_it = lines.iter().copied();
2183        let err = parse_frame_header(&mut line_it).unwrap_err();
2184
2185        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2186        assert!(err.to_string().contains("energy"));
2187    }
2188
2189    #[test]
2190    fn test_validate_true_rejects_malformed_bonds() {
2191        let lines = vec![
2192            "PREBOX1",
2193            "{\"con_spec_version\":2,\"sections\":[],\"validate\":true,\"bonds\":[[0]]}",
2194            "10.0 20.0 30.0",
2195            "90.0 90.0 90.0",
2196            "POSTBOX1",
2197            "POSTBOX2",
2198            "1",
2199            "1",
2200            "12.011",
2201        ];
2202        let mut line_it = lines.iter().copied();
2203        let err = parse_frame_header(&mut line_it).unwrap_err();
2204        assert!(matches!(err, ParseError::InvalidMetadataJson(_)));
2205        assert!(err.to_string().contains("bonds"));
2206    }
2207
2208    #[test]
2209    fn test_bonds_metadata_round_trip_in_header() {
2210        use crate::types::{meta, Bond};
2211        let lines = vec![
2212            "PREBOX1",
2213            "{\"con_spec_version\":2,\"bonds\":[[0,1],{\"i\":0,\"j\":2,\"order\":1}]}",
2214            "10.0 20.0 30.0",
2215            "90.0 90.0 90.0",
2216            "POSTBOX1",
2217            "POSTBOX2",
2218            "1",
2219            "1",
2220            "12.011",
2221        ];
2222        let mut line_it = lines.iter().copied();
2223        let header = parse_frame_header(&mut line_it).expect("header");
2224        let bonds = header.bonds();
2225        assert_eq!(bonds.len(), 2);
2226        assert_eq!(bonds[0], Bond::new(0, 1));
2227        assert_eq!(bonds[1].i, 0);
2228        assert_eq!(bonds[1].j, 2);
2229        assert_eq!(bonds[1].order, Some(1));
2230        assert!(header.metadata.contains_key(meta::BONDS));
2231    }
2232
2233    #[test]
2234    fn test_parse_velocity_section_absent() {
2235        let lines = vec![
2236            "PREBOX1",
2237            "{\"con_spec_version\":2}",
2238            "10.0 20.0 30.0",
2239            "90.0 90.0 90.0",
2240            "POSTBOX1",
2241            "POSTBOX2",
2242            "1",
2243            "1",
2244            "12.011",
2245            "C",
2246            "Coordinates of Component 1",
2247            "0.0 0.0 0.0 0.0 1",
2248        ];
2249        let mut line_it = lines.iter().copied().peekable();
2250        let mut frame = parse_single_frame(&mut line_it).expect("parse should succeed");
2251        let has_vel = parse_velocity_section(&mut line_it, &frame.header, &mut frame.atom_data)
2252            .expect("should succeed with no velocities");
2253        assert!(!has_vel);
2254        assert_eq!(frame.atom_data[0].velocity, None);
2255    }
2256
2257    #[test]
2258    fn test_parse_line_of_range_f64_exact() {
2259        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0 42", 4, 5, &[0.0; 5]).unwrap();
2260        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 42.0]);
2261    }
2262
2263    #[test]
2264    fn test_byte_scan_stack_parses_realistic_coord_line() {
2265        // Typical CON atom line: x y z fixed_mask [atom_id]
2266        let line = "   0.63939999999999997    0.90449999999999997   -0.00009999999999977 1    0";
2267        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
2268        let mut buf = [0.0f64; 5];
2269        let n = parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
2270        assert_eq!(n, 5);
2271        assert!((buf[0] - 0.6394).abs() < 1e-4);
2272        assert!((buf[1] - 0.9045).abs() < 1e-4);
2273        assert_eq!(buf[3] as u8, 1);
2274        assert_eq!(buf[4] as u64, 0);
2275        // trailing junk must error
2276        let bad = "1.0 2.0 3.0 1 0 EXTRA";
2277        assert!(parse_line_of_range_f64_stack(bad, 4, 5, &defaults, &mut buf).is_err());
2278        // partial non-boundary token must error
2279        let glued = "1.0 2.0 3.0abc 1 0";
2280        assert!(parse_line_of_range_f64_stack(glued, 4, 5, &defaults, &mut buf).is_err());
2281    }
2282
2283    #[test]
2284    fn test_parse_line_of_range_f64_stack_matches_vec_api() {
2285        let defaults = [0.0, 0.0, 0.0, 0.0, 7.0];
2286        let line = "1.0 2.0 3.0 0.0";
2287        let mut buf = [0.0f64; 5];
2288        parse_line_of_range_f64_stack(line, 4, 5, &defaults, &mut buf).unwrap();
2289        let via_vec = parse_line_of_range_f64(line, 4, 5, &defaults).unwrap();
2290        assert_eq!(&buf[..5], via_vec.as_slice());
2291        assert_eq!(buf[4], 7.0);
2292    }
2293
2294    #[test]
2295    fn test_parse_line_of_range_f64_padded() {
2296        let defaults = [0.0, 0.0, 0.0, 0.0, 99.0];
2297        let vals = parse_line_of_range_f64("1.0 2.0 3.0 0.0", 4, 5, &defaults).unwrap();
2298        assert_eq!(vals, vec![1.0, 2.0, 3.0, 0.0, 99.0]);
2299    }
2300
2301    #[test]
2302    fn test_parse_line_of_range_f64_too_few() {
2303        let result = parse_line_of_range_f64("1.0 2.0 3.0", 4, 5, &[0.0; 5]);
2304        assert!(result.is_err());
2305    }
2306
2307    #[test]
2308    fn test_parse_line_of_range_f64_too_many() {
2309        let result = parse_line_of_range_f64("1.0 2.0 3.0 0.0 5.0 6.0", 4, 5, &[0.0; 5]);
2310        assert!(result.is_err());
2311    }
2312
2313    #[test]
2314    fn test_parse_all_four_column_lines() {
2315        // All atom lines have only 4 columns; atom_index defaults to sequential.
2316        let lines = vec![
2317            "PREBOX1",
2318            "{\"con_spec_version\":2}",
2319            "10.0 10.0 10.0",
2320            "90.0 90.0 90.0",
2321            "POSTBOX1",
2322            "POSTBOX2",
2323            "1",
2324            "3",
2325            "12.011",
2326            "C",
2327            "Coordinates of Component 1",
2328            "0.0 0.0 0.0 0",
2329            "1.0 0.0 0.0 0",
2330            "2.0 0.0 0.0 1",
2331        ];
2332        let mut line_it = lines.iter().copied();
2333        let frame = parse_single_frame(&mut line_it).unwrap();
2334        assert_eq!(frame.atom_data[0].atom_id, 0);
2335        assert_eq!(frame.atom_data[1].atom_id, 1);
2336        assert_eq!(frame.atom_data[2].atom_id, 2);
2337        assert!(frame.atom_data[2].is_fixed());
2338    }
2339}