Skip to main content

readcon_core/
writer.rs

1use crate::types::{
2    ConFrame, SECTION_CHARGES, SECTION_DISPLACEMENTS, SECTION_ENERGIES, SECTION_FORCES,
3    SECTION_MAGMOMS, SECTION_SPINS, SECTION_VELOCITIES, encode_fixed_bitmask, meta,
4};
5use serde_json::json;
6use std::fs::File;
7use std::io::{self, BufWriter, Write};
8use std::path::Path;
9
10/// Default floating-point precision used for writing coordinates, cell dimensions, and masses.
11const DEFAULT_FLOAT_PRECISION: usize = 6;
12
13/// Numeric text representation for CON frame fields.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum FloatFormat {
16    /// A fixed number of digits after the decimal point.
17    DecimalPlaces(usize),
18    /// The shortest scientific representation preserving each finite f64.
19    /// Signed zero is preserved; decimal precision does not limit this mode.
20    RoundTrip,
21}
22
23impl Default for FloatFormat {
24    fn default() -> Self {
25        Self::DecimalPlaces(DEFAULT_FLOAT_PRECISION)
26    }
27}
28
29/// A writer that can serialize and write `ConFrame` objects to any output stream.
30///
31/// This struct encapsulates a writer (like a file) and provides a high-level API
32/// for writing simulation frames in the `.con` format.
33///
34/// # Example
35/// ```no_run
36/// # use std::fs::File;
37/// # use readcon_core::types::ConFrame;
38/// # use readcon_core::writer::ConFrameWriter;
39/// # let frames: Vec<ConFrame> = Vec::new();
40/// let mut writer = ConFrameWriter::from_path("output.con").unwrap();
41/// writer.extend(frames.iter()).unwrap();
42/// ```
43pub struct ConFrameWriter<W: Write> {
44    writer: BufWriter<W>,
45    float_format: FloatFormat,
46    /// When true: sort metadata keys in JSON, emit sections in canonical
47    /// order (velocities, forces, energies), fixed precision suitable for
48    /// content-stable corpus writes / semantic-ish dedup. Opt-in so default
49    /// writes keep historical float formatting.
50    canonical: bool,
51    /// Cache for the JSON metadata line: when consecutive frames share
52    /// the same (spec_version, sections-set, metadata) triple the
53    /// serialized JSON object is identical, so reusing the cached
54    /// string skips the per-frame `serde_json::Map::insert` rebuild
55    /// and re-serialisation. Hot for trajectory writes where every
56    /// frame has the same `units` / `potential` / `validate` keys.
57    metadata_cache: Option<MetadataCacheEntry>,
58    /// One frame of CON text. Filled then flushed with a single `write_all`
59    /// so the sink is not entered once per atom line.
60    scratch: Vec<u8>,
61}
62
63#[derive(Debug)]
64struct MetadataCacheEntry {
65    /// Snapshot of the inputs that fully determine the serialized JSON
66    /// metadata line. Cheap to clone; cheaper than re-serialising the
67    /// whole map on every frame.
68    spec_version: u32,
69    has_velocities: bool,
70    has_forces: bool,
71    has_energies: bool,
72    has_charges: bool,
73    has_spins: bool,
74    has_magmoms: bool,
75    has_displacements: bool,
76    metadata: std::collections::BTreeMap<String, serde_json::Value>,
77    /// Cached serialised metadata line (without trailing newline).
78    serialized: String,
79}
80
81impl MetadataCacheEntry {
82    fn matches(
83        &self,
84        spec_version: u32,
85        has_velocities: bool,
86        has_forces: bool,
87        has_energies: bool,
88        has_charges: bool,
89        has_spins: bool,
90        has_magmoms: bool,
91        has_displacements: bool,
92        metadata: &std::collections::BTreeMap<String, serde_json::Value>,
93    ) -> bool {
94        self.spec_version == spec_version
95            && self.has_velocities == has_velocities
96            && self.has_forces == has_forces
97            && self.has_energies == has_energies
98            && self.has_charges == has_charges
99            && self.has_spins == has_spins
100            && self.has_magmoms == has_magmoms
101            && self.has_displacements == has_displacements
102            && &self.metadata == metadata
103    }
104}
105
106// General implementation for any type that implements `Write`.
107impl<W: Write> ConFrameWriter<W> {
108    /// Creates a new `ConFrameWriter` that wraps a given writer.
109    ///
110    /// # Arguments
111    ///
112    /// * `writer` - Any type that implements `std::io::Write`, e.g., a `File`.
113    pub fn new(writer: W) -> Self {
114        Self::with_float_format(writer, FloatFormat::default())
115    }
116
117    /// Creates a new `ConFrameWriter` with a custom floating-point precision.
118    ///
119    /// # Arguments
120    ///
121    /// * `writer` - Any type that implements `std::io::Write`.
122    /// * `precision` - Number of decimal places for floating-point output.
123    pub fn with_precision(writer: W, precision: usize) -> Self {
124        Self::with_float_format(writer, FloatFormat::DecimalPlaces(precision))
125    }
126
127    /// Creates a writer with an explicit numeric text representation.
128    ///
129    /// `FloatFormat::RoundTrip` retains small forces and coordinates exactly.
130    /// Metadata floats use the JSON serializer independently of this setting.
131    pub fn with_float_format(writer: W, float_format: FloatFormat) -> Self {
132        Self {
133            writer: BufWriter::new(writer),
134            float_format,
135            canonical: false,
136            metadata_cache: None,
137            scratch: Vec::with_capacity(16 * 1024),
138        }
139    }
140
141    /// Opt-in **canonical** serialization: BTree-ordered metadata keys in JSON,
142    /// fixed section order, stable float precision (default 6). Use for corpus
143    /// materialization and content-stable hashes; not required for on-disk fidelity
144    /// of spans preserved from `next_with_raw_span`.
145    pub fn canonical(mut self, on: bool) -> Self {
146        self.set_canonical(on);
147        self
148    }
149
150    /// Set or clear canonical mode on an existing writer (C ABI / FFI).
151    pub fn set_canonical(&mut self, on: bool) {
152        self.canonical = on;
153        if on {
154            self.metadata_cache = None;
155        }
156    }
157
158    /// Whether canonical serialization is enabled.
159    pub fn is_canonical(&self) -> bool {
160        self.canonical
161    }
162
163    fn refresh_metadata_cache(&mut self, frame: &ConFrame) {
164        let spec_version = frame.header.spec_version;
165        let has_vel = frame.has_velocities();
166        let has_frc = frame.has_forces();
167        let has_eng = frame.has_energies();
168        let has_chg = frame.has_charges();
169        let has_spn = frame.has_spins();
170        let has_mm = frame.has_magmoms();
171        let has_dsp = frame.has_displacements();
172
173        let cache_hit = !self.canonical
174            && self.metadata_cache.as_ref().is_some_and(|c| {
175                c.matches(
176                    spec_version,
177                    has_vel,
178                    has_frc,
179                    has_eng,
180                    has_chg,
181                    has_spn,
182                    has_mm,
183                    has_dsp,
184                    &frame.header.metadata,
185                )
186            });
187        if cache_hit {
188            return;
189        }
190
191        let mut meta_obj = serde_json::Map::new();
192        meta_obj.insert(meta::CON_SPEC_VERSION.into(), json!(spec_version));
193        let mut sections = Vec::new();
194        if has_vel {
195            sections.push(json!(SECTION_VELOCITIES));
196        }
197        if has_frc {
198            sections.push(json!(SECTION_FORCES));
199        }
200        if has_eng {
201            sections.push(json!(SECTION_ENERGIES));
202        }
203        if has_chg {
204            sections.push(json!(SECTION_CHARGES));
205        }
206        if has_spn {
207            sections.push(json!(SECTION_SPINS));
208        }
209        if has_mm {
210            sections.push(json!(SECTION_MAGMOMS));
211        }
212        if has_dsp {
213            sections.push(json!(SECTION_DISPLACEMENTS));
214        }
215        let validate = frame
216            .header
217            .metadata
218            .get(meta::VALIDATE)
219            .and_then(|value| value.as_bool())
220            .unwrap_or(false);
221        if !sections.is_empty() || validate {
222            meta_obj.insert(meta::SECTIONS.into(), json!(sections));
223        }
224        for (k, v) in &frame.header.metadata {
225            if k == meta::CON_SPEC_VERSION || k == meta::SECTIONS {
226                continue;
227            }
228            meta_obj.insert(k.clone(), v.clone());
229        }
230        if let Some(u) = meta_obj.get(meta::UNITS).cloned() {
231            if let Ok(c) = crate::units::canonicalize_units_object(&u) {
232                meta_obj.insert(meta::UNITS.into(), c);
233            }
234        }
235        if spec_version >= 3 {
236            let need_default = match meta_obj.get(meta::UNITS) {
237                None => true,
238                Some(u) => crate::units::validate_v3_units_metadata(u).is_err(),
239            };
240            if need_default {
241                meta_obj.insert(meta::UNITS.into(), crate::units::default_v3_units_json());
242            }
243        }
244        let serialized = serde_json::Value::Object(meta_obj).to_string();
245        self.metadata_cache = Some(MetadataCacheEntry {
246            spec_version,
247            has_velocities: has_vel,
248            has_forces: has_frc,
249            has_energies: has_eng,
250            has_charges: has_chg,
251            has_spins: has_spn,
252            has_magmoms: has_mm,
253            has_displacements: has_dsp,
254            metadata: frame.header.metadata.clone(),
255            serialized,
256        });
257    }
258
259    /// Writes a single `ConFrame` to the output stream.
260    pub fn write_frame(&mut self, frame: &ConFrame) -> io::Result<()> {
261        let prec = self.float_format;
262        self.refresh_metadata_cache(frame);
263        let meta_line = self
264            .metadata_cache
265            .as_ref()
266            .expect("metadata_cache populated above")
267            .serialized
268            .clone();
269        self.scratch.clear();
270        {
271            let buf = &mut self.scratch;
272
273            // --- Write the 9-line Header ---
274            let _ = writeln!(buf, "{}", frame.header.prebox_header.user);
275            let _ = writeln!(buf, "{meta_line}");
276            push_f64(buf, frame.header.boxl[0], prec);
277            buf.push(b' ');
278            push_f64(buf, frame.header.boxl[1], prec);
279            buf.push(b' ');
280            push_f64(buf, frame.header.boxl[2], prec);
281            buf.push(b'\n');
282            push_f64(buf, frame.header.angles[0], prec);
283            buf.push(b' ');
284            push_f64(buf, frame.header.angles[1], prec);
285            buf.push(b' ');
286            push_f64(buf, frame.header.angles[2], prec);
287            buf.push(b'\n');
288            let _ = writeln!(buf, "{}", frame.header.postbox_header[0]);
289            let _ = writeln!(buf, "{}", frame.header.postbox_header[1]);
290            let _ = writeln!(buf, "{}", frame.header.natm_types);
291
292            for (i, n) in frame.header.natms_per_type.iter().enumerate() {
293                if i > 0 {
294                    buf.push(b' ');
295                }
296                push_u64(buf, *n as u64);
297            }
298            buf.push(b'\n');
299
300            for (i, m) in frame.header.masses_per_type.iter().enumerate() {
301                if i > 0 {
302                    buf.push(b' ');
303                }
304                push_f64(buf, *m, prec);
305            }
306            buf.push(b'\n');
307
308            // --- Write the Atom Data ---
309            let mut atom_idx_offset = 0;
310            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
311                let symbol = &frame.atom_data[atom_idx_offset].symbol;
312                let _ = writeln!(buf, "{symbol}");
313                let _ = writeln!(buf, "Coordinates of Component {}", type_idx + 1);
314
315                for i in 0..num_atoms_in_type {
316                    let atom = &frame.atom_data[atom_idx_offset + i];
317                    push_xyz_line(
318                        buf,
319                        atom.x,
320                        atom.y,
321                        atom.z,
322                        prec,
323                        encode_fixed_bitmask(atom.fixed),
324                        atom.atom_id,
325                    );
326                }
327                atom_idx_offset += num_atoms_in_type;
328            }
329
330            // --- Write optional velocity section ---
331            if frame.has_velocities() {
332                buf.push(b'\n');
333
334                let mut vel_idx_offset = 0;
335                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
336                {
337                    let symbol = &frame.atom_data[vel_idx_offset].symbol;
338                    let _ = writeln!(buf, "{symbol}");
339                    let _ = writeln!(buf, "Velocities of Component {}", type_idx + 1);
340
341                    for i in 0..num_atoms_in_type {
342                        let atom = &frame.atom_data[vel_idx_offset + i];
343                        let [vx, vy, vz] = atom.velocity.unwrap_or([0.0; 3]);
344                        push_xyz_line(
345                            buf,
346                            vx,
347                            vy,
348                            vz,
349                            prec,
350                            encode_fixed_bitmask(atom.fixed),
351                            atom.atom_id,
352                        );
353                    }
354                    vel_idx_offset += num_atoms_in_type;
355                }
356            }
357
358            // --- Write optional force section ---
359            if frame.has_forces() {
360                buf.push(b'\n');
361
362                let mut force_idx_offset = 0;
363                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
364                {
365                    let symbol = &frame.atom_data[force_idx_offset].symbol;
366                    let _ = writeln!(buf, "{symbol}");
367                    let _ = writeln!(buf, "Forces of Component {}", type_idx + 1);
368
369                    for i in 0..num_atoms_in_type {
370                        let atom = &frame.atom_data[force_idx_offset + i];
371                        let [fx, fy, fz] = atom.force.unwrap_or([0.0; 3]);
372                        push_xyz_line(
373                            buf,
374                            fx,
375                            fy,
376                            fz,
377                            prec,
378                            encode_fixed_bitmask(atom.fixed),
379                            atom.atom_id,
380                        );
381                    }
382                    force_idx_offset += num_atoms_in_type;
383                }
384            }
385
386            // --- Write optional energies section ---
387            if frame.has_energies() {
388                buf.push(b'\n');
389
390                let mut energy_idx_offset = 0;
391                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
392                {
393                    let symbol = &frame.atom_data[energy_idx_offset].symbol;
394                    let _ = writeln!(buf, "{symbol}");
395                    let _ = writeln!(buf, "Energies of Component {}", type_idx + 1);
396
397                    for i in 0..num_atoms_in_type {
398                        let atom = &frame.atom_data[energy_idx_offset + i];
399                        let e = atom.energy.unwrap_or(0.0);
400                        push_scalar_line(
401                            buf,
402                            e,
403                            prec,
404                            encode_fixed_bitmask(atom.fixed),
405                            atom.atom_id,
406                        );
407                    }
408                    energy_idx_offset += num_atoms_in_type;
409                }
410            }
411
412            if frame.has_charges() {
413                buf.push(b'\n');
414                let mut off = 0;
415                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
416                {
417                    let symbol = &frame.atom_data[off].symbol;
418                    let _ = writeln!(buf, "{symbol}");
419                    let _ = writeln!(buf, "Charges of Component {}", type_idx + 1);
420                    for i in 0..num_atoms_in_type {
421                        let atom = &frame.atom_data[off + i];
422                        let q = atom.charge.unwrap_or(0.0);
423                        push_scalar_line(
424                            buf,
425                            q,
426                            prec,
427                            encode_fixed_bitmask(atom.fixed),
428                            atom.atom_id,
429                        );
430                    }
431                    off += num_atoms_in_type;
432                }
433            }
434
435            if frame.has_spins() {
436                buf.push(b'\n');
437                let mut off = 0;
438                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
439                {
440                    let symbol = &frame.atom_data[off].symbol;
441                    let _ = writeln!(buf, "{symbol}");
442                    let _ = writeln!(buf, "Spins of Component {}", type_idx + 1);
443                    for i in 0..num_atoms_in_type {
444                        let atom = &frame.atom_data[off + i];
445                        let s = atom.spin.unwrap_or(0.0);
446                        push_scalar_line(
447                            buf,
448                            s,
449                            prec,
450                            encode_fixed_bitmask(atom.fixed),
451                            atom.atom_id,
452                        );
453                    }
454                    off += num_atoms_in_type;
455                }
456            }
457
458            if frame.has_magmoms() {
459                buf.push(b'\n');
460                let mut off = 0;
461                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
462                {
463                    let symbol = &frame.atom_data[off].symbol;
464                    let _ = writeln!(buf, "{symbol}");
465                    let _ = writeln!(buf, "Magmoms of Component {}", type_idx + 1);
466                    for i in 0..num_atoms_in_type {
467                        let atom = &frame.atom_data[off + i];
468                        let [mx, my, mz] = atom.magmom.unwrap_or([0.0; 3]);
469                        push_xyz_line(
470                            buf,
471                            mx,
472                            my,
473                            mz,
474                            prec,
475                            encode_fixed_bitmask(atom.fixed),
476                            atom.atom_id,
477                        );
478                    }
479                    off += num_atoms_in_type;
480                }
481            }
482
483            if frame.has_displacements() {
484                buf.push(b'\n');
485                let mut off = 0;
486                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
487                {
488                    let symbol = &frame.atom_data[off].symbol;
489                    let _ = writeln!(buf, "{symbol}");
490                    let _ = writeln!(buf, "Displacements of Component {}", type_idx + 1);
491                    for i in 0..num_atoms_in_type {
492                        let atom = &frame.atom_data[off + i];
493                        let [dx, dy, dz] = atom.displacement.unwrap_or([0.0; 3]);
494                        push_xyz_line(
495                            buf,
496                            dx,
497                            dy,
498                            dz,
499                            prec,
500                            encode_fixed_bitmask(atom.fixed),
501                            atom.atom_id,
502                        );
503                    }
504                    off += num_atoms_in_type;
505                }
506            }
507        }
508
509        self.writer.write_all(&self.scratch)
510    }
511
512    /// Writes all frames from an iterator to the output stream.
513    ///
514    /// This is the most convenient way to write a multi-frame file.
515    pub fn extend<'a>(&mut self, frames: impl Iterator<Item = &'a ConFrame>) -> io::Result<()> {
516        for frame in frames {
517            self.write_frame(frame)?;
518        }
519        Ok(())
520    }
521}
522
523fn push_u64(buf: &mut Vec<u8>, n: u64) {
524    push_u128(buf, u128::from(n));
525}
526
527fn push_u128(buf: &mut Vec<u8>, mut n: u128) {
528    let mut tmp = [0u8; 40];
529    let mut i = 40;
530    if n == 0 {
531        buf.push(b'0');
532        return;
533    }
534    while n > 0 {
535        i -= 1;
536        tmp[i] = b'0' + (n % 10) as u8;
537        n /= 10;
538    }
539    buf.extend_from_slice(&tmp[i..]);
540}
541
542fn push_f64(buf: &mut Vec<u8>, value: f64, format: FloatFormat) {
543    match format {
544        FloatFormat::DecimalPlaces(precision) => push_f64_prec(buf, value, precision),
545        FloatFormat::RoundTrip => {
546            let _ = write!(buf, "{value:e}");
547        }
548    }
549}
550
551/// Fixed-point `f64` with `prec` digits after the decimal, matching `{:.prec$}`.
552/// Non-finite values and `prec > 17` fall back to `std::fmt`.
553fn push_f64_prec(buf: &mut Vec<u8>, v: f64, prec: usize) {
554    // The six-decimal path uses fixed-point arithmetic; other precisions
555    // use the standard formatter.
556    if !v.is_finite() || prec != 6 {
557        let _ = write!(buf, "{v:.prec$}");
558        return;
559    }
560    if v.is_sign_negative() {
561        buf.push(b'-');
562    }
563    let ax = v.abs();
564    if prec == 0 {
565        push_u128(buf, ax.round() as u128);
566        return;
567    }
568    let scale = 10u128.pow(prec as u32);
569    let n = (ax * scale as f64).round() as u128;
570    let int_part = n / scale;
571    let frac = n % scale;
572    push_u128(buf, int_part);
573    buf.push(b'.');
574    let mut tmp = [b'0'; 20];
575    let mut x = frac;
576    let mut i = prec;
577    while i > 0 {
578        i -= 1;
579        tmp[i] = b'0' + (x % 10) as u8;
580        x /= 10;
581    }
582    buf.extend_from_slice(&tmp[..prec]);
583}
584
585fn push_xyz_line(
586    buf: &mut Vec<u8>,
587    x: f64,
588    y: f64,
589    z: f64,
590    prec: FloatFormat,
591    fixed: u8,
592    atom_id: u64,
593) {
594    push_f64(buf, x, prec);
595    buf.push(b' ');
596    push_f64(buf, y, prec);
597    buf.push(b' ');
598    push_f64(buf, z, prec);
599    buf.push(b' ');
600    push_u64(buf, u64::from(fixed));
601    buf.push(b' ');
602    push_u64(buf, atom_id);
603    buf.push(b'\n');
604}
605
606fn push_scalar_line(buf: &mut Vec<u8>, v: f64, prec: FloatFormat, fixed: u8, atom_id: u64) {
607    push_f64(buf, v, prec);
608    buf.push(b' ');
609    push_u64(buf, u64::from(fixed));
610    buf.push(b' ');
611    push_u64(buf, atom_id);
612    buf.push(b'\n');
613}
614
615// Implementation block specifically for when the writer is a `File`.
616impl ConFrameWriter<File> {
617    /// Creates a new `ConFrameWriter` that writes to a file at the given path.
618    ///
619    /// This is a convenience function that creates the file and wraps it.
620    pub fn from_path<P: AsRef<Path>>(path: P) -> io::Result<Self> {
621        let file = File::create(path)?;
622        Ok(Self::new(file))
623    }
624
625    /// Creates a new `ConFrameWriter` that writes to a file with a custom precision.
626    pub fn from_path_with_precision<P: AsRef<Path>>(path: P, precision: usize) -> io::Result<Self> {
627        let file = File::create(path)?;
628        Ok(Self::with_precision(file, precision))
629    }
630}
631
632// Gzip-compressed writer constructors.
633impl ConFrameWriter<flate2::write::GzEncoder<File>> {
634    /// Creates a gzip-compressed writer for the given path.
635    pub fn from_path_gzip<P: AsRef<Path>>(path: P) -> io::Result<Self> {
636        let encoder = crate::compression::gzip_writer(path.as_ref())?;
637        Ok(Self::new(encoder))
638    }
639
640    /// Creates a gzip-compressed writer with custom precision.
641    pub fn from_path_gzip_with_precision<P: AsRef<Path>>(
642        path: P,
643        precision: usize,
644    ) -> io::Result<Self> {
645        let encoder = crate::compression::gzip_writer(path.as_ref())?;
646        Ok(Self::with_precision(encoder, precision))
647    }
648}
649
650// Zstd-compressed writer constructors. Available only with the `zstd`
651// Cargo feature.
652#[cfg(feature = "zstd")]
653impl ConFrameWriter<zstd::stream::write::AutoFinishEncoder<'static, File>> {
654    /// Creates a zstd-compressed writer for the given path.
655    pub fn from_path_zstd<P: AsRef<Path>>(path: P) -> io::Result<Self> {
656        let encoder = crate::compression::zstd_writer(path.as_ref())?;
657        Ok(Self::new(encoder))
658    }
659
660    /// Creates a zstd-compressed writer with custom precision.
661    pub fn from_path_zstd_with_precision<P: AsRef<Path>>(
662        path: P,
663        precision: usize,
664    ) -> io::Result<Self> {
665        let encoder = crate::compression::zstd_writer(path.as_ref())?;
666        Ok(Self::with_precision(encoder, precision))
667    }
668}
669
670#[cfg(test)]
671mod float_format_tests {
672    use super::push_f64_prec;
673
674    fn formatted(v: f64, prec: usize) -> String {
675        let mut buf = Vec::new();
676        push_f64_prec(&mut buf, v, prec);
677        String::from_utf8(buf).expect("utf8")
678    }
679
680    #[test]
681    fn matches_std_fixed_precision() {
682        let vals = [
683            0.0,
684            -0.0,
685            1.0,
686            -1.0,
687            0.9045,
688            6.975_299_999_999_995,
689            63.546,
690            1.008,
691            15.3456,
692            90.0,
693            218.0,
694            1e-6,
695            -1.23456789,
696            10.0,
697        ];
698        for prec in [0usize, 6] {
699            for v in vals {
700                let got = formatted(v, prec);
701                let exp = format!("{v:.prec$}");
702                assert_eq!(got, exp, "v={v:?} prec={prec}");
703            }
704        }
705    }
706}