Skip to main content

readcon_core/
writer.rs

1use crate::types::{
2    ConFrame, ConFrameBuilder, SECTION_CHARGES, SECTION_DISPLACEMENTS, SECTION_ENERGIES,
3    SECTION_FORCES, SECTION_MAGMOMS, SECTION_SPINS, SECTION_SPREADS, SECTION_VELOCITIES,
4    encode_fixed_bitmask, meta,
5};
6use serde_json::{Value, json};
7use std::collections::BTreeMap;
8use std::fs::File;
9use std::io::{self, BufWriter, Write};
10use std::path::Path;
11
12/// Failure from [`ConFrameWriter`]. I/O stays distinct from format errors
13/// so `InvalidUnits` and `InvalidCount` remain distinguishable (CON-error.1).
14#[derive(Debug)]
15pub enum WriteError {
16    Io(io::Error),
17    Format(crate::error::ParseError),
18}
19
20impl std::fmt::Display for WriteError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            WriteError::Io(e) => write!(f, "{e}"),
24            WriteError::Format(e) => write!(f, "{e}"),
25        }
26    }
27}
28
29impl std::error::Error for WriteError {
30    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
31        match self {
32            WriteError::Io(e) => Some(e),
33            WriteError::Format(e) => Some(e),
34        }
35    }
36}
37
38impl From<io::Error> for WriteError {
39    fn from(e: io::Error) -> Self {
40        WriteError::Io(e)
41    }
42}
43
44impl From<WriteError> for io::Error {
45    fn from(e: WriteError) -> Self {
46        match e {
47            WriteError::Io(e) => e,
48            WriteError::Format(err) => io::Error::new(io::ErrorKind::InvalidData, err.to_string()),
49        }
50    }
51}
52
53/// Numeric text representation for CON frame fields.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum FloatFormat {
56    /// A fixed number of digits after the decimal point.
57    DecimalPlaces(usize),
58    /// The shortest scientific representation preserving each finite f64.
59    /// Signed zero is preserved; decimal precision does not limit this mode.
60    RoundTrip,
61}
62
63impl Default for FloatFormat {
64    fn default() -> Self {
65        // CON-num.6 (D13): the default writer round-trips binary64.
66        Self::RoundTrip
67    }
68}
69
70/// A writer that can serialize and write `ConFrame` objects to any output stream.
71///
72/// This struct encapsulates a writer (like a file) and provides a high-level API
73/// for writing simulation frames in the `.con` format.
74///
75/// # Example
76/// ```no_run
77/// # use std::fs::File;
78/// # use readcon_core::types::ConFrame;
79/// # use readcon_core::writer::ConFrameWriter;
80/// # let frames: Vec<ConFrame> = Vec::new();
81/// let mut writer = ConFrameWriter::from_path("output.con").unwrap();
82/// writer.extend(frames.iter()).unwrap();
83/// ```
84pub struct ConFrameWriter<W: Write> {
85    writer: BufWriter<W>,
86    float_format: FloatFormat,
87    /// When true: sort metadata keys in JSON and emit sections in canonical
88    /// order (velocities, forces, energies). Opt-in. Float text follows
89    /// `float_format` (round-trip unless the caller asked for decimals).
90    canonical: bool,
91    /// Cache for the JSON metadata line: when consecutive frames share
92    /// the same (spec_version, sections-set, metadata) triple the
93    /// serialized JSON object is identical, so reusing the cached
94    /// string skips the per-frame `serde_json::Map::insert` rebuild
95    /// and re-serialisation. Hot for trajectory writes where every
96    /// frame has the same `units` / `potential` / `validate` keys.
97    metadata_cache: Option<MetadataCacheEntry>,
98    /// One frame of CON text. Filled then flushed with a single `write_all`
99    /// so the sink is not entered once per atom line.
100    scratch: Vec<u8>,
101    /// Reused shortest-round-trip buffer (ryu). One per writer, not per float.
102    ryu: ryu::Buffer,
103}
104
105#[derive(Debug)]
106struct MetadataCacheEntry {
107    /// Snapshot of the inputs that fully determine the serialized JSON
108    /// metadata line. Cheap to clone; cheaper than re-serialising the
109    /// whole map on every frame.
110    spec_version: u32,
111    has_velocities: bool,
112    has_forces: bool,
113    has_energies: bool,
114    has_charges: bool,
115    has_spins: bool,
116    has_magmoms: bool,
117    has_displacements: bool,
118    has_spreads: bool,
119    metadata: std::collections::BTreeMap<String, serde_json::Value>,
120    /// Cached serialised metadata line (without trailing newline).
121    serialized: String,
122}
123
124impl MetadataCacheEntry {
125    fn matches(
126        &self,
127        spec_version: u32,
128        has_velocities: bool,
129        has_forces: bool,
130        has_energies: bool,
131        has_charges: bool,
132        has_spins: bool,
133        has_magmoms: bool,
134        has_displacements: bool,
135        has_spreads: bool,
136        metadata: &std::collections::BTreeMap<String, serde_json::Value>,
137    ) -> bool {
138        self.spec_version == spec_version
139            && self.has_velocities == has_velocities
140            && self.has_forces == has_forces
141            && self.has_energies == has_energies
142            && self.has_charges == has_charges
143            && self.has_spins == has_spins
144            && self.has_magmoms == has_magmoms
145            && self.has_displacements == has_displacements
146            && self.has_spreads == has_spreads
147            && self.metadata.len() == metadata.len()
148            && self
149                .metadata
150                .iter()
151                .zip(metadata)
152                .all(|((ka, a), (kb, b))| ka == kb && metadata_value_eq(a, b))
153    }
154}
155
156// Cache equality preserves the numeric representation, including signed zero,
157// throughout nested JSON values.
158fn metadata_value_eq(a: &serde_json::Value, b: &serde_json::Value) -> bool {
159    use serde_json::Value;
160    match (a, b) {
161        (Value::Number(a), Value::Number(b)) if a.is_f64() && b.is_f64() => {
162            a.as_f64().map(f64::to_bits) == b.as_f64().map(f64::to_bits)
163        }
164        (Value::Array(a), Value::Array(b)) => {
165            a.len() == b.len() && a.iter().zip(b).all(|(a, b)| metadata_value_eq(a, b))
166        }
167        (Value::Object(a), Value::Object(b)) => {
168            a.len() == b.len()
169                && a.iter()
170                    .all(|(key, a)| b.get(key).is_some_and(|b| metadata_value_eq(a, b)))
171        }
172        _ => a == b,
173    }
174}
175
176/// CON-section.16. Minor 2 and later must list, in `section_arity`, every
177/// emitted section that minor 1 does not reserve.
178fn require_minor2_section_arity(
179    minor: u64,
180    sections: &[serde_json::Value],
181    metadata: &serde_json::Map<String, serde_json::Value>,
182) -> Result<(), WriteError> {
183    if minor < 2 {
184        return Ok(());
185    }
186    for name in sections.iter().filter_map(|value| value.as_str()) {
187        if crate::types::reserved_section_value_arity(name).is_some() {
188            continue;
189        }
190        let listed = metadata
191            .get(meta::SECTION_ARITY)
192            .and_then(|value| value.as_object())
193            .and_then(|obj| obj.get(name))
194            .and_then(|value| value.as_u64())
195            .is_some_and(|n| n >= 1);
196        if !listed {
197            return Err(WriteError::Format(
198                crate::error::ParseError::ValidationError(format!(
199                    "minor >= 2 must list {name} in section_arity"
200                )),
201            ));
202        }
203    }
204    Ok(())
205}
206
207// General implementation for any type that implements `Write`.
208impl<W: Write> ConFrameWriter<W> {
209    /// Creates a new `ConFrameWriter` that wraps a given writer.
210    ///
211    /// # Arguments
212    ///
213    /// * `writer` - Any type that implements `std::io::Write`, e.g., a `File`.
214    pub fn new(writer: W) -> Self {
215        Self::with_float_format(writer, FloatFormat::default())
216    }
217
218    /// Creates a new `ConFrameWriter` with a custom floating-point precision.
219    ///
220    /// # Arguments
221    ///
222    /// * `writer` - Any type that implements `std::io::Write`.
223    /// * `precision` - Number of decimal places for floating-point output.
224    pub fn with_precision(writer: W, precision: usize) -> Self {
225        Self::with_float_format(writer, FloatFormat::DecimalPlaces(precision))
226    }
227
228    /// Creates a writer with an explicit numeric text representation.
229    ///
230    /// `FloatFormat::RoundTrip` retains small forces and coordinates exactly.
231    /// Metadata floats use the JSON serializer independently of this setting.
232    pub fn with_float_format(writer: W, float_format: FloatFormat) -> Self {
233        Self {
234            writer: BufWriter::new(writer),
235            float_format,
236            canonical: false,
237            metadata_cache: None,
238            scratch: Vec::with_capacity(16 * 1024),
239            ryu: ryu::Buffer::new(),
240        }
241    }
242
243    /// Opt-in **canonical** serialization: BTree-ordered metadata keys in JSON,
244    /// fixed section order. Use for corpus
245    /// materialization and content-stable hashes; not required for on-disk fidelity
246    /// of spans preserved from `next_with_raw_span`.
247    pub fn canonical(mut self, on: bool) -> Self {
248        self.set_canonical(on);
249        self
250    }
251
252    /// Set or clear canonical mode on an existing writer (C ABI / FFI).
253    pub fn set_canonical(&mut self, on: bool) {
254        self.canonical = on;
255        if on {
256            self.metadata_cache = None;
257        }
258    }
259
260    /// Whether canonical serialization is enabled.
261    pub fn is_canonical(&self) -> bool {
262        self.canonical
263    }
264
265    fn refresh_metadata_cache(&mut self, frame: &ConFrame) -> Result<(), WriteError> {
266        let spec_version = frame.header.spec_version;
267        let has_vel = frame.has_velocities();
268        let has_frc = frame.has_forces();
269        let has_eng = frame.has_energies();
270        let has_chg = frame.has_charges();
271        let has_spn = frame.has_spins();
272        let has_mm = frame.has_magmoms();
273        let has_dsp = frame.has_displacements();
274        let has_spr = frame.has_spreads();
275
276        let cache_hit = !self.canonical
277            && self.metadata_cache.as_ref().is_some_and(|c| {
278                c.matches(
279                    spec_version,
280                    has_vel,
281                    has_frc,
282                    has_eng,
283                    has_chg,
284                    has_spn,
285                    has_mm,
286                    has_dsp,
287                    has_spr,
288                    &frame.header.metadata,
289                )
290            });
291        if cache_hit {
292            return Ok(());
293        }
294
295        let mut meta_obj = serde_json::Map::new();
296        meta_obj.insert(meta::CON_SPEC_VERSION.into(), json!(spec_version));
297        let mut sections = Vec::new();
298        if has_vel {
299            sections.push(json!(SECTION_VELOCITIES));
300        }
301        if has_frc {
302            sections.push(json!(SECTION_FORCES));
303        }
304        if has_eng {
305            sections.push(json!(SECTION_ENERGIES));
306        }
307        if has_chg {
308            sections.push(json!(SECTION_CHARGES));
309        }
310        if has_spn {
311            sections.push(json!(SECTION_SPINS));
312        }
313        if has_mm {
314            sections.push(json!(SECTION_MAGMOMS));
315        }
316        if has_dsp {
317            sections.push(json!(SECTION_DISPLACEMENTS));
318        }
319        if has_spr {
320            sections.push(json!(SECTION_SPREADS));
321        }
322        let validate = frame
323            .header
324            .metadata
325            .get(meta::VALIDATE)
326            .and_then(|value| value.as_bool())
327            .unwrap_or(false);
328        if !sections.is_empty() || validate {
329            meta_obj.insert(meta::SECTIONS.into(), json!(sections));
330        }
331        for (k, v) in &frame.header.metadata {
332            if k == meta::CON_SPEC_VERSION || k == meta::SECTIONS {
333                continue;
334            }
335            meta_obj.insert(k.clone(), v.clone());
336        }
337        if let Some(u) = meta_obj.get(meta::UNITS).cloned() {
338            // D11: an invalid unit fails the write. Do not substitute a default.
339            let c = crate::units::canonicalize_units_object(&u).map_err(|e| {
340                WriteError::Format(crate::error::ParseError::InvalidUnits(e.to_string()))
341            })?;
342            meta_obj.insert(meta::UNITS.into(), c);
343        }
344        if spec_version >= 3 {
345            match meta_obj.get(meta::UNITS) {
346                None => {
347                    return Err(WriteError::Format(crate::error::ParseError::InvalidUnits(
348                        "con_spec_version >= 3 requires units.length and units.energy".into(),
349                    )));
350                }
351                Some(u) => {
352                    crate::units::validate_v3_units_metadata(u).map_err(|e| {
353                        WriteError::Format(crate::error::ParseError::InvalidUnits(e.to_string()))
354                    })?;
355                }
356            }
357        }
358        let minor = frame
359            .header
360            .metadata
361            .get(meta::CON_SPEC_MINOR)
362            .and_then(|v| v.as_u64())
363            .unwrap_or(0);
364        if spec_version >= 3 && minor >= 1 && has_vel {
365            let time_ok = meta_obj
366                .get(meta::UNITS)
367                .and_then(|u| u.get("time"))
368                .and_then(|v| v.as_str())
369                .is_some_and(|s| !s.trim().is_empty());
370            if !time_ok {
371                return Err(WriteError::Format(crate::error::ParseError::InvalidUnits(
372                    "minor >= 1 requires units.time when a velocities section is present".into(),
373                )));
374            }
375        }
376        // A minor of 2 or later lists every section minor 1 does not reserve
377        // (CON-section.16). Reserved names need no entry.
378        require_minor2_section_arity(minor, &sections, &meta_obj)?;
379        let serialized = serde_json::Value::Object(meta_obj).to_string();
380        self.metadata_cache = Some(MetadataCacheEntry {
381            spec_version,
382            has_velocities: has_vel,
383            has_forces: has_frc,
384            has_energies: has_eng,
385            has_charges: has_chg,
386            has_spins: has_spn,
387            has_magmoms: has_mm,
388            has_displacements: has_dsp,
389            has_spreads: has_spr,
390            metadata: frame.header.metadata.clone(),
391            serialized,
392        });
393        Ok(())
394    }
395
396    /// Writes a single `ConFrame` to the output stream.
397    pub fn write_frame(&mut self, frame: &ConFrame) -> Result<(), WriteError> {
398        let zero_frame = frame.header.natm_types == 0
399            && frame.atom_data.is_empty()
400            && frame.header.natms_per_type.is_empty()
401            && frame.header.masses_per_type.is_empty();
402        if zero_frame && !frame.header.sections.is_empty() {
403            return Err(WriteError::Format(crate::error::ParseError::InvalidCount(
404                "a zero-atom frame cannot declare sections".into(),
405            )));
406        }
407        if !zero_frame
408            && (frame.header.natm_types == 0
409                || frame.atom_data.is_empty()
410                || frame.header.natms_per_type.iter().any(|n| *n == 0))
411        {
412            return Err(WriteError::Format(crate::error::ParseError::InvalidCount(
413                "each per-type count must be >= 1".into(),
414            )));
415        }
416        ensure_unique_ids(&frame.atom_data)?;
417        let prec = self.float_format;
418        let meta_line = if frame.header.spec_version < 2 {
419            // Spec 1 line 2 is free text. Do not re-emit JSON major 0 or 1 (D15).
420            frame.header.prebox_header.metadata_line().to_string()
421        } else {
422            self.refresh_metadata_cache(frame)?;
423            self.metadata_cache
424                .as_ref()
425                .expect("metadata_cache populated above")
426                .serialized
427                .clone()
428        };
429        let (boxl, angles) = match frame.header.lattice_vectors() {
430            Some(vecs) => crate::types::lengths_and_angles(vecs),
431            None => (frame.header.boxl, frame.header.angles),
432        };
433        self.scratch.clear();
434        {
435            let buf = &mut self.scratch;
436            let ryu = &mut self.ryu;
437
438            // --- Write the 9-line Header ---
439            let _ = writeln!(buf, "{}", frame.header.prebox_header.user);
440            let _ = writeln!(buf, "{meta_line}");
441            push_f64(buf, ryu, boxl[0], prec);
442            buf.push(b' ');
443            push_f64(buf, ryu, boxl[1], prec);
444            buf.push(b' ');
445            push_f64(buf, ryu, boxl[2], prec);
446            buf.push(b'\n');
447            push_f64(buf, ryu, angles[0], prec);
448            buf.push(b' ');
449            push_f64(buf, ryu, angles[1], prec);
450            buf.push(b' ');
451            push_f64(buf, ryu, angles[2], prec);
452            buf.push(b'\n');
453            let _ = writeln!(buf, "{}", frame.header.postbox_header[0]);
454            let _ = writeln!(buf, "{}", frame.header.postbox_header[1]);
455            let _ = writeln!(buf, "{}", frame.header.natm_types);
456
457            for (i, n) in frame.header.natms_per_type.iter().enumerate() {
458                if i > 0 {
459                    buf.push(b' ');
460                }
461                push_u64(buf, *n as u64);
462            }
463            buf.push(b'\n');
464
465            for (i, m) in frame.header.masses_per_type.iter().enumerate() {
466                if i > 0 {
467                    buf.push(b' ');
468                }
469                push_f64(buf, ryu, *m, prec);
470            }
471            buf.push(b'\n');
472
473            // --- Write the Atom Data ---
474            let mut atom_idx_offset = 0;
475            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
476                let symbol = &frame.atom_data[atom_idx_offset].symbol;
477                let _ = writeln!(buf, "{symbol}");
478                let _ = writeln!(buf, "Coordinates of Component {}", type_idx + 1);
479
480                for i in 0..num_atoms_in_type {
481                    let atom = &frame.atom_data[atom_idx_offset + i];
482                    push_xyz_line(
483                        buf,
484                        ryu,
485                        atom.x,
486                        atom.y,
487                        atom.z,
488                        prec,
489                        written_fixed_flag(atom.fixed, frame.header.spec_version),
490                        atom.atom_id,
491                    );
492                }
493                atom_idx_offset += num_atoms_in_type;
494            }
495
496            // --- Write optional velocity section ---
497            if frame.has_velocities() {
498                buf.push(b'\n');
499
500                let mut vel_idx_offset = 0;
501                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
502                {
503                    let symbol = &frame.atom_data[vel_idx_offset].symbol;
504                    let _ = writeln!(buf, "{symbol}");
505                    let _ = writeln!(buf, "Velocities of Component {}", type_idx + 1);
506
507                    for i in 0..num_atoms_in_type {
508                        let atom = &frame.atom_data[vel_idx_offset + i];
509                        let [vx, vy, vz] = atom.velocity().unwrap_or([0.0; 3]);
510                        push_xyz_line(
511                            buf,
512                            ryu,
513                            vx,
514                            vy,
515                            vz,
516                            prec,
517                            written_fixed_flag(atom.fixed, frame.header.spec_version),
518                            atom.atom_id,
519                        );
520                    }
521                    vel_idx_offset += num_atoms_in_type;
522                }
523            }
524
525            // --- Write optional force section ---
526            if frame.has_forces() {
527                buf.push(b'\n');
528
529                let mut force_idx_offset = 0;
530                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
531                {
532                    let symbol = &frame.atom_data[force_idx_offset].symbol;
533                    let _ = writeln!(buf, "{symbol}");
534                    let _ = writeln!(buf, "Forces of Component {}", type_idx + 1);
535
536                    for i in 0..num_atoms_in_type {
537                        let atom = &frame.atom_data[force_idx_offset + i];
538                        let [fx, fy, fz] = atom.force().unwrap_or([0.0; 3]);
539                        push_xyz_line(
540                            buf,
541                            ryu,
542                            fx,
543                            fy,
544                            fz,
545                            prec,
546                            written_fixed_flag(atom.fixed, frame.header.spec_version),
547                            atom.atom_id,
548                        );
549                    }
550                    force_idx_offset += num_atoms_in_type;
551                }
552            }
553
554            // --- Write optional energies section ---
555            if frame.has_energies() {
556                buf.push(b'\n');
557
558                let mut energy_idx_offset = 0;
559                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
560                {
561                    let symbol = &frame.atom_data[energy_idx_offset].symbol;
562                    let _ = writeln!(buf, "{symbol}");
563                    let _ = writeln!(buf, "Energies of Component {}", type_idx + 1);
564
565                    for i in 0..num_atoms_in_type {
566                        let atom = &frame.atom_data[energy_idx_offset + i];
567                        let e = atom.energy().unwrap_or(0.0);
568                        push_scalar_line(
569                            buf,
570                            ryu,
571                            e,
572                            prec,
573                            written_fixed_flag(atom.fixed, frame.header.spec_version),
574                            atom.atom_id,
575                        );
576                    }
577                    energy_idx_offset += num_atoms_in_type;
578                }
579            }
580
581            if frame.has_charges() {
582                buf.push(b'\n');
583                let mut off = 0;
584                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
585                {
586                    let symbol = &frame.atom_data[off].symbol;
587                    let _ = writeln!(buf, "{symbol}");
588                    let _ = writeln!(buf, "Charges of Component {}", type_idx + 1);
589                    for i in 0..num_atoms_in_type {
590                        let atom = &frame.atom_data[off + i];
591                        let q = atom.charge().unwrap_or(0.0);
592                        push_scalar_line(
593                            buf,
594                            ryu,
595                            q,
596                            prec,
597                            written_fixed_flag(atom.fixed, frame.header.spec_version),
598                            atom.atom_id,
599                        );
600                    }
601                    off += num_atoms_in_type;
602                }
603            }
604
605            if frame.has_spins() {
606                buf.push(b'\n');
607                let mut off = 0;
608                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
609                {
610                    let symbol = &frame.atom_data[off].symbol;
611                    let _ = writeln!(buf, "{symbol}");
612                    let _ = writeln!(buf, "Spins of Component {}", type_idx + 1);
613                    for i in 0..num_atoms_in_type {
614                        let atom = &frame.atom_data[off + i];
615                        let s = atom.spin().unwrap_or(0.0);
616                        push_scalar_line(
617                            buf,
618                            ryu,
619                            s,
620                            prec,
621                            written_fixed_flag(atom.fixed, frame.header.spec_version),
622                            atom.atom_id,
623                        );
624                    }
625                    off += num_atoms_in_type;
626                }
627            }
628
629            if frame.has_magmoms() {
630                buf.push(b'\n');
631                let mut off = 0;
632                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
633                {
634                    let symbol = &frame.atom_data[off].symbol;
635                    let _ = writeln!(buf, "{symbol}");
636                    let _ = writeln!(buf, "Magmoms of Component {}", type_idx + 1);
637                    for i in 0..num_atoms_in_type {
638                        let atom = &frame.atom_data[off + i];
639                        let [mx, my, mz] = atom.magmom().unwrap_or([0.0; 3]);
640                        push_xyz_line(
641                            buf,
642                            ryu,
643                            mx,
644                            my,
645                            mz,
646                            prec,
647                            written_fixed_flag(atom.fixed, frame.header.spec_version),
648                            atom.atom_id,
649                        );
650                    }
651                    off += num_atoms_in_type;
652                }
653            }
654
655            if frame.has_displacements() {
656                buf.push(b'\n');
657                let mut off = 0;
658                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
659                {
660                    let symbol = &frame.atom_data[off].symbol;
661                    let _ = writeln!(buf, "{symbol}");
662                    let _ = writeln!(buf, "Displacements of Component {}", type_idx + 1);
663                    for i in 0..num_atoms_in_type {
664                        let atom = &frame.atom_data[off + i];
665                        let [dx, dy, dz] = atom.displacement().unwrap_or([0.0; 3]);
666                        push_xyz_line(
667                            buf,
668                            ryu,
669                            dx,
670                            dy,
671                            dz,
672                            prec,
673                            written_fixed_flag(atom.fixed, frame.header.spec_version),
674                            atom.atom_id,
675                        );
676                    }
677                    off += num_atoms_in_type;
678                }
679            }
680
681            if frame.has_spreads() {
682                buf.push(b'\n');
683                let mut off = 0;
684                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
685                {
686                    let symbol = &frame.atom_data[off].symbol;
687                    let _ = writeln!(buf, "{symbol}");
688                    let _ = writeln!(buf, "Spreads of Component {}", type_idx + 1);
689                    for i in 0..num_atoms_in_type {
690                        let atom = &frame.atom_data[off + i];
691                        let [sx, sy, sz] = atom.spread().unwrap_or([0.0; 3]);
692                        if !crate::types::spread_row_ok([sx, sy, sz]) {
693                            let err = if sx.is_finite() && sy.is_finite() && sz.is_finite() {
694                                crate::types::invalid_spread(off + i)
695                            } else {
696                                crate::error::ParseError::NonFiniteValue(format!(
697                                    "spreads atom {}",
698                                    off + i
699                                ))
700                            };
701                            return Err(WriteError::Format(err));
702                        }
703                        push_xyz_line(
704                            buf,
705                            ryu,
706                            sx,
707                            sy,
708                            sz,
709                            prec,
710                            written_fixed_flag(atom.fixed, frame.header.spec_version),
711                            atom.atom_id,
712                        );
713                    }
714                    off += num_atoms_in_type;
715                }
716            }
717        }
718
719        self.writer.write_all(&self.scratch)?;
720        Ok(())
721    }
722
723    /// Flushes buffered data to the underlying writer and reports errors.
724    pub fn flush(&mut self) -> io::Result<()> {
725        self.writer.flush()
726    }
727
728    /// Writes all frames from an iterator to the output stream.
729    ///
730    /// This is the most convenient way to write a multi-frame file.
731    pub fn extend<'a>(
732        &mut self,
733        frames: impl Iterator<Item = &'a ConFrame>,
734    ) -> Result<(), WriteError> {
735        for frame in frames {
736            self.write_frame(frame)?;
737        }
738        Ok(())
739    }
740}
741
742fn ensure_unique_ids(atoms: &[crate::types::AtomDatum]) -> Result<(), WriteError> {
743    let n = atoms.len();
744    if n == 0 {
745        return Ok(());
746    }
747    // Trajectory frames usually number atoms 0..n-1. That needs no set.
748    if atoms
749        .iter()
750        .enumerate()
751        .all(|(i, atom)| atom.atom_id == i as u64)
752    {
753        return Ok(());
754    }
755    let mut seen_ids = std::collections::HashSet::with_capacity(n);
756    for atom in atoms {
757        if !seen_ids.insert(atom.atom_id) {
758            return Err(WriteError::Format(
759                crate::error::ParseError::ValidationError(format!(
760                    "atom id {} is repeated in the frame",
761                    atom.atom_id
762                )),
763            ));
764        }
765    }
766    Ok(())
767}
768
769fn written_fixed_flag(fixed: [bool; 3], spec_version: u32) -> u8 {
770    if spec_version < 2 {
771        if fixed.iter().any(|axis| *axis) { 1 } else { 0 }
772    } else {
773        encode_fixed_bitmask(fixed)
774    }
775}
776
777fn push_u64(buf: &mut Vec<u8>, n: u64) {
778    push_u128(buf, u128::from(n));
779}
780
781fn push_u128(buf: &mut Vec<u8>, mut n: u128) {
782    let mut tmp = [0u8; 40];
783    let mut i = 40;
784    if n == 0 {
785        buf.push(b'0');
786        return;
787    }
788    while n > 0 {
789        i -= 1;
790        tmp[i] = b'0' + (n % 10) as u8;
791        n /= 10;
792    }
793    buf.extend_from_slice(&tmp[i..]);
794}
795
796fn push_f64(buf: &mut Vec<u8>, ryu: &mut ryu::Buffer, value: f64, format: FloatFormat) {
797    match format {
798        FloatFormat::DecimalPlaces(precision) => push_f64_prec(buf, value, precision),
799        FloatFormat::RoundTrip => {
800            // Shortest correctly-rounded spelling. `format` covers the
801            // non-finite fallback; finite values take the reused buffer.
802            let text = if value.is_finite() {
803                ryu.format_finite(value)
804            } else {
805                ryu.format(value)
806            };
807            buf.extend_from_slice(text.as_bytes());
808        }
809    }
810}
811
812/// Fixed-point `f64` with `prec` digits after the decimal, matching `{:.prec$}`.
813/// Non-finite values and `prec > 17` fall back to `std::fmt`.
814fn push_f64_prec(buf: &mut Vec<u8>, v: f64, prec: usize) {
815    // The six-decimal path uses fixed-point arithmetic; other precisions
816    // use the standard formatter.
817    if !v.is_finite() || prec != 6 {
818        let _ = write!(buf, "{v:.prec$}");
819        return;
820    }
821    if v.is_sign_negative() {
822        buf.push(b'-');
823    }
824    let ax = v.abs();
825    if prec == 0 {
826        push_u128(buf, ax.round() as u128);
827        return;
828    }
829    let scale = 10u128.pow(prec as u32);
830    let n = (ax * scale as f64).round() as u128;
831    let int_part = n / scale;
832    let frac = n % scale;
833    push_u128(buf, int_part);
834    buf.push(b'.');
835    let mut tmp = [b'0'; 20];
836    let mut x = frac;
837    let mut i = prec;
838    while i > 0 {
839        i -= 1;
840        tmp[i] = b'0' + (x % 10) as u8;
841        x /= 10;
842    }
843    buf.extend_from_slice(&tmp[..prec]);
844}
845
846fn push_xyz_line(
847    buf: &mut Vec<u8>,
848    ryu: &mut ryu::Buffer,
849    x: f64,
850    y: f64,
851    z: f64,
852    prec: FloatFormat,
853    fixed: u8,
854    atom_id: u64,
855) {
856    push_f64(buf, ryu, x, prec);
857    buf.push(b' ');
858    push_f64(buf, ryu, y, prec);
859    buf.push(b' ');
860    push_f64(buf, ryu, z, prec);
861    buf.push(b' ');
862    push_u64(buf, u64::from(fixed));
863    buf.push(b' ');
864    push_u64(buf, atom_id);
865    buf.push(b'\n');
866}
867
868fn push_scalar_line(
869    buf: &mut Vec<u8>,
870    ryu: &mut ryu::Buffer,
871    v: f64,
872    prec: FloatFormat,
873    fixed: u8,
874    atom_id: u64,
875) {
876    push_f64(buf, ryu, v, prec);
877    buf.push(b' ');
878    push_u64(buf, u64::from(fixed));
879    buf.push(b' ');
880    push_u64(buf, atom_id);
881    buf.push(b'\n');
882}
883
884/// Record `units.time` for a v1 or v2 frame that carries velocities.
885///
886/// Those velocities are already in [`crate::units::NATURAL_TIME_UNIT`]
887/// (CON-units.8). [`ConFrameBuilder::build`] stamps minor 1, and a minor
888/// of 1 or later rejects velocities with no `units.time` (CON-units.11).
889/// A 3.0 frame is treated the same way, overwriting any `units.time`
890/// label. A 3.1 frame is left alone so a caller who omitted time still fails.
891pub(crate) fn stamp_legacy_velocity_time(
892    meta: &mut BTreeMap<String, Value>,
893    spec_version: u32,
894    has_velocity: bool,
895) {
896    if !has_velocity {
897        return;
898    }
899    let minor = meta
900        .get(meta::CON_SPEC_MINOR)
901        .and_then(Value::as_u64)
902        .unwrap_or(0);
903    if spec_version >= 3 && minor >= 1 {
904        return;
905    }
906    // Versions 1, 2 and 3.0 store velocities in the natural unit whatever
907    // `units.time` says (CON-units.8, CON-units.9). Writing minor 1 makes
908    // the label authoritative (CON-units.10), so replace any label rather
909    // than keep one the old file never honoured.
910    let mut units = match meta.get(meta::UNITS).cloned() {
911        Some(Value::Object(map)) => Value::Object(map),
912        Some(_) => return,
913        None => crate::units::default_v3_units_json(),
914    };
915    if let Some(object) = units.as_object_mut() {
916        object.insert(
917            "time".to_string(),
918            Value::String(crate::units::NATURAL_TIME_UNIT.to_string()),
919        );
920        meta.insert(meta::UNITS.to_string(), units);
921        // The label now holds, so the frame is written as minor 1.
922        if meta.contains_key(meta::CON_SPEC_MINOR) {
923            meta.insert(meta::CON_SPEC_MINOR.to_string(), Value::from(1u64));
924        }
925    }
926}
927
928/// Rebuild `frame` the way the Python and Julia writers do.
929///
930/// [`ConFrameBuilder::build`] stamps `con_spec_minor` 1 when the key is
931/// absent (D15). A parsed frame does not carry that key unless the file
932/// did, and the writer does not invent it. Language bindings that turn a
933/// read frame back into atoms and metadata therefore emit the minor, and
934/// this is that rebuild.
935pub fn binding_rewrite_frame(frame: &ConFrame) -> Result<ConFrame, crate::error::ParseError> {
936    let mut meta = frame.header.metadata.clone();
937    meta.remove(meta::CON_SPEC_VERSION);
938    meta.remove(meta::SECTIONS);
939    let has_velocity = frame.atom_data.iter().any(|atom| atom.velocity().is_some());
940    stamp_legacy_velocity_time(&mut meta, frame.header.spec_version, has_velocity);
941
942    let mut builder = ConFrameBuilder::new(frame.header.boxl, frame.header.angles);
943    builder
944        .prebox_header(frame.header.prebox_header.user.as_str())
945        .postbox_header(frame.header.postbox_header.clone())
946        .metadata(meta);
947
948    let mut masses = Vec::with_capacity(frame.atom_data.len());
949    for (type_index, &count) in frame.header.natms_per_type.iter().enumerate() {
950        let mass = frame
951            .header
952            .masses_per_type
953            .get(type_index)
954            .copied()
955            .unwrap_or(0.0);
956        for _ in 0..count {
957            masses.push(mass);
958        }
959    }
960
961    for (index, atom) in frame.atom_data.iter().enumerate() {
962        let mass = masses.get(index).copied().unwrap_or(0.0);
963        builder.add_atom(
964            atom.symbol.as_ref(),
965            atom.x,
966            atom.y,
967            atom.z,
968            atom.fixed,
969            atom.atom_id,
970            mass,
971        );
972        if let Some(velocity) = atom.velocity() {
973            builder.with_velocity(velocity);
974        }
975        if let Some(force) = atom.force() {
976            builder.with_force(force);
977        }
978        if let Some(energy) = atom.energy() {
979            builder.with_energy(energy);
980        }
981        if let Some(charge) = atom.charge() {
982            builder.with_charge(charge);
983        }
984        if let Some(spin) = atom.spin() {
985            builder.with_spin(spin);
986        }
987        if let Some(magmom) = atom.magmom() {
988            builder.with_magmom(magmom);
989        }
990        if let Some(displacement) = atom.displacement() {
991            builder.with_displacement(displacement);
992        }
993        if let Some(spread) = atom.spread() {
994            builder.with_spread(spread);
995        }
996    }
997    builder.build()
998}
999
1000// Implementation block specifically for when the writer is a `File`.
1001impl ConFrameWriter<File> {
1002    /// Creates a new `ConFrameWriter` that writes to a file at the given path.
1003    ///
1004    /// This is a convenience function that creates the file and wraps it.
1005    pub fn from_path<P: AsRef<Path>>(path: P) -> io::Result<Self> {
1006        let file = File::create(path)?;
1007        Ok(Self::new(file))
1008    }
1009
1010    /// Creates a new `ConFrameWriter` that writes to a file with a custom precision.
1011    pub fn from_path_with_precision<P: AsRef<Path>>(path: P, precision: usize) -> io::Result<Self> {
1012        let file = File::create(path)?;
1013        Ok(Self::with_precision(file, precision))
1014    }
1015}
1016
1017// Gzip-compressed writer constructors.
1018impl ConFrameWriter<flate2::write::GzEncoder<File>> {
1019    /// Creates a gzip-compressed writer for the given path.
1020    pub fn from_path_gzip<P: AsRef<Path>>(path: P) -> io::Result<Self> {
1021        let encoder = crate::compression::gzip_writer(path.as_ref())?;
1022        Ok(Self::new(encoder))
1023    }
1024
1025    /// Creates a gzip-compressed writer with custom precision.
1026    pub fn from_path_gzip_with_precision<P: AsRef<Path>>(
1027        path: P,
1028        precision: usize,
1029    ) -> io::Result<Self> {
1030        let encoder = crate::compression::gzip_writer(path.as_ref())?;
1031        Ok(Self::with_precision(encoder, precision))
1032    }
1033}
1034
1035// Zstd-compressed writer constructors. Available only with the `zstd`
1036// Cargo feature.
1037#[cfg(feature = "zstd")]
1038impl ConFrameWriter<zstd::stream::write::AutoFinishEncoder<'static, File>> {
1039    /// Creates a zstd-compressed writer for the given path.
1040    pub fn from_path_zstd<P: AsRef<Path>>(path: P) -> io::Result<Self> {
1041        let encoder = crate::compression::zstd_writer(path.as_ref())?;
1042        Ok(Self::new(encoder))
1043    }
1044
1045    /// Creates a zstd-compressed writer with custom precision.
1046    pub fn from_path_zstd_with_precision<P: AsRef<Path>>(
1047        path: P,
1048        precision: usize,
1049    ) -> io::Result<Self> {
1050        let encoder = crate::compression::zstd_writer(path.as_ref())?;
1051        Ok(Self::with_precision(encoder, precision))
1052    }
1053}
1054
1055#[cfg(test)]
1056mod float_format_tests {
1057    use super::push_f64_prec;
1058
1059    fn formatted(v: f64, prec: usize) -> String {
1060        let mut buf = Vec::new();
1061        push_f64_prec(&mut buf, v, prec);
1062        String::from_utf8(buf).expect("utf8")
1063    }
1064
1065    #[test]
1066    fn matches_std_fixed_precision() {
1067        let vals = [
1068            0.0,
1069            -0.0,
1070            1.0,
1071            -1.0,
1072            0.9045,
1073            6.975_299_999_999_995,
1074            63.546,
1075            1.008,
1076            15.3456,
1077            90.0,
1078            218.0,
1079            1e-6,
1080            -1.23456789,
1081            10.0,
1082        ];
1083        for prec in [0usize, 6] {
1084            for v in vals {
1085                let got = formatted(v, prec);
1086                let exp = format!("{v:.prec$}");
1087                assert_eq!(got, exp, "v={v:?} prec={prec}");
1088            }
1089        }
1090    }
1091}
1092
1093#[cfg(test)]
1094mod section_arity_tests {
1095    use super::require_minor2_section_arity;
1096    use serde_json::json;
1097
1098    #[test]
1099    fn minor2_lists_a_new_section_in_section_arity() {
1100        let widgets = vec![json!("widgets")];
1101        let err = require_minor2_section_arity(2, &widgets, &serde_json::Map::new()).unwrap_err();
1102        let msg = err.to_string();
1103        assert!(msg.contains("section_arity"), "{msg}");
1104
1105        let mut listed = serde_json::Map::new();
1106        listed.insert("section_arity".into(), json!({"widgets": 3}));
1107        require_minor2_section_arity(2, &widgets, &listed).unwrap();
1108
1109        let reserved = vec![json!("velocities"), json!("energies")];
1110        require_minor2_section_arity(2, &reserved, &serde_json::Map::new()).unwrap();
1111        require_minor2_section_arity(1, &widgets, &serde_json::Map::new()).unwrap();
1112    }
1113}