use crate::types::{
ConFrame, SECTION_CHARGES, SECTION_DISPLACEMENTS, SECTION_ENERGIES, SECTION_FORCES,
SECTION_MAGMOMS, SECTION_SPINS, SECTION_SPREADS, SECTION_VELOCITIES, encode_fixed_bitmask,
meta,
};
use serde_json::json;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;
#[derive(Debug)]
pub enum WriteError {
Io(io::Error),
Format(crate::error::ParseError),
}
impl std::fmt::Display for WriteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WriteError::Io(e) => write!(f, "{e}"),
WriteError::Format(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for WriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
WriteError::Io(e) => Some(e),
WriteError::Format(e) => Some(e),
}
}
}
impl From<io::Error> for WriteError {
fn from(e: io::Error) -> Self {
WriteError::Io(e)
}
}
impl From<WriteError> for io::Error {
fn from(e: WriteError) -> Self {
match e {
WriteError::Io(e) => e,
WriteError::Format(err) => io::Error::new(io::ErrorKind::InvalidData, err.to_string()),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FloatFormat {
DecimalPlaces(usize),
RoundTrip,
}
impl Default for FloatFormat {
fn default() -> Self {
Self::RoundTrip
}
}
pub struct ConFrameWriter<W: Write> {
writer: BufWriter<W>,
float_format: FloatFormat,
canonical: bool,
metadata_cache: Option<MetadataCacheEntry>,
scratch: Vec<u8>,
ryu: ryu::Buffer,
}
#[derive(Debug)]
struct MetadataCacheEntry {
spec_version: u32,
has_velocities: bool,
has_forces: bool,
has_energies: bool,
has_charges: bool,
has_spins: bool,
has_magmoms: bool,
has_displacements: bool,
has_spreads: bool,
metadata: std::collections::BTreeMap<String, serde_json::Value>,
serialized: String,
}
impl MetadataCacheEntry {
fn matches(
&self,
spec_version: u32,
has_velocities: bool,
has_forces: bool,
has_energies: bool,
has_charges: bool,
has_spins: bool,
has_magmoms: bool,
has_displacements: bool,
has_spreads: 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.has_charges == has_charges
&& self.has_spins == has_spins
&& self.has_magmoms == has_magmoms
&& self.has_displacements == has_displacements
&& self.has_spreads == has_spreads
&& self.metadata.len() == metadata.len()
&& self
.metadata
.iter()
.zip(metadata)
.all(|((ka, a), (kb, b))| ka == kb && metadata_value_eq(a, b))
}
}
fn metadata_value_eq(a: &serde_json::Value, b: &serde_json::Value) -> bool {
use serde_json::Value;
match (a, b) {
(Value::Number(a), Value::Number(b)) if a.is_f64() && b.is_f64() => {
a.as_f64().map(f64::to_bits) == b.as_f64().map(f64::to_bits)
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| metadata_value_eq(a, b))
}
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter()
.all(|(key, a)| b.get(key).is_some_and(|b| metadata_value_eq(a, b)))
}
_ => a == b,
}
}
fn require_minor2_section_arity(
minor: u64,
sections: &[serde_json::Value],
metadata: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), WriteError> {
if minor < 2 {
return Ok(());
}
for name in sections.iter().filter_map(|value| value.as_str()) {
if crate::types::reserved_section_value_arity(name).is_some() {
continue;
}
let listed = metadata
.get(meta::SECTION_ARITY)
.and_then(|value| value.as_object())
.and_then(|obj| obj.get(name))
.and_then(|value| value.as_u64())
.is_some_and(|n| n >= 1);
if !listed {
return Err(WriteError::Format(
crate::error::ParseError::ValidationError(format!(
"minor >= 2 must list {name} in section_arity"
)),
));
}
}
Ok(())
}
impl<W: Write> ConFrameWriter<W> {
pub fn new(writer: W) -> Self {
Self::with_float_format(writer, FloatFormat::default())
}
pub fn with_precision(writer: W, precision: usize) -> Self {
Self::with_float_format(writer, FloatFormat::DecimalPlaces(precision))
}
pub fn with_float_format(writer: W, float_format: FloatFormat) -> Self {
Self {
writer: BufWriter::new(writer),
float_format,
canonical: false,
metadata_cache: None,
scratch: Vec::with_capacity(16 * 1024),
ryu: ryu::Buffer::new(),
}
}
pub fn canonical(mut self, on: bool) -> Self {
self.set_canonical(on);
self
}
pub fn set_canonical(&mut self, on: bool) {
self.canonical = on;
if on {
self.metadata_cache = None;
}
}
pub fn is_canonical(&self) -> bool {
self.canonical
}
fn refresh_metadata_cache(&mut self, frame: &ConFrame) -> Result<(), WriteError> {
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 has_chg = frame.has_charges();
let has_spn = frame.has_spins();
let has_mm = frame.has_magmoms();
let has_dsp = frame.has_displacements();
let has_spr = frame.has_spreads();
let cache_hit = !self.canonical
&& self.metadata_cache.as_ref().is_some_and(|c| {
c.matches(
spec_version,
has_vel,
has_frc,
has_eng,
has_chg,
has_spn,
has_mm,
has_dsp,
has_spr,
&frame.header.metadata,
)
});
if cache_hit {
return Ok(());
}
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));
}
if has_chg {
sections.push(json!(SECTION_CHARGES));
}
if has_spn {
sections.push(json!(SECTION_SPINS));
}
if has_mm {
sections.push(json!(SECTION_MAGMOMS));
}
if has_dsp {
sections.push(json!(SECTION_DISPLACEMENTS));
}
if has_spr {
sections.push(json!(SECTION_SPREADS));
}
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());
}
if let Some(u) = meta_obj.get(meta::UNITS).cloned() {
let c = crate::units::canonicalize_units_object(&u).map_err(|e| {
WriteError::Format(crate::error::ParseError::InvalidUnits(e.to_string()))
})?;
meta_obj.insert(meta::UNITS.into(), c);
}
if spec_version >= 3 {
match meta_obj.get(meta::UNITS) {
None => {
return Err(WriteError::Format(crate::error::ParseError::InvalidUnits(
"con_spec_version >= 3 requires units.length and units.energy".into(),
)));
}
Some(u) => {
crate::units::validate_v3_units_metadata(u).map_err(|e| {
WriteError::Format(crate::error::ParseError::InvalidUnits(e.to_string()))
})?;
}
}
}
let minor = frame
.header
.metadata
.get(meta::CON_SPEC_MINOR)
.and_then(|v| v.as_u64())
.unwrap_or(0);
if spec_version >= 3 && minor >= 1 && has_vel {
let time_ok = meta_obj
.get(meta::UNITS)
.and_then(|u| u.get("time"))
.and_then(|v| v.as_str())
.is_some_and(|s| !s.trim().is_empty());
if !time_ok {
return Err(WriteError::Format(crate::error::ParseError::InvalidUnits(
"minor >= 1 requires units.time when a velocities section is present".into(),
)));
}
}
require_minor2_section_arity(minor, §ions, &meta_obj)?;
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,
has_charges: has_chg,
has_spins: has_spn,
has_magmoms: has_mm,
has_displacements: has_dsp,
has_spreads: has_spr,
metadata: frame.header.metadata.clone(),
serialized,
});
Ok(())
}
pub fn write_frame(&mut self, frame: &ConFrame) -> Result<(), WriteError> {
let zero_frame = frame.header.natm_types == 0
&& frame.atom_data.is_empty()
&& frame.header.natms_per_type.is_empty()
&& frame.header.masses_per_type.is_empty();
if zero_frame && !frame.header.sections.is_empty() {
return Err(WriteError::Format(crate::error::ParseError::InvalidCount(
"a zero-atom frame cannot declare sections".into(),
)));
}
if !zero_frame
&& (frame.header.natm_types == 0
|| frame.atom_data.is_empty()
|| frame.header.natms_per_type.iter().any(|n| *n == 0))
{
return Err(WriteError::Format(crate::error::ParseError::InvalidCount(
"each per-type count must be >= 1".into(),
)));
}
ensure_unique_ids(&frame.atom_data)?;
let prec = self.float_format;
let meta_line = if frame.header.spec_version < 2 {
frame.header.prebox_header.metadata_line().to_string()
} else {
self.refresh_metadata_cache(frame)?;
self.metadata_cache
.as_ref()
.expect("metadata_cache populated above")
.serialized
.clone()
};
let (boxl, angles) = match frame.header.lattice_vectors() {
Some(vecs) => crate::types::lengths_and_angles(vecs),
None => (frame.header.boxl, frame.header.angles),
};
self.scratch.clear();
{
let buf = &mut self.scratch;
let ryu = &mut self.ryu;
let _ = writeln!(buf, "{}", frame.header.prebox_header.user);
let _ = writeln!(buf, "{meta_line}");
push_f64(buf, ryu, boxl[0], prec);
buf.push(b' ');
push_f64(buf, ryu, boxl[1], prec);
buf.push(b' ');
push_f64(buf, ryu, boxl[2], prec);
buf.push(b'\n');
push_f64(buf, ryu, angles[0], prec);
buf.push(b' ');
push_f64(buf, ryu, angles[1], prec);
buf.push(b' ');
push_f64(buf, ryu, angles[2], prec);
buf.push(b'\n');
let _ = writeln!(buf, "{}", frame.header.postbox_header[0]);
let _ = writeln!(buf, "{}", frame.header.postbox_header[1]);
let _ = writeln!(buf, "{}", frame.header.natm_types);
for (i, n) in frame.header.natms_per_type.iter().enumerate() {
if i > 0 {
buf.push(b' ');
}
push_u64(buf, *n as u64);
}
buf.push(b'\n');
for (i, m) in frame.header.masses_per_type.iter().enumerate() {
if i > 0 {
buf.push(b' ');
}
push_f64(buf, ryu, *m, prec);
}
buf.push(b'\n');
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;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "Coordinates of Component {}", type_idx + 1);
for i in 0..num_atoms_in_type {
let atom = &frame.atom_data[atom_idx_offset + i];
push_xyz_line(
buf,
ryu,
atom.x,
atom.y,
atom.z,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
atom_idx_offset += num_atoms_in_type;
}
if frame.has_velocities() {
buf.push(b'\n');
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;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "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]);
push_xyz_line(
buf,
ryu,
vx,
vy,
vz,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
vel_idx_offset += num_atoms_in_type;
}
}
if frame.has_forces() {
buf.push(b'\n');
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;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "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]);
push_xyz_line(
buf,
ryu,
fx,
fy,
fz,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
force_idx_offset += num_atoms_in_type;
}
}
if frame.has_energies() {
buf.push(b'\n');
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;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "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);
push_scalar_line(
buf,
ryu,
e,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
energy_idx_offset += num_atoms_in_type;
}
}
if frame.has_charges() {
buf.push(b'\n');
let mut off = 0;
for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
{
let symbol = &frame.atom_data[off].symbol;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "Charges of Component {}", type_idx + 1);
for i in 0..num_atoms_in_type {
let atom = &frame.atom_data[off + i];
let q = atom.charge().unwrap_or(0.0);
push_scalar_line(
buf,
ryu,
q,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
off += num_atoms_in_type;
}
}
if frame.has_spins() {
buf.push(b'\n');
let mut off = 0;
for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
{
let symbol = &frame.atom_data[off].symbol;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "Spins of Component {}", type_idx + 1);
for i in 0..num_atoms_in_type {
let atom = &frame.atom_data[off + i];
let s = atom.spin().unwrap_or(0.0);
push_scalar_line(
buf,
ryu,
s,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
off += num_atoms_in_type;
}
}
if frame.has_magmoms() {
buf.push(b'\n');
let mut off = 0;
for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
{
let symbol = &frame.atom_data[off].symbol;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "Magmoms of Component {}", type_idx + 1);
for i in 0..num_atoms_in_type {
let atom = &frame.atom_data[off + i];
let [mx, my, mz] = atom.magmom().unwrap_or([0.0; 3]);
push_xyz_line(
buf,
ryu,
mx,
my,
mz,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
off += num_atoms_in_type;
}
}
if frame.has_displacements() {
buf.push(b'\n');
let mut off = 0;
for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
{
let symbol = &frame.atom_data[off].symbol;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "Displacements of Component {}", type_idx + 1);
for i in 0..num_atoms_in_type {
let atom = &frame.atom_data[off + i];
let [dx, dy, dz] = atom.displacement().unwrap_or([0.0; 3]);
push_xyz_line(
buf,
ryu,
dx,
dy,
dz,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
off += num_atoms_in_type;
}
}
if frame.has_spreads() {
buf.push(b'\n');
let mut off = 0;
for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
{
let symbol = &frame.atom_data[off].symbol;
let _ = writeln!(buf, "{symbol}");
let _ = writeln!(buf, "Spreads of Component {}", type_idx + 1);
for i in 0..num_atoms_in_type {
let atom = &frame.atom_data[off + i];
let [sx, sy, sz] = atom.spread().unwrap_or([0.0; 3]);
if !crate::types::spread_row_ok([sx, sy, sz]) {
let err = if sx.is_finite() && sy.is_finite() && sz.is_finite() {
crate::types::invalid_spread(off + i)
} else {
crate::error::ParseError::NonFiniteValue(format!(
"spreads atom {}",
off + i
))
};
return Err(WriteError::Format(err));
}
push_xyz_line(
buf,
ryu,
sx,
sy,
sz,
prec,
written_fixed_flag(atom.fixed, frame.header.spec_version),
atom.atom_id,
);
}
off += num_atoms_in_type;
}
}
}
self.writer.write_all(&self.scratch)?;
Ok(())
}
pub fn flush(&mut self) -> io::Result<()> {
self.writer.flush()
}
pub fn extend<'a>(
&mut self,
frames: impl Iterator<Item = &'a ConFrame>,
) -> Result<(), WriteError> {
for frame in frames {
self.write_frame(frame)?;
}
Ok(())
}
}
fn ensure_unique_ids(atoms: &[crate::types::AtomDatum]) -> Result<(), WriteError> {
let n = atoms.len();
if n == 0 {
return Ok(());
}
if atoms
.iter()
.enumerate()
.all(|(i, atom)| atom.atom_id == i as u64)
{
return Ok(());
}
let mut seen_ids = std::collections::HashSet::with_capacity(n);
for atom in atoms {
if !seen_ids.insert(atom.atom_id) {
return Err(WriteError::Format(
crate::error::ParseError::ValidationError(format!(
"atom id {} is repeated in the frame",
atom.atom_id
)),
));
}
}
Ok(())
}
fn written_fixed_flag(fixed: [bool; 3], spec_version: u32) -> u8 {
if spec_version < 2 {
if fixed.iter().any(|axis| *axis) { 1 } else { 0 }
} else {
encode_fixed_bitmask(fixed)
}
}
fn push_u64(buf: &mut Vec<u8>, n: u64) {
push_u128(buf, u128::from(n));
}
fn push_u128(buf: &mut Vec<u8>, mut n: u128) {
let mut tmp = [0u8; 40];
let mut i = 40;
if n == 0 {
buf.push(b'0');
return;
}
while n > 0 {
i -= 1;
tmp[i] = b'0' + (n % 10) as u8;
n /= 10;
}
buf.extend_from_slice(&tmp[i..]);
}
fn push_f64(buf: &mut Vec<u8>, ryu: &mut ryu::Buffer, value: f64, format: FloatFormat) {
match format {
FloatFormat::DecimalPlaces(precision) => push_f64_prec(buf, value, precision),
FloatFormat::RoundTrip => {
let text = if value.is_finite() {
ryu.format_finite(value)
} else {
ryu.format(value)
};
buf.extend_from_slice(text.as_bytes());
}
}
}
fn push_f64_prec(buf: &mut Vec<u8>, v: f64, prec: usize) {
if !v.is_finite() || prec != 6 {
let _ = write!(buf, "{v:.prec$}");
return;
}
if v.is_sign_negative() {
buf.push(b'-');
}
let ax = v.abs();
if prec == 0 {
push_u128(buf, ax.round() as u128);
return;
}
let scale = 10u128.pow(prec as u32);
let n = (ax * scale as f64).round() as u128;
let int_part = n / scale;
let frac = n % scale;
push_u128(buf, int_part);
buf.push(b'.');
let mut tmp = [b'0'; 20];
let mut x = frac;
let mut i = prec;
while i > 0 {
i -= 1;
tmp[i] = b'0' + (x % 10) as u8;
x /= 10;
}
buf.extend_from_slice(&tmp[..prec]);
}
fn push_xyz_line(
buf: &mut Vec<u8>,
ryu: &mut ryu::Buffer,
x: f64,
y: f64,
z: f64,
prec: FloatFormat,
fixed: u8,
atom_id: u64,
) {
push_f64(buf, ryu, x, prec);
buf.push(b' ');
push_f64(buf, ryu, y, prec);
buf.push(b' ');
push_f64(buf, ryu, z, prec);
buf.push(b' ');
push_u64(buf, u64::from(fixed));
buf.push(b' ');
push_u64(buf, atom_id);
buf.push(b'\n');
}
fn push_scalar_line(
buf: &mut Vec<u8>,
ryu: &mut ryu::Buffer,
v: f64,
prec: FloatFormat,
fixed: u8,
atom_id: u64,
) {
push_f64(buf, ryu, v, prec);
buf.push(b' ');
push_u64(buf, u64::from(fixed));
buf.push(b' ');
push_u64(buf, atom_id);
buf.push(b'\n');
}
impl ConFrameWriter<File> {
pub fn from_path<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let file = File::create(path)?;
Ok(Self::new(file))
}
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))
}
}
impl ConFrameWriter<flate2::write::GzEncoder<File>> {
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))
}
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))
}
}
#[cfg(feature = "zstd")]
impl ConFrameWriter<zstd::stream::write::AutoFinishEncoder<'static, File>> {
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))
}
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))
}
}
#[cfg(test)]
mod float_format_tests {
use super::push_f64_prec;
fn formatted(v: f64, prec: usize) -> String {
let mut buf = Vec::new();
push_f64_prec(&mut buf, v, prec);
String::from_utf8(buf).expect("utf8")
}
#[test]
fn matches_std_fixed_precision() {
let vals = [
0.0,
-0.0,
1.0,
-1.0,
0.9045,
6.975_299_999_999_995,
63.546,
1.008,
15.3456,
90.0,
218.0,
1e-6,
-1.23456789,
10.0,
];
for prec in [0usize, 6] {
for v in vals {
let got = formatted(v, prec);
let exp = format!("{v:.prec$}");
assert_eq!(got, exp, "v={v:?} prec={prec}");
}
}
}
}
#[cfg(test)]
mod section_arity_tests {
use super::require_minor2_section_arity;
use serde_json::json;
#[test]
fn minor2_lists_a_new_section_in_section_arity() {
let widgets = vec![json!("widgets")];
let err = require_minor2_section_arity(2, &widgets, &serde_json::Map::new()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("section_arity"), "{msg}");
let mut listed = serde_json::Map::new();
listed.insert("section_arity".into(), json!({"widgets": 3}));
require_minor2_section_arity(2, &widgets, &listed).unwrap();
let reserved = vec![json!("velocities"), json!("energies")];
require_minor2_section_arity(2, &reserved, &serde_json::Map::new()).unwrap();
require_minor2_section_arity(1, &widgets, &serde_json::Map::new()).unwrap();
}
}