readcon-core 0.13.1

An oxidized single and multiple CON file reader and writer with FFI bindings for ergonomic C/C++ usage.
Documentation
use crate::types::{
    ConFrame, SECTION_ENERGIES, SECTION_FORCES, SECTION_VELOCITIES, encode_fixed_bitmask, meta,
};
use serde_json::json;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;

/// Default floating-point precision used for writing coordinates, cell dimensions, and masses.
const DEFAULT_FLOAT_PRECISION: usize = 6;

/// A writer that can serialize and write `ConFrame` objects to any output stream.
///
/// This struct encapsulates a writer (like a file) and provides a high-level API
/// for writing simulation frames in the `.con` format.
///
/// # Example
/// ```no_run
/// # use std::fs::File;
/// # use readcon_core::types::ConFrame;
/// # use readcon_core::writer::ConFrameWriter;
/// # let frames: Vec<ConFrame> = Vec::new();
/// let mut writer = ConFrameWriter::from_path("output.con").unwrap();
/// writer.extend(frames.iter()).unwrap();
/// ```
pub struct ConFrameWriter<W: Write> {
    writer: BufWriter<W>,
    precision: usize,
    /// Cache for the JSON metadata line: when consecutive frames share
    /// the same (spec_version, sections-set, metadata) triple the
    /// serialized JSON object is identical, so reusing the cached
    /// string skips the per-frame `serde_json::Map::insert` rebuild
    /// and re-serialisation. Hot for trajectory writes where every
    /// frame has the same `units` / `potential` / `validate` keys.
    metadata_cache: Option<MetadataCacheEntry>,
}

#[derive(Debug)]
struct MetadataCacheEntry {
    /// Snapshot of the inputs that fully determine the serialized JSON
    /// metadata line. Cheap to clone; cheaper than re-serialising the
    /// whole map on every frame.
    spec_version: u32,
    has_velocities: bool,
    has_forces: bool,
    has_energies: bool,
    metadata: std::collections::BTreeMap<String, serde_json::Value>,
    /// Cached serialised metadata line (without trailing newline).
    serialized: String,
}

impl MetadataCacheEntry {
    fn matches(
        &self,
        spec_version: u32,
        has_velocities: bool,
        has_forces: bool,
        has_energies: bool,
        metadata: &std::collections::BTreeMap<String, serde_json::Value>,
    ) -> bool {
        self.spec_version == spec_version
            && self.has_velocities == has_velocities
            && self.has_forces == has_forces
            && self.has_energies == has_energies
            && &self.metadata == metadata
    }
}

// General implementation for any type that implements `Write`.
impl<W: Write> ConFrameWriter<W> {
    /// Creates a new `ConFrameWriter` that wraps a given writer.
    ///
    /// # Arguments
    ///
    /// * `writer` - Any type that implements `std::io::Write`, e.g., a `File`.
    pub fn new(writer: W) -> Self {
        Self {
            writer: BufWriter::new(writer),
            precision: DEFAULT_FLOAT_PRECISION,
            metadata_cache: None,
        }
    }

    /// Creates a new `ConFrameWriter` with a custom floating-point precision.
    ///
    /// # Arguments
    ///
    /// * `writer` - Any type that implements `std::io::Write`.
    /// * `precision` - Number of decimal places for floating-point output.
    pub fn with_precision(writer: W, precision: usize) -> Self {
        Self {
            writer: BufWriter::new(writer),
            precision,
            metadata_cache: None,
        }
    }

    /// Writes a single `ConFrame` to the output stream.
    pub fn write_frame(&mut self, frame: &ConFrame) -> io::Result<()> {
        let prec = self.precision;

        // --- Write the 9-line Header ---
        writeln!(self.writer, "{}", frame.header.prebox_header.user)?;

        // Line 2: serialised JSON metadata. The serialisation is
        // deterministic in (spec_version, has_*, metadata), so we
        // cache the previous frame's result and reuse it when the
        // inputs are unchanged. For trajectory writes where every
        // frame shares the same `units` / `potential` / `validate`
        // keys this avoids rebuilding and re-serialising the JSON
        // object on every frame.
        let spec_version = frame.header.spec_version;
        let has_vel = frame.has_velocities();
        let has_frc = frame.has_forces();
        let has_eng = frame.has_energies();

        let cache_hit = self
            .metadata_cache
            .as_ref()
            .is_some_and(|c| c.matches(spec_version, has_vel, has_frc, has_eng, &frame.header.metadata));

        if !cache_hit {
            let mut meta_obj = serde_json::Map::new();
            meta_obj.insert(
                meta::CON_SPEC_VERSION.into(),
                json!(spec_version),
            );
            let mut sections = Vec::new();
            if has_vel {
                sections.push(json!(SECTION_VELOCITIES));
            }
            if has_frc {
                sections.push(json!(SECTION_FORCES));
            }
            if has_eng {
                sections.push(json!(SECTION_ENERGIES));
            }
            let validate = frame
                .header
                .metadata
                .get(meta::VALIDATE)
                .and_then(|value| value.as_bool())
                .unwrap_or(false);
            if !sections.is_empty() || validate {
                meta_obj.insert(meta::SECTIONS.into(), json!(sections));
            }
            for (k, v) in &frame.header.metadata {
                if k == meta::CON_SPEC_VERSION || k == meta::SECTIONS {
                    continue;
                }
                meta_obj.insert(k.clone(), v.clone());
            }
            let serialized = serde_json::Value::Object(meta_obj).to_string();
            self.metadata_cache = Some(MetadataCacheEntry {
                spec_version,
                has_velocities: has_vel,
                has_forces: has_frc,
                has_energies: has_eng,
                metadata: frame.header.metadata.clone(),
                serialized,
            });
        }

        let cached = self
            .metadata_cache
            .as_ref()
            .expect("metadata_cache populated above");
        writeln!(self.writer, "{}", cached.serialized)?;
        writeln!(
            self.writer,
            "{1:.0$} {2:.0$} {3:.0$}",
            prec, frame.header.boxl[0], frame.header.boxl[1], frame.header.boxl[2]
        )?;
        writeln!(
            self.writer,
            "{1:.0$} {2:.0$} {3:.0$}",
            prec, frame.header.angles[0], frame.header.angles[1], frame.header.angles[2]
        )?;
        writeln!(self.writer, "{}", frame.header.postbox_header[0])?;
        writeln!(self.writer, "{}", frame.header.postbox_header[1])?;
        writeln!(self.writer, "{}", frame.header.natm_types)?;

        let natms_str: Vec<String> = frame
            .header
            .natms_per_type
            .iter()
            .map(|n| n.to_string())
            .collect();
        writeln!(self.writer, "{}", natms_str.join(" "))?;

        let masses_str: Vec<String> = frame
            .header
            .masses_per_type
            .iter()
            .map(|m| format!("{:.1$}", m, prec))
            .collect();
        writeln!(self.writer, "{}", masses_str.join(" "))?;

        // --- Write the Atom Data ---
        let mut atom_idx_offset = 0;
        for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
            let symbol = &frame.atom_data[atom_idx_offset].symbol;
            writeln!(self.writer, "{}", symbol)?;
            writeln!(self.writer, "Coordinates of Component {}", type_idx + 1)?;

            for i in 0..num_atoms_in_type {
                let atom = &frame.atom_data[atom_idx_offset + i];
                writeln!(
                    self.writer,
                    "{x:.prec$} {y:.prec$} {z:.prec$} {fixed_flag} {atom_id}",
                    prec = prec,
                    x = atom.x,
                    y = atom.y,
                    z = atom.z,
                    fixed_flag = encode_fixed_bitmask(atom.fixed),
                    atom_id = atom.atom_id
                )?;
            }
            atom_idx_offset += num_atoms_in_type;
        }

        // --- Write optional velocity section ---
        if frame.has_velocities() {
            // Blank separator line between coordinates and velocities
            writeln!(self.writer)?;

            let mut vel_idx_offset = 0;
            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
                let symbol = &frame.atom_data[vel_idx_offset].symbol;
                writeln!(self.writer, "{}", symbol)?;
                writeln!(self.writer, "Velocities of Component {}", type_idx + 1)?;

                for i in 0..num_atoms_in_type {
                    let atom = &frame.atom_data[vel_idx_offset + i];
                    let [vx, vy, vz] = atom.velocity.unwrap_or([0.0; 3]);
                    writeln!(
                        self.writer,
                        "{vx:.prec$} {vy:.prec$} {vz:.prec$} {fixed_flag} {atom_id}",
                        prec = prec,
                        fixed_flag = encode_fixed_bitmask(atom.fixed),
                        atom_id = atom.atom_id
                    )?;
                }
                vel_idx_offset += num_atoms_in_type;
            }
        }

        // --- Write optional force section ---
        if frame.has_forces() {
            // Blank separator line
            writeln!(self.writer)?;

            let mut force_idx_offset = 0;
            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
                let symbol = &frame.atom_data[force_idx_offset].symbol;
                writeln!(self.writer, "{}", symbol)?;
                writeln!(self.writer, "Forces of Component {}", type_idx + 1)?;

                for i in 0..num_atoms_in_type {
                    let atom = &frame.atom_data[force_idx_offset + i];
                    let [fx, fy, fz] = atom.force.unwrap_or([0.0; 3]);
                    writeln!(
                        self.writer,
                        "{fx:.prec$} {fy:.prec$} {fz:.prec$} {fixed_flag} {atom_id}",
                        prec = prec,
                        fixed_flag = encode_fixed_bitmask(atom.fixed),
                        atom_id = atom.atom_id
                    )?;
                }
                force_idx_offset += num_atoms_in_type;
            }
        }

        // --- Write optional energies section ---
        if frame.has_energies() {
            writeln!(self.writer)?;

            let mut energy_idx_offset = 0;
            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
                let symbol = &frame.atom_data[energy_idx_offset].symbol;
                writeln!(self.writer, "{}", symbol)?;
                writeln!(self.writer, "Energies of Component {}", type_idx + 1)?;

                for i in 0..num_atoms_in_type {
                    let atom = &frame.atom_data[energy_idx_offset + i];
                    let e = atom.energy.unwrap_or(0.0);
                    writeln!(
                        self.writer,
                        "{e:.prec$} {fixed_flag} {atom_id}",
                        prec = prec,
                        fixed_flag = encode_fixed_bitmask(atom.fixed),
                        atom_id = atom.atom_id
                    )?;
                }
                energy_idx_offset += num_atoms_in_type;
            }
        }

        Ok(())
    }

    /// Writes all frames from an iterator to the output stream.
    ///
    /// This is the most convenient way to write a multi-frame file.
    pub fn extend<'a>(&mut self, frames: impl Iterator<Item = &'a ConFrame>) -> io::Result<()> {
        for frame in frames {
            self.write_frame(frame)?;
        }
        Ok(())
    }
}

// Implementation block specifically for when the writer is a `File`.
impl ConFrameWriter<File> {
    /// Creates a new `ConFrameWriter` that writes to a file at the given path.
    ///
    /// This is a convenience function that creates the file and wraps it.
    pub fn from_path<P: AsRef<Path>>(path: P) -> io::Result<Self> {
        let file = File::create(path)?;
        Ok(Self::new(file))
    }

    /// Creates a new `ConFrameWriter` that writes to a file with a custom precision.
    pub fn from_path_with_precision<P: AsRef<Path>>(path: P, precision: usize) -> io::Result<Self> {
        let file = File::create(path)?;
        Ok(Self::with_precision(file, precision))
    }
}

// Gzip-compressed writer constructors.
impl ConFrameWriter<flate2::write::GzEncoder<File>> {
    /// Creates a gzip-compressed writer for the given path.
    pub fn from_path_gzip<P: AsRef<Path>>(path: P) -> io::Result<Self> {
        let encoder = crate::compression::gzip_writer(path.as_ref())?;
        Ok(Self::new(encoder))
    }

    /// Creates a gzip-compressed writer with custom precision.
    pub fn from_path_gzip_with_precision<P: AsRef<Path>>(
        path: P,
        precision: usize,
    ) -> io::Result<Self> {
        let encoder = crate::compression::gzip_writer(path.as_ref())?;
        Ok(Self::with_precision(encoder, precision))
    }
}

// Zstd-compressed writer constructors. Available only with the `zstd`
// Cargo feature.
#[cfg(feature = "zstd")]
impl ConFrameWriter<zstd::stream::write::AutoFinishEncoder<'static, File>> {
    /// Creates a zstd-compressed writer for the given path.
    pub fn from_path_zstd<P: AsRef<Path>>(path: P) -> io::Result<Self> {
        let encoder = crate::compression::zstd_writer(path.as_ref())?;
        Ok(Self::new(encoder))
    }

    /// Creates a zstd-compressed writer with custom precision.
    pub fn from_path_zstd_with_precision<P: AsRef<Path>>(
        path: P,
        precision: usize,
    ) -> io::Result<Self> {
        let encoder = crate::compression::zstd_writer(path.as_ref())?;
        Ok(Self::with_precision(encoder, precision))
    }
}