From e2886d30bb61eeb1f6bab5ff5c585802ec43fec9 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 15:00:29 +1000 Subject: [PATCH 1/9] feat(diagnosticism): add `nanoseconds_to_string()` for compact duration formatting Port the Diagnosticism.Python 0.16.0 time_format algorithm with inline tests matching test_time_format.py. Export at the crate root, add Criterion benchmarks, update README tagline, and bump version to 0.3.0. --- CHANGES.md | 5 + Cargo.lock | 2 +- Cargo.toml | 6 +- README.md | 6 +- benches/time_format.rs | 124 ++++++++++++ src/diagnostics/mod.rs | 1 + src/diagnostics/time_format.rs | 334 +++++++++++++++++++++++++++++++++ src/lib.rs | 3 + 8 files changed, 478 insertions(+), 3 deletions(-) create mode 100644 benches/time_format.rs create mode 100644 src/diagnostics/time_format.rs diff --git a/CHANGES.md b/CHANGES.md index baf4fed..44f0786 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,11 @@ # Diagnosticism.Rust - CHANGES +## 0.3.0 - 27th June 2026 + +* added `nanoseconds_to_string()` — compact human-readable duration formatting (behaviour matches **Diagnosticism.Python** 0.16.0); + + ## 0.2.1 - 27th June 2026 * added CI tasks; diff --git a/Cargo.lock b/Cargo.lock index 8f425f2..18899c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,7 +206,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "diagnosticism" -version = "0.2.1" +version = "0.3.0" dependencies = [ "criterion", "rand", diff --git a/Cargo.toml b/Cargo.toml index 016ff76..f458600 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ name = "diagnosticism" readme = "README.md" repository = "https://github.com/synesissoftware/Diagnosticism.Rust" rust-version = "1.74" -version = "0.2.1" +version = "0.3.0" # ########################################################## @@ -40,6 +40,10 @@ path = "src/lib.rs" name = "doomgram" harness = false +[[bench]] +name = "time_format" +harness = false + [[example]] name = "debug-squeezer" path = "examples/debug_squeezer.rs" diff --git a/README.md b/README.md index a66b6bb..98c255f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Diagnosticism.Rust -Diagnosticism, for Rust +Simple diagnostics utilities for Rust — part of the cross-language **Diagnosticism** family. ![Language](https://img.shields.io/badge/Rust-000000?style=flat&logo=rust&logoColor=white) [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) @@ -78,6 +78,7 @@ The following optional features are defined in **Cargo.toml**: The following function is re-exported at the crate root (and defined in the [`diagnostics`](https://docs.rs/diagnosticism/latest/diagnosticism/diagnostics/index.html) module): * `doom_scope()` - executes a closure, records its elapsed time in a [`DoomGram`](https://docs.rs/diagnosticism/latest/diagnosticism/struct.DoomGram.html), and returns the closure's result together with the measured elapsed time (in nanoseconds). See the example [**examples/doomgram.md**](./examples/doomgram.md); +* `nanoseconds_to_string()` - formats a nanosecond count as a compact human-readable duration string (units `ns`, `µs`, `ms`, `s` with roughly three significant digits); behaviour matches [**Diagnosticism.Python**](https://github.com/synesissoftware/Diagnosticism.Python) 0.16.0; ### Macros @@ -317,7 +318,10 @@ Crates upon which **Diagnosticism.Rust** has development dependencies: ### Related projects +* [**Diagnosticism**](https://github.com/synesissoftware/Diagnosticism); +* [**Diagnosticism.Go**](https://github.com/synesissoftware/Diagnosticism.Go); * [**Diagnosticism.Python**](https://github.com/synesissoftware/Diagnosticism.Python); +* [**Diagnosticism.Ruby**](https://github.com/synesissoftware/Diagnosticism.Ruby); ### License diff --git a/benches/time_format.rs b/benches/time_format.rs new file mode 100644 index 0000000..b77dd97 --- /dev/null +++ b/benches/time_format.rs @@ -0,0 +1,124 @@ +// benchmarks/time_format.rs : evaluates costs of `nanoseconds_to_string()` + +#![allow(non_snake_case)] + +use std::hint::black_box; + +use diagnosticism::nanoseconds_to_string; + +use criterion::{ + criterion_group, + criterion_main, + BatchSize, + Criterion, +}; + + +/// Representative nanosecond counts spanning each output band and formatting +/// path (integer-only, fractional, large whole, zero, negative, explicit `+`). +const REPRESENTATIVE_VALUES : [(i64, &str); 12] = [ + (0, "zero"), + (9, "9 ns"), + (789, "789 ns"), + (6_789, "6.789 µs"), + (6_000, "6 µs"), + (123_456_789, "123.4 ms"), + (123_000_000, "123 ms"), + (9_123_456_789, "9.123 s"), + (9_000_000_000, "9 s"), + (77_777_777_777_777_777, "77e16 ns → s"), + (-123_456_789, "negative 123.4 ms"), + (999_772_000, "999.7 ms edge"), +]; + + +fn bench_nanoseconds_to_string( + c : &mut Criterion, + nanoseconds : i64, + format_spec : &str, + label : &str, +) { + let id = format!("`nanoseconds_to_string()` [{label}]"); + + c.bench_function(&id, |b| { + b.iter(|| { + let s = black_box(nanoseconds_to_string(black_box(nanoseconds), black_box(format_spec))); + + black_box(s) + }) + }); +} + + +pub fn BENCHMARK_nanoseconds_to_string_zero(c : &mut Criterion) { + bench_nanoseconds_to_string(c, 0, "", "zero"); +} + + +pub fn BENCHMARK_nanoseconds_to_string_ns(c : &mut Criterion) { + bench_nanoseconds_to_string(c, 9, "", "9 ns"); + bench_nanoseconds_to_string(c, 789, "", "789 ns"); +} + + +pub fn BENCHMARK_nanoseconds_to_string_us(c : &mut Criterion) { + bench_nanoseconds_to_string(c, 6_789, "", "6.789 µs"); + bench_nanoseconds_to_string(c, 6_000, "", "6 µs"); +} + + +pub fn BENCHMARK_nanoseconds_to_string_ms(c : &mut Criterion) { + bench_nanoseconds_to_string(c, 123_456_789, "", "123.4 ms"); + bench_nanoseconds_to_string(c, 123_000_000, "", "123 ms"); + bench_nanoseconds_to_string(c, 999_772_000, "", "999.7 ms edge"); +} + + +pub fn BENCHMARK_nanoseconds_to_string_s(c : &mut Criterion) { + bench_nanoseconds_to_string(c, 9_123_456_789, "", "9.123 s"); + bench_nanoseconds_to_string(c, 9_000_000_000, "", "9 s"); + bench_nanoseconds_to_string(c, 77_777_777_777_777_777, "", "77e16 ns → s"); +} + + +pub fn BENCHMARK_nanoseconds_to_string_negative(c : &mut Criterion) { + bench_nanoseconds_to_string(c, -123_456_789, "", "negative 123.4 ms"); +} + + +pub fn BENCHMARK_nanoseconds_to_string_explicit_plus(c : &mut Criterion) { + bench_nanoseconds_to_string(c, 999_772_000, "+", "999.7 ms with +"); +} + + +pub fn BENCHMARK_nanoseconds_to_string_mixed_workload(c : &mut Criterion) { + c.bench_function("`nanoseconds_to_string()` [mixed representative values]", |b| { + b.iter_batched( + || 0usize, + |index| { + let (nanoseconds, _label) = REPRESENTATIVE_VALUES[index % REPRESENTATIVE_VALUES.len()]; + + let s = black_box(nanoseconds_to_string(black_box(nanoseconds), "")); + + black_box(s); + + index + 1 + }, + BatchSize::SmallInput, + ) + }); +} + + +criterion_group!( + benches, + BENCHMARK_nanoseconds_to_string_zero, + BENCHMARK_nanoseconds_to_string_ns, + BENCHMARK_nanoseconds_to_string_us, + BENCHMARK_nanoseconds_to_string_ms, + BENCHMARK_nanoseconds_to_string_s, + BENCHMARK_nanoseconds_to_string_negative, + BENCHMARK_nanoseconds_to_string_explicit_plus, + BENCHMARK_nanoseconds_to_string_mixed_workload, +); +criterion_main!(benches); diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 2edc384..cfb00e6 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -15,6 +15,7 @@ declare_and_publish!(doomgram, DoomGram, doom_scope); declare_and_publish!(ellipsis, Ellipsis); mod flf; declare_and_publish!(password, Password); +declare_and_publish!(time_format, nanoseconds_to_string); // ///////////////////////////// end of file //////////////////////////// // diff --git a/src/diagnostics/time_format.rs b/src/diagnostics/time_format.rs new file mode 100644 index 0000000..29b05cf --- /dev/null +++ b/src/diagnostics/time_format.rs @@ -0,0 +1,334 @@ +// src/diagnostics/time_format.rs : duration formatting + +// NOTE: this work was brought in from **asynkio** via **Diagnosticism.Python** +// 0.16.0 + +const SCALES : [i64; 12] = [ + 1, + 10, + 100, + 1_000, + 10_000, + 100_000, + 1_000_000, + 10_000_000, + 100_000_000, + 1_000_000_000, + 10_000_000_000, + 100_000_000_000, +]; + +const SUFFIXES : [&str; 4] = [ + "ns", + "µs", + "ms", + "s", +]; + + +/// Formats a nanosecond count as a compact human-readable duration string. +/// +/// The output adapts the unit (`ns`, `µs`, `ms`, `s`) and decimal precision +/// to keep roughly three significant digits in the numeric portion. +/// +/// Behaviour matches [`Diagnosticism.Python`][dp] 0.16.0 +/// `nanoseconds_to_string()`. +/// +/// # Parameters +/// +/// * `nanoseconds` — the duration, in nanoseconds; +/// * `format_spec` — formatting options; the only recognised flag is `+`, +/// which causes positive values to include an explicit leading sign; +/// other characters are ignored; +/// +/// # Returns +/// +/// The formatted duration string. Zero is always `"0s"` with no sign. +/// +/// [dp]: https://github.com/synesissoftware/Diagnosticism.Python +pub fn nanoseconds_to_string( + nanoseconds : i64, + format_spec : &str, +) -> String { + let mut v = nanoseconds; + + let sign = if v < 0 { + v = -v; + + "-" + } else if format_spec.contains('+') { + "+" + } else { + "" + }; + + if v == 0 { + return String::from("0s"); + } + + let (oom, divisor) = scale_index(v); + + let suffix = SUFFIXES[oom / 3]; + + if oom < 3 { + return fmt(sign, v, 0, suffix); + } + + let divisor_0 = divisor / 1_000; + + let i = oom % 3; + + let divisor_1 = if i == 0 { + 1_000 + } else if i == 1 { + 100 + } else { + 10 + }; + + v /= divisor_0; + + let whole = v / divisor_1; + let frac = v - (whole * divisor_1); + + fmt(sign, whole, frac, suffix) +} + + +fn scale_index(n : i64) -> (usize, i64) { + debug_assert!(n > 0); + + if n >= 100_000_000_000 { + return (11, SCALES[11]); + } + + let mut l = 0; + let mut h = 11; + + let mut count = 0; + + while l <= h { + count += 1; + + debug_assert!(count < 5); + + let m = (h + l) / 2; + + let b = SCALES[m]; + + if n == b { + return (m, b); + } + + if n < b { + h = m; + + continue; + } + + debug_assert!(n > b); + + if n < b * 10 { + return (m, b); + } + + l = m; + } + + (11, SCALES[11]) +} + + +fn fmt( + sign : &str, + whole : i64, + frac : i64, + suffix : &str, +) -> String { + if frac == 0 { + return format!("{sign}{whole}{suffix}"); + } + + if whole > 999 { + return format!("{sign}{whole}{suffix}"); + } + + if whole > 99 { + return format!("{sign}{whole}.{frac}{suffix}"); + } + + if whole > 9 { + return format!("{sign}{whole}.{frac:02}{suffix}"); + } + + format!("{sign}{whole}.{frac}{suffix}") +} + + +#[cfg(test)] +mod tests { + #![allow(non_snake_case)] + + use super::nanoseconds_to_string; + + + fn assert_ns( + nanoseconds : i64, + format_spec : &str, + expected : &str, + ) { + assert_eq!( + expected, + nanoseconds_to_string(nanoseconds, format_spec), + ); + } + + + #[test] + fn TEST_zero() { + assert_ns(0, "", "0s"); + assert_ns(0, "+", "0s"); + } + + + #[test] + fn TEST_one_second() { + assert_ns(1_000_000_000, "", "1s"); + } + + + #[test] + fn TEST_123_milliseconds() { + assert_ns(123_000_000, "", "123ms"); + } + + + #[test] + fn TEST_123_456_789_nanoseconds() { + assert_ns(123_456_789, "", "123.4ms"); + } + + + #[test] + fn TEST_STRINGS() { + #[rustfmt::skip] + let cases = [ + ( 0, "0s"), + ( 9, "9ns"), + ( 89, "89ns"), + ( 789, "789ns"), + ( 6_789, "6.789µs"), + ( 56_789, "56.78µs"), + ( 456_789, "456.7µs"), + ( 3_456_789, "3.456ms"), + ( 23_456_789, "23.45ms"), + ( 123_456_789, "123.4ms"), + ( 9_123_456_789, "9.123s"), + ( 89_123_456_789, "89.12s"), + ( 789_123_456_789, "789.1s"), + ( 80, "80ns"), + ( 700, "700ns"), + ( 6_000, "6µs"), + ( 50_000, "50µs"), + ( 400_000, "400µs"), + ( 3_000_000, "3ms"), + ( 20_000_000, "20ms"), + ( 100_000_000, "100ms"), + ( 9_000_000_000, "9s"), + ( 10_000_000_000, "10s"), + ( 200_000_000_000, "200s"), + ( 3_000_000_000_000, "3000s"), + ( 40_000_000_000_000, "40000s"), + ( 500_000_000_000_000, "500000s"), + ( 6_000_000_000_000_000, "6000000s"), + (70_000_000_000_000_000, "70000000s"), + ( 11_111_111_111, "11.11s"), + ( 222_222_222_222, "222.2s"), + ( 3_333_333_333_333, "3333s"), + ( 44_444_444_444_444, "44444s"), + ( 555_555_555_555_555, "555555s"), + ( 6_666_666_666_666_666, "6666666s"), + (77_777_777_777_777_777, "77777777s"), + ]; + + for (nanoseconds, expected) in cases { + assert_ns(nanoseconds, "", expected); + } + } + + + #[test] + fn TEST_NEGATIVE_VALUES_STRINGS() { + #[rustfmt::skip] + let cases = [ + ( -9, "-9ns"), + ( -89, "-89ns"), + ( -789, "-789ns"), + ( -6_789, "-6.789µs"), + ( -56_789, "-56.78µs"), + ( -456_789, "-456.7µs"), + ( -3_456_789, "-3.456ms"), + ( -23_456_789, "-23.45ms"), + ( -123_456_789, "-123.4ms"), + ( -9_123_456_789, "-9.123s"), + ( -80, "-80ns"), + ( -700, "-700ns"), + ( -6_000, "-6µs"), + ( -50_000, "-50µs"), + ( -400_000, "-400µs"), + ( -3_000_000, "-3ms"), + ( -20_000_000, "-20ms"), + ( -100_000_000, "-100ms"), + ( -9_000_000_000, "-9s"), + ( -10_000_000_000, "-10s"), + ( -200_000_000_000, "-200s"), + ( -3_000_000_000_000, "-3000s"), + (-40_000_000_000_000, "-40000s"), + ]; + + for (nanoseconds, expected) in cases { + assert_ns(nanoseconds, "", expected); + } + } + + + #[rustfmt::skip] + #[test] + fn TEST_observed_edge_cases() { + assert_ns( 999_772_000, "", "999.7ms"); + assert_ns( 999_800_000, "", "999.8ms"); + assert_ns( 999_974_000, "", "999.9ms"); + + assert_ns(-999_772_000, "", "-999.7ms"); + assert_ns(-999_800_000, "", "-999.8ms"); + assert_ns(-999_974_000, "", "-999.9ms"); + } + + + #[rustfmt::skip] + #[test] + fn TEST_with_plus_sign() { + assert_ns( 999_772_000, "", "999.7ms"); + assert_ns( 999_800_000, "", "999.8ms"); + assert_ns( 999_974_000, "", "999.9ms"); + + assert_ns(-999_772_000, "", "-999.7ms"); + assert_ns(-999_800_000, "", "-999.8ms"); + assert_ns(-999_974_000, "", "-999.9ms"); + + assert_ns( 999_772_000, "", "999.7ms"); + assert_ns( 999_800_000, "", "999.8ms"); + assert_ns( 999_974_000, "", "999.9ms"); + + assert_ns( 999_772_000, "+", "+999.7ms"); + assert_ns( 999_800_000, "+", "+999.8ms"); + assert_ns( 999_974_000, "+", "+999.9ms"); + + assert_ns(-999_772_000, "+", "-999.7ms"); + assert_ns(-999_800_000, "+", "-999.8ms"); + assert_ns(-999_974_000, "+", "-999.9ms"); + } +} + + +// ///////////////////////////// end of file //////////////////////////// // diff --git a/src/lib.rs b/src/lib.rs index b3ea0c7..7f4bef5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,8 @@ //! [`Debug`](std::fmt::Debug) fields; //! * [`doom_scope`] — time a closure and record the elapsed duration in a //! [`DoomGram`]; +//! * [`nanoseconds_to_string`] — format a nanosecond count as a compact +//! human-readable duration string; //! //! ## Macros (crate root) //! @@ -78,6 +80,7 @@ pub mod diagnostics; pub use diagnostics::{ doom_scope, + nanoseconds_to_string, DebugSqueezer, DoomGram, Ellipsis, From 910b44c22b2c776a4002b583a5d28681b364e388 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 15:27:27 +1000 Subject: [PATCH 2/9] chore: improved documentation in **src/lib.rs** --- src/lib.rs | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7f4bef5..dac1d8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,28 @@ -//! Miscellaneous discrete and simple diagnostics facilities for Rust. -//! -//! **Diagnosticism** supplements what is available in the standard library. -//! It is implemented in several languages; in Rust the facilities are -//! (currently) aimed around supplementing [`Debug`](std::fmt::Debug), -//! together with lightweight timing and source-location helpers. -//! -//! For example, [`Ellipsis`] can be used in a custom -//! [`Debug`](std::fmt::Debug) implementation to elide fields in terse -//! (`"{:?}"`) output while still including them in alternate (`"{:#?}"`) -//! form. +//! Simple diagnostics utilities for Rust — part of the cross-language +//! **Diagnosticism** family. +//! +//! **Diagnosticism** offers small, focused helpers that extend the +//! standard library for logging, profiling, and debug output. The +//! project is implemented in several languages; each port exposes +//! facilities that are useful and idiomatic in that environment. +//! (See [**Diagnosticism.Python**][dp] for a wider API, including +//! tracing and callstack capture.) +//! +//! In Rust, this crate focuses on three areas: +//! +//! * **[`Debug`](std::fmt::Debug) helpers** — control what appears in +//! log output ([`Ellipsis`], [`Password`], [`DebugSqueezer`]); +//! * **Timing** — record duration distributions ([`DoomGram`]), +//! measure closures ([`doom_scope`]), and format nanoseconds +//! ([`nanoseconds_to_string`]); +//! * **Source location** — compile-time file, line, and function +//! macros (`fileline!`, `filelinefunction!`, and others). +//! +//! For example, [`Ellipsis`] in a custom [`Debug`](std::fmt::Debug) +//! implementation can elide fields in terse `"{:?}"` output while +//! still including them in alternate `"{:#?}"` form. +//! +//! [dp]: https://github.com/synesissoftware/Diagnosticism.Python //! //! # Installation //! From 45f23d76366034699c32c30ef9d2f0f551cefaeb Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 15:28:39 +1000 Subject: [PATCH 3/9] fix --- benches/time_format.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/benches/time_format.rs b/benches/time_format.rs index b77dd97..e6d7a7e 100644 --- a/benches/time_format.rs +++ b/benches/time_format.rs @@ -14,8 +14,9 @@ use criterion::{ }; -/// Representative nanosecond counts spanning each output band and formatting -/// path (integer-only, fractional, large whole, zero, negative, explicit `+`). +/// Representative nanosecond counts spanning each output band and +/// formatting path (integer-only, fractional, large whole, zero, negative, +/// explicit `+`). const REPRESENTATIVE_VALUES : [(i64, &str); 12] = [ (0, "zero"), (9, "9 ns"), From a4bb22f3714c8d1606ed7b6602ace49aba1f0ad3 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 15:31:53 +1000 Subject: [PATCH 4/9] improved docs --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 98c255f..20f06d5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,16 @@ The following function is re-exported at the crate root (and defined in the [`di * `doom_scope()` - executes a closure, records its elapsed time in a [`DoomGram`](https://docs.rs/diagnosticism/latest/diagnosticism/struct.DoomGram.html), and returns the closure's result together with the measured elapsed time (in nanoseconds). See the example [**examples/doomgram.md**](./examples/doomgram.md); * `nanoseconds_to_string()` - formats a nanosecond count as a compact human-readable duration string (units `ns`, `µs`, `ms`, `s` with roughly three significant digits); behaviour matches [**Diagnosticism.Python**](https://github.com/synesissoftware/Diagnosticism.Python) 0.16.0; +For example: + +```Rust +use diagnosticism::nanoseconds_to_string; + +nanoseconds_to_string(123_456_789, ""); // "123.4ms" +nanoseconds_to_string( 6_789, ""); // "6.789µs" +nanoseconds_to_string(999_772_000, "+"); // "+999.7ms" +``` + ### Macros From 67cecf97aa7af8db523d5e4f3fdc87fabe5fe0c0 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 16:43:38 +1000 Subject: [PATCH 5/9] feat(diagnosticism): return `NanosecondsStr` from `nanoseconds_to_string()` Avoid heap allocation on typical outputs with inline 15-byte NanosecondsStr storage, manual buffer formatting, and ilog10 scale selection; split time_format into format and nanoseconds_str submodules and export the type at the crate root. --- .cursor/rules/rust-standards.mdc | 12 + CHANGES.md | 2 +- README.md | 9 +- TODO.md | 1 + scripts/check_derives.py | 129 +++++++ src/diagnostics/mod.rs | 2 +- .../{time_format.rs => time_format/format.rs} | 160 +++++--- src/diagnostics/time_format/mod.rs | 13 + .../time_format/nanoseconds_str.rs | 357 ++++++++++++++++++ src/lib.rs | 10 +- 10 files changed, 626 insertions(+), 69 deletions(-) create mode 100644 scripts/check_derives.py rename src/diagnostics/{time_format.rs => time_format/format.rs} (74%) create mode 100644 src/diagnostics/time_format/mod.rs create mode 100644 src/diagnostics/time_format/nanoseconds_str.rs diff --git a/.cursor/rules/rust-standards.mdc b/.cursor/rules/rust-standards.mdc index 24470c8..29f947d 100644 --- a/.cursor/rules/rust-standards.mdc +++ b/.cursor/rules/rust-standards.mdc @@ -23,6 +23,18 @@ alwaysApply: false Run `./scripts/check_doc_76.py` to verify compliance. +## DERIVE_LAYOUT (Derive Layout & Formatting) + +Multi-trait `#[derive(...)]` macros must be split into separate, +single-trait lines, ordered alphabetically by trait name. Tightly coupled +traits may remain on one line: `#[derive(Eq, PartialEq)]`, +`#[derive(Ord, PartialOrd)]`. + +`rustfmt.toml` sets `merge_derives = false` to preserve this layout. + +Run `./scripts/check_derives.py` to verify compliance. + + ## RUST_TEST_NAMING All test methods must use `SHOUTING_SNAKE_CASE` - e.g. `TEST_PARSING()` - diff --git a/CHANGES.md b/CHANGES.md index 44f0786..dfb61cd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,7 +3,7 @@ ## 0.3.0 - 27th June 2026 -* added `nanoseconds_to_string()` — compact human-readable duration formatting (behaviour matches **Diagnosticism.Python** 0.16.0); +* added `nanoseconds_to_string()` — compact human-readable duration formatting (behaviour matches **Diagnosticism.Python** 0.16.0) with a custom return type `NanosecondsStr` for highly efficient conversion in vast majority of cases; ## 0.2.1 - 27th June 2026 diff --git a/README.md b/README.md index 20f06d5..0d6e7ea 100644 --- a/README.md +++ b/README.md @@ -78,16 +78,16 @@ The following optional features are defined in **Cargo.toml**: The following function is re-exported at the crate root (and defined in the [`diagnostics`](https://docs.rs/diagnosticism/latest/diagnosticism/diagnostics/index.html) module): * `doom_scope()` - executes a closure, records its elapsed time in a [`DoomGram`](https://docs.rs/diagnosticism/latest/diagnosticism/struct.DoomGram.html), and returns the closure's result together with the measured elapsed time (in nanoseconds). See the example [**examples/doomgram.md**](./examples/doomgram.md); -* `nanoseconds_to_string()` - formats a nanosecond count as a compact human-readable duration string (units `ns`, `µs`, `ms`, `s` with roughly three significant digits); behaviour matches [**Diagnosticism.Python**](https://github.com/synesissoftware/Diagnosticism.Python) 0.16.0; +* `nanoseconds_to_string()` - formats a nanosecond count as a compact human-readable duration string (units `ns`, `µs`, `ms`, `s` with roughly three significant digits); returns a [`NanosecondsStr`](https://docs.rs/diagnosticism/latest/diagnosticism/struct.NanosecondsStr.html); behaviour matches [**Diagnosticism.Python**](https://github.com/synesissoftware/Diagnosticism.Python) 0.16.0; For example: ```Rust use diagnosticism::nanoseconds_to_string; -nanoseconds_to_string(123_456_789, ""); // "123.4ms" -nanoseconds_to_string( 6_789, ""); // "6.789µs" -nanoseconds_to_string(999_772_000, "+"); // "+999.7ms" +assert_eq!( "123.4ms", nanoseconds_to_string(123_456_789, "")); +assert_eq!( "6.789µs", nanoseconds_to_string( 6_789, "")); +assert_eq!("+999.7ms", nanoseconds_to_string(999_772_000, "+")); ``` @@ -109,6 +109,7 @@ The following structures are re-exported at the crate root (and defined in the [ * `DebugSqueezer` - used to assist with restricting the length of `Debug` forms of fields within a given width. See the example [**examples/debug_squeezer.md**](./examples/debug_squeezer.md); * `DoomGram` - a **D**ecimal **O**rder-**O**f-**M**agnitude histo**G**ram structure that records efficiently duration values in the orders of magnitude 1ns+, 10ns+, 100ns+, 1µs+, ..., 10s+, 100s+ and provides a mechanism for displaying this histogram in a simple single 12-character display, which is useful for logging cumulative execution costs of components in long-running performance-sensitive applications. See the example [**examples/doomgram.md**](./examples/doomgram.md); +* `NanosecondsStr` - compact storage for a formatted duration string; returned by `nanoseconds_to_string()`; typical outputs fit in 15 inline UTF-8 bytes without heap allocation; implements `Display`, `Deref` to `str`, and `AsRef`; * `Ellipsis` - provides the string `"..."` to be used for fields whose `Debug` forms are not to be expressed in terse (non-`#alternate()`) output. See the example [**examples/ellipsis.md**](./examples/ellipsis.md); * `Password` - provides strings such as `"********"` to be used for fields that are sensitive and whose `Debug` forms are not to be expressed. See the example [**examples/password.md**](./examples/password.md); diff --git a/TODO.md b/TODO.md index ec586ba..0145aa3 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,7 @@ * [ ] `DebugSqueezer`: avoid formatting the entire [`Debug`](https://doc.rust-lang.org/std/fmt/trait.Debug.html) string before truncation (current implementation uses `format!()` then `truncate()`; investigate streaming/early-limit approaches that preserve readable elision suffixes without allocating for oversized output); * [ ] [`DoomGram::to_strip()`](https://docs.rs/diagnosticism/latest/diagnosticism/struct.DoomGram.html#method.to_strip): reduce cost of building the 12-character histogram strip (currently ~20µs debug / ~2µs release; see in-code TODO in `doomgram.rs` — pre-fill with `_`, skip `gram_doom_to_char()` for zero counts, iterate bucket counts from an array rather than separate fields; validate with `benches/doomgram.rs`); +* [ ] Determine whether use of `ilog10()` can help to reduce performance costs of `DoomGram`; diff --git a/scripts/check_derives.py b/scripts/check_derives.py new file mode 100644 index 0000000..dc0f30d --- /dev/null +++ b/scripts/check_derives.py @@ -0,0 +1,129 @@ +#! /usr/bin/env python3 +""" +Verify DERIVE_LAYOUT: multi-trait `#[derive(...)]` macros must be split +into separate single-trait lines, ordered alphabetically by trait name, +except tightly coupled groups (Eq/PartialEq, Ord/PartialOrd). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +COUPLED_TRAIT_GROUPS = [ + ["Eq", "PartialEq"], + ["Ord", "PartialOrd"], +] + +PASS = "\N{WHITE HEAVY CHECK MARK}" # ✅ +FAIL = "\N{CROSS MARK}" # ❌ + + +def lint_file(filepath: Path) -> list[str]: + errors: list[str] = [] + + lines = filepath.read_text(encoding="utf-8").splitlines() + i = 0 + + while i < len(lines): + line = lines[i] + + if not re.match(r"^\s*#\[derive\(", line): + i += 1 + continue + + derive_block: list[tuple[int, str]] = [] + start_line_num = i + 1 + + while i < len(lines) and re.match(r"^\s*#\[derive\(", lines[i]): + derive_block.append((i + 1, lines[i])) + i += 1 + + parsed_lines: list[tuple[int, str, str]] = [] + block_has_error = False + + for line_num, line_str in derive_block: + match = re.search(r"#\[derive\((.*?)\)\]", line_str) + + if not match: + continue + + traits = [ + t.strip() + for t in match.group(1).split(",") + if t.strip() + ] + + if len(traits) > 1: + if traits not in COUPLED_TRAIT_GROUPS: + block_has_error = True + allowed = ", ".join( + f"'{', '.join(group)}'" + for group in COUPLED_TRAIT_GROUPS + ) + errors.append( + f"{filepath}:{line_num}: multi-trait derive " + f"'{line_str.strip()}' is not allowed " + f"(except coupled groups: {allowed})", + ) + elif len(traits) == 0: + block_has_error = True + errors.append( + f"{filepath}:{line_num}: empty derive attribute " + f"'{line_str.strip()}'", + ) + + sort_key = traits[0] if traits else "" + parsed_lines.append((line_num, line_str, sort_key)) + + if not block_has_error and len(parsed_lines) > 1: + sort_keys = [item[2] for item in parsed_lines] + + if sort_keys != sorted(sort_keys): + actual = [item[1].strip() for item in parsed_lines] + expected = [ + item[1].strip() + for item in sorted(parsed_lines, key=lambda x: x[2]) + ] + errors.append( + f"{filepath}:{start_line_num}: derive attributes not " + f"sorted alphabetically\n" + f" actual: {actual}\n" + f" expected: {expected}", + ) + + return errors + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + errors: list[str] = [] + + for directory in ("src", "examples", "benches"): + base = root / directory + + if not base.is_dir(): + continue + + for path in sorted(base.rglob("*.rs")): + if "target" in path.parts: + continue + + errors.extend(lint_file(path)) + + if errors: + print( + f"{FAIL} DERIVE_LAYOUT violations:", + file=sys.stderr, + ) + print("\n".join(f" {FAIL} {error}" for error in errors), file=sys.stderr) + return 1 + + print(f"{PASS} DERIVE_LAYOUT: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index cfb00e6..3aaeb61 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -15,7 +15,7 @@ declare_and_publish!(doomgram, DoomGram, doom_scope); declare_and_publish!(ellipsis, Ellipsis); mod flf; declare_and_publish!(password, Password); -declare_and_publish!(time_format, nanoseconds_to_string); +declare_and_publish!(time_format, NanosecondsStr, nanoseconds_to_string); // ///////////////////////////// end of file //////////////////////////// // diff --git a/src/diagnostics/time_format.rs b/src/diagnostics/time_format/format.rs similarity index 74% rename from src/diagnostics/time_format.rs rename to src/diagnostics/time_format/format.rs index 29b05cf..4463b75 100644 --- a/src/diagnostics/time_format.rs +++ b/src/diagnostics/time_format/format.rs @@ -1,7 +1,7 @@ -// src/diagnostics/time_format.rs : duration formatting +// src/diagnostics/time_format/format.rs : `nanoseconds_to_string()` + +use super::nanoseconds_str::NanosecondsStr; -// NOTE: this work was brought in from **asynkio** via **Diagnosticism.Python** -// 0.16.0 const SCALES : [i64; 12] = [ 1, @@ -26,7 +26,10 @@ const SUFFIXES : [&str; 4] = [ ]; -/// Formats a nanosecond count as a compact human-readable duration string. +// API functions + +/// Formats a nanosecond count as a compact human-readable duration string, +/// returning a [`NanosecondsStr`]. /// /// The output adapts the unit (`ns`, `µs`, `ms`, `s`) and decimal precision /// to keep roughly three significant digits in the numeric portion. @@ -43,27 +46,28 @@ const SUFFIXES : [&str; 4] = [ /// /// # Returns /// -/// The formatted duration string. Zero is always `"0s"` with no sign. +/// A [`NanosecondsStr`] holding the formatted duration. Zero is always +/// `"0s"` with no sign. /// /// [dp]: https://github.com/synesissoftware/Diagnosticism.Python pub fn nanoseconds_to_string( nanoseconds : i64, format_spec : &str, -) -> String { +) -> NanosecondsStr { let mut v = nanoseconds; - let sign = if v < 0 { + let sign_byte = if v < 0 { v = -v; - "-" + Some(b'-') } else if format_spec.contains('+') { - "+" + Some(b'+') } else { - "" + None }; if v == 0 { - return String::from("0s"); + return NanosecondsStr::from_buffer(b"0s"); } let (oom, divisor) = scale_index(v); @@ -71,7 +75,7 @@ pub fn nanoseconds_to_string( let suffix = SUFFIXES[oom / 3]; if oom < 3 { - return fmt(sign, v, 0, suffix); + return format_parts(sign_byte, v, 0, suffix); } let divisor_0 = divisor / 1_000; @@ -91,77 +95,115 @@ pub fn nanoseconds_to_string( let whole = v / divisor_1; let frac = v - (whole * divisor_1); - fmt(sign, whole, frac, suffix) + format_parts(sign_byte, whole, frac, suffix) } +// Helper functions + fn scale_index(n : i64) -> (usize, i64) { debug_assert!(n > 0); - if n >= 100_000_000_000 { - return (11, SCALES[11]); - } + let oom = if n >= 100_000_000_000 { + 11 + } else { + n.ilog10() as usize + }; - let mut l = 0; - let mut h = 11; + (oom, SCALES[oom]) +} - let mut count = 0; - while l <= h { - count += 1; +fn format_parts( + sign_byte : Option, + whole : i64, + frac : i64, + suffix : &str, +) -> NanosecondsStr { + let mut buf = [0u8; 24]; + let mut pos = 0usize; - debug_assert!(count < 5); + if let Some(b) = sign_byte { + buf[pos] = b; - let m = (h + l) / 2; + pos += 1; + } - let b = SCALES[m]; + pos = write_u64(&mut buf, whole as u64, pos); - if n == b { - return (m, b); - } + if frac != 0 && whole <= 999 { + buf[pos] = b'.'; - if n < b { - h = m; + pos += 1; - continue; + if whole > 99 { + pos = write_u64(&mut buf, frac as u64, pos); + } else if whole > 9 { + pos = write_frac_min_width_2(&mut buf, pos, frac); + } else { + pos = write_u64(&mut buf, frac as u64, pos); } + } - debug_assert!(n > b); + pos = write_bytes(&mut buf, pos, suffix.as_bytes()); + + NanosecondsStr::from_buffer(&buf[..pos]) +} - if n < b * 10 { - return (m, b); - } - l = m; +fn write_u64( + buf : &mut [u8], + mut n : u64, + mut pos : usize, +) -> usize { + if n == 0 { + buf[pos] = b'0'; + + return pos + 1; + } + + let start = pos; + + while n > 0 { + buf[pos] = (n % 10) as u8 + b'0'; + + n /= 10; + + pos += 1; } - (11, SCALES[11]) + buf[start..pos].reverse(); + + pos } -fn fmt( - sign : &str, - whole : i64, +fn write_frac_min_width_2( + buf : &mut [u8], + pos : usize, frac : i64, - suffix : &str, -) -> String { - if frac == 0 { - return format!("{sign}{whole}{suffix}"); - } +) -> usize { + debug_assert!((0..100).contains(&frac)); - if whole > 999 { - return format!("{sign}{whole}{suffix}"); - } + if frac >= 10 { + write_u64(buf, frac as u64, pos) + } else { + buf[pos] = b'0'; + buf[pos + 1] = (frac as u8) + b'0'; - if whole > 99 { - return format!("{sign}{whole}.{frac}{suffix}"); + pos + 2 } +} - if whole > 9 { - return format!("{sign}{whole}.{frac:02}{suffix}"); - } - format!("{sign}{whole}.{frac}{suffix}") +fn write_bytes( + buf : &mut [u8], + pos : usize, + bytes : &[u8], +) -> usize { + buf[pos..pos + bytes.len()].copy_from_slice(bytes); + + pos + bytes.len() } @@ -185,26 +227,26 @@ mod tests { #[test] - fn TEST_zero() { + fn TEST_ZERO() { assert_ns(0, "", "0s"); assert_ns(0, "+", "0s"); } #[test] - fn TEST_one_second() { + fn TEST_ONE_SECOND() { assert_ns(1_000_000_000, "", "1s"); } #[test] - fn TEST_123_milliseconds() { + fn TEST_123_MILLISECONDS() { assert_ns(123_000_000, "", "123ms"); } #[test] - fn TEST_123_456_789_nanoseconds() { + fn TEST_123_456_789_NANOSECONDS() { assert_ns(123_456_789, "", "123.4ms"); } @@ -294,7 +336,7 @@ mod tests { #[rustfmt::skip] #[test] - fn TEST_observed_edge_cases() { + fn TEST_OBSERVED_EDGE_CASES() { assert_ns( 999_772_000, "", "999.7ms"); assert_ns( 999_800_000, "", "999.8ms"); assert_ns( 999_974_000, "", "999.9ms"); @@ -307,7 +349,7 @@ mod tests { #[rustfmt::skip] #[test] - fn TEST_with_plus_sign() { + fn TEST_WITH_PLUS_SIGN() { assert_ns( 999_772_000, "", "999.7ms"); assert_ns( 999_800_000, "", "999.8ms"); assert_ns( 999_974_000, "", "999.9ms"); diff --git a/src/diagnostics/time_format/mod.rs b/src/diagnostics/time_format/mod.rs new file mode 100644 index 0000000..db5d713 --- /dev/null +++ b/src/diagnostics/time_format/mod.rs @@ -0,0 +1,13 @@ +// src/diagnostics/time_format/mod.rs : duration formatting + +// NOTE: this work was brought in from **asynkio** via **Diagnosticism.Python** +// 0.16.0 + +mod format; +mod nanoseconds_str; + +pub use format::nanoseconds_to_string; +pub use nanoseconds_str::NanosecondsStr; + + +// ///////////////////////////// end of file //////////////////////////// // diff --git a/src/diagnostics/time_format/nanoseconds_str.rs b/src/diagnostics/time_format/nanoseconds_str.rs new file mode 100644 index 0000000..333b72d --- /dev/null +++ b/src/diagnostics/time_format/nanoseconds_str.rs @@ -0,0 +1,357 @@ +// src/diagnostics/time_format/nanoseconds_str.rs : `NanosecondsStr` + +use std::{ + borrow::Borrow, + fmt as std_fmt, + ops::Deref, +}; + + +const INLINE_CAP : usize = 15; + + +/// Compact storage for a formatted nanosecond duration string. +/// +/// Obtain values from [`nanoseconds_to_string`]. Most outputs fit in +/// [`INLINE_CAP`] UTF-8 bytes and are stored inline without heap +/// allocation. Longer results use a [`String`] variant. +#[derive(Clone)] +#[derive(Eq)] +pub struct NanosecondsStr { + inner : NanosecondsStrInner, +} + +#[derive(Clone)] +#[derive(Eq, PartialEq)] +enum NanosecondsStrInner { + Inline { + len : u8, + bytes : [u8; INLINE_CAP], + }, + Heap(String), +} + + +// API functions + +impl NanosecondsStr { + pub(in crate::diagnostics::time_format) fn from_buffer(buf : &[u8]) -> Self { + debug_assert!(std::str::from_utf8(buf).is_ok()); + + if buf.len() <= INLINE_CAP { + let mut bytes = [0u8; INLINE_CAP]; + + bytes[..buf.len()].copy_from_slice(buf); + + Self { + inner : NanosecondsStrInner::Inline { + len : buf.len() as u8, + bytes, + }, + } + } else { + Self { + inner : NanosecondsStrInner::Heap(String::from_utf8(buf.to_vec()).unwrap()), + } + } + } +} + + +// Mutating methods + +impl NanosecondsStr { +} + + +// Nonmutating methods + +impl NanosecondsStr { + /// Borrows the formatted UTF-8 string. + pub fn as_str(&self) -> &str { + match &self.inner { + NanosecondsStrInner::Inline { + len, + bytes, + } => { + let len = *len as usize; + + // SAFETY: `bytes` holds valid UTF-8 written by this module. + unsafe { std::str::from_utf8_unchecked(&bytes[..len]) } + }, + NanosecondsStrInner::Heap(s) => s.as_str(), + } + } + + #[cfg(test)] + fn is_heap(&self) -> bool { + matches!(self.inner, NanosecondsStrInner::Heap(_)) + } +} + + +// Trait implementations + +impl AsRef for NanosecondsStr { + fn as_ref(&self) -> &str { + self.as_str() + } +} + + +impl Borrow for NanosecondsStr { + fn borrow(&self) -> &str { + self.as_str() + } +} + + +impl std_fmt::Debug for NanosecondsStr { + fn fmt( + &self, + f : &mut std_fmt::Formatter<'_>, + ) -> std_fmt::Result { + std_fmt::Debug::fmt(self.as_str(), f) + } +} + + +impl Deref for NanosecondsStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + + +impl std_fmt::Display for NanosecondsStr { + fn fmt( + &self, + f : &mut std_fmt::Formatter<'_>, + ) -> std_fmt::Result { + f.write_str(self.as_str()) + } +} + + +impl From for String { + fn from(value : NanosecondsStr) -> Self { + match value.inner { + NanosecondsStrInner::Inline { + len, + bytes, + } => { + let len = len as usize; + + String::from_utf8(bytes[..len].to_vec()).unwrap() + }, + NanosecondsStrInner::Heap(s) => s, + } + } +} + + +impl PartialEq for &str { + fn eq( + &self, + other : &NanosecondsStr, + ) -> bool { + *self == other.as_str() + } +} + + +impl PartialEq<&str> for NanosecondsStr { + fn eq( + &self, + other : &&str, + ) -> bool { + self.as_str() == *other + } +} + + +impl PartialEq for NanosecondsStr { + fn eq( + &self, + other : &Self, + ) -> bool { + self.as_str() == other.as_str() + } +} + + +impl PartialEq for NanosecondsStr { + fn eq( + &self, + other : &str, + ) -> bool { + self.as_str() == other + } +} + + +impl PartialEq for str { + fn eq( + &self, + other : &NanosecondsStr, + ) -> bool { + self == other.as_str() + } +} + + +#[cfg(test)] +mod tests { + #![allow(non_snake_case)] + + use super::{ + NanosecondsStr, + INLINE_CAP, + }; + + use super::super::format::nanoseconds_to_string; + + + fn heap_from_bytes(bytes : &[u8]) -> NanosecondsStr { + NanosecondsStr::from_buffer(bytes) + } + + + #[test] + fn TEST_NanosecondsStr_INLINE_STORAGE() { + let s = nanoseconds_to_string(123_456_789, ""); + + assert!(!s.is_heap()); + assert!(s.as_str().len() <= INLINE_CAP); + assert_eq!("123.4ms", s); + assert_eq!("123.4ms", s.as_str()); + assert_eq!("123.4ms", &*s); + assert_eq!("123.4ms", s.as_ref()); + assert_eq!("123.4ms", String::from(s.clone())); + } + + + #[test] + fn TEST_NanosecondsStr_INLINE_AT_INLINE_CAP() { + let expected = "a".repeat(INLINE_CAP); + let s = heap_from_bytes(expected.as_bytes()); + + assert!(!s.is_heap()); + assert_eq!(expected.as_str(), s); + assert_eq!(expected, String::from(s)); + } + + + #[test] + fn TEST_NanosecondsStr_HEAP_AT_INLINE_CAP_PLUS_ONE() { + let expected = "b".repeat(INLINE_CAP + 1); + let s = heap_from_bytes(expected.as_bytes()); + + assert!(s.is_heap()); + assert_eq!(expected.as_str(), s); + } + + + #[test] + fn TEST_NanosecondsStr_HEAP_STORAGE() { + let expected = "x".repeat(INLINE_CAP + 4); + let s = heap_from_bytes(expected.as_bytes()); + + assert!(s.is_heap()); + assert_eq!(expected, s.as_str()); + assert_eq!(expected, &*s); + assert_eq!(expected, s.as_ref()); + assert_eq!(expected, format!("{s}")); + assert_eq!(format!("{expected:?}"), format!("{s:?}")); + assert_eq!(expected, String::from(s)); + } + + + #[test] + fn TEST_NanosecondsStr_HEAP_Clone_AND_String() { + let expected = "overflow-duration-value"; + let original = heap_from_bytes(expected.as_bytes()); + let cloned = original.clone(); + + assert!(original.is_heap()); + assert!(cloned.is_heap()); + assert_eq!(expected, cloned.as_str()); + assert_eq!(expected, String::from(cloned)); + } + + + #[test] + fn TEST_nanoseconds_to_string_ALL_PARITY_CASES_INLINE() { + #[rustfmt::skip] + let nanoseconds = [ + 0_i64, + 9, + 89, + 789, + 6_789, + 56_789, + 456_789, + 3_456_789, + 23_456_789, + 123_456_789, + 9_123_456_789, + 89_123_456_789, + 789_123_456_789, + 80, + 700, + 6_000, + 50_000, + 400_000, + 3_000_000, + 20_000_000, + 100_000_000, + 9_000_000_000, + 10_000_000_000, + 200_000_000_000, + 3_000_000_000_000, + 40_000_000_000_000, + 500_000_000_000_000, + 6_000_000_000_000_000, + 70_000_000_000_000_000, + 11_111_111_111, + 222_222_222_222, + 3_333_333_333_333, + 44_444_444_444_444, + 555_555_555_555_555, + 6_666_666_666_666_666, + 77_777_777_777_777_777, + -9, + -89, + -789, + -6_789, + -56_789, + -456_789, + -3_456_789, + -23_456_789, + -123_456_789, + -9_123_456_789, + 999_772_000, + 999_800_000, + 999_974_000, + -999_772_000, + -999_800_000, + -999_974_000, + ]; + + for value in nanoseconds { + let s = nanoseconds_to_string(value, ""); + + assert!( + !s.is_heap(), + "expected inline storage for {value}, got {:?}", + s.as_str(), + ); + assert!(s.as_str().len() <= INLINE_CAP); + } + } +} + + +// ///////////////////////////// end of file //////////////////////////// // diff --git a/src/lib.rs b/src/lib.rs index dac1d8b..6b40f11 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,8 +13,8 @@ //! * **[`Debug`](std::fmt::Debug) helpers** — control what appears in //! log output ([`Ellipsis`], [`Password`], [`DebugSqueezer`]); //! * **Timing** — record duration distributions ([`DoomGram`]), -//! measure closures ([`doom_scope`]), and format nanoseconds -//! ([`nanoseconds_to_string`]); +//! measure closures ([`doom_scope`]), and format durations via +//! [`nanoseconds_to_string`] into [`NanosecondsStr`]; //! * **Source location** — compile-time file, line, and function //! macros (`fileline!`, `filelinefunction!`, and others). //! @@ -49,8 +49,9 @@ //! [`Debug`](std::fmt::Debug) fields; //! * [`doom_scope`] — time a closure and record the elapsed duration in a //! [`DoomGram`]; -//! * [`nanoseconds_to_string`] — format a nanosecond count as a compact -//! human-readable duration string; +//! * [`NanosecondsStr`] — compact storage for a formatted duration string; +//! * [`nanoseconds_to_string`] — format a nanosecond count into a +//! [`NanosecondsStr`]; //! //! ## Macros (crate root) //! @@ -98,6 +99,7 @@ pub use diagnostics::{ DebugSqueezer, DoomGram, Ellipsis, + NanosecondsStr, Password, }; From 88363e0611b52aa202ce92515b25c8051c56f9f7 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 28 Jun 2026 22:32:32 +1000 Subject: [PATCH 6/9] feature: added `DoomGram::to_mmm()` and `DoomGram::to_nmmm()` --- CHANGES.md | 7 +- Cargo.lock | 2 +- Cargo.toml | 2 +- EXAMPLES.md | 2 +- README.md | 51 +++++++---- benches/doomgram.rs | 120 ++++++++++++++++++++++++- examples/doomgram.md | 52 +++++++---- examples/doomgram.rs | 14 ++- src/diagnostics/doomgram.rs | 171 ++++++++++++++++++++++++++++++++++++ src/diagnostics/mod.rs | 6 +- src/lib.rs | 3 +- 11 files changed, 381 insertions(+), 49 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index cee9bf6..46844bd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,9 @@ # Diagnosticism.Rust - CHANGES -## 0.3.2 - 29th June 2026 +## 0.4.0 - 29th June 2026 -* added `DoomGram#to_mmm()`; -* added `DoomGram#to_nmmm()`; +* added `DoomGram::to_mmm()` and `DoomGram::to_nmmm()` — compact min/mean/max duration summaries using `nanoseconds_to_string()`; ## 0.3.1 - 28th June 2026 @@ -12,7 +11,7 @@ * internal implementation improvements; -## 0.3.0 - 27th June 2026 +## 0.3.0 - 28th June 2026 * added `nanoseconds_to_string()` — compact human-readable duration formatting (behaviour matches **Diagnosticism.Python** 0.16.0); diff --git a/Cargo.lock b/Cargo.lock index 3a961f1..56fb164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,7 +206,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "diagnosticism" -version = "0.3.2" +version = "0.4.0" dependencies = [ "criterion", "rand", diff --git a/Cargo.toml b/Cargo.toml index df4a98c..3a13315 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ name = "diagnosticism" readme = "README.md" repository = "https://github.com/synesissoftware/Diagnosticism.Rust" rust-version = "1.74" -version = "0.3.2" +version = "0.4.0" # ########################################################## diff --git a/EXAMPLES.md b/EXAMPLES.md index c717e5b..1ae8a18 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -3,7 +3,7 @@ |Name|Source & Description|Summary| |---|---|---| |**debug_squeezer**|[examples/debug_squeezer.rs](/examples/debug_squeezer.rs)
[examples/debug_squeezer.md](/examples/debug_squeezer.md)|An example using **Diagnosticism.Rust**'s `DebugSqueezer` type to simplify the `Debug` form of a user-defined type.| -|**doomgram**|[examples/doomgram.rs](/examples/doomgram.rs)
[examples/doomgram.md](/examples/doomgram.md)|An example using **Diagnosticism.Rust**'s `DoomGram` type to represent the performance of some time-consuming operations.| +|**doomgram**|[examples/doomgram.rs](/examples/doomgram.rs)
[examples/doomgram.md](/examples/doomgram.md)|An example using **Diagnosticism.Rust**'s `DoomGram` type to represent the performance of some time-consuming operations, including `to_strip()`, `to_mmm()`, and `to_nmmm()`.| |**ellipsis**|[examples/ellipsis.rs](/examples/ellipsis.rs)
[examples/ellipsis.md](/examples/ellipsis.md)|An example using **Diagnosticism.Rust**'s `Ellipsis` type to shorten the `Debug` form of a user-defined type.| |**password**|[examples/password.rs](/examples/password.rs)
[examples/password.md](/examples/password.md)|An example using **Diagnosticism.Rust**'s `Password` type to secure the `Debug` form of a user-defined type.| diff --git a/README.md b/README.md index ae31a1c..3722325 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ The following macros are defined at the crate root (e.g. `use diagnosticism::fil The following structures are re-exported at the crate root (and defined in the [`diagnostics`](https://docs.rs/diagnosticism/latest/diagnosticism/diagnostics/index.html) module): * `DebugSqueezer` - used to assist with restricting the length of `Debug` forms of fields within a given width. See the example [**examples/debug_squeezer.md**](./examples/debug_squeezer.md); -* `DoomGram` - a **D**ecimal **O**rder-**O**f-**M**agnitude histo**G**ram structure that records efficiently duration values in the orders of magnitude 1ns+, 10ns+, 100ns+, 1µs+, ..., 10s+, 100s+ and provides a mechanism for displaying this histogram in a simple single 12-character display, which is useful for logging cumulative execution costs of components in long-running performance-sensitive applications. See the example [**examples/doomgram.md**](./examples/doomgram.md); +* `DoomGram` - a **D**ecimal **O**rder-**O**f-**M**agnitude histo**G**ram structure that records efficiently duration values in the orders of magnitude 1ns+, 10ns+, 100ns+, 1µs+, ..., 10s+, 100s+ and provides a mechanism for displaying this histogram in a simple single 12-character display (`to_strip()`), plus compact min/mean/max duration summaries (`to_mmm()` and `to_nmmm()`), which is useful for logging cumulative execution costs of components in long-running performance-sensitive applications. See the example [**examples/doomgram.md**](./examples/doomgram.md); * `NanosecondsStr` - compact storage for a formatted duration string; returned by `nanoseconds_to_string()`; typical outputs fit in 15 inline UTF-8 bytes without heap allocation; implements `Display`, `Deref` to `str`, and `AsRef`; * `Ellipsis` - provides the string `"..."` to be used for fields whose `Debug` forms are not to be expressed in terse (non-`#alternate()`) output. See the example [**examples/ellipsis.md**](./examples/ellipsis.md); * `Password` - provides strings such as `"********"` to be used for fields that are sensitive and whose `Debug` forms are not to be expressed. See the example [**examples/password.md**](./examples/password.md); @@ -135,16 +135,19 @@ Examples are provided in the ```examples``` directory, along with a markdown des ### Example - `DoomGram` -The example program **doomgram** (in **examples** directory, built with feature `test-doomgram`), illustrates use of `DoomGram` to capture the order-of-magnitude histogram of a large number of small random delays. The program source is: +The example program **doomgram** (in **examples** directory, built with feature `test-doomgram`), illustrates use of `DoomGram` to capture the order-of-magnitude histogram of a large number of small random delays, and to format min/mean/max duration summaries. The program source is: ```Rust // examples/doomgram.rs : example program illustrating use of `DoomGram` -use diagnosticism::DoomGram; +use diagnosticism::{ + doom_scope, + DoomGram, +}; use rand::{ rngs::StdRng, - RngCore, + Rng, SeedableRng, }; @@ -173,19 +176,15 @@ fn main() { } } - let before = Instant::now(); - - if 0 != i % 2000 { - thread::sleep(Duration::from_nanos(v as u64)); - } else { - // no wait, so should be very low ns - - thread::sleep(Duration::from_secs(0)); - } - - let after = Instant::now(); + doom_scope(&mut dg, || { + if 0 != i % 2000 { + thread::sleep(Duration::from_nanos(v as u64)); + } else { + // no wait, so should be very low ns - dg.push_event_duration(after - before); + thread::sleep(Duration::from_secs(0)); + } + }); } // output results on second run through @@ -195,7 +194,19 @@ fn main() { let after = Instant::now(); eprintln!("`#to_strip()` : {strip} (in {:?})", after - before); - eprintln!(""); + + let before = Instant::now(); + let mmm = dg.to_mmm(); + let after = Instant::now(); + + eprintln!("`#to_mmm()` : {mmm} (in {:?})", after - before); + + let before = Instant::now(); + let nmmm = dg.to_nmmm(); + let after = Instant::now(); + + eprintln!("`#to_nmmm()` : {nmmm} (in {:?})", after - before); + eprintln!(); eprintln!("dg={dg:#?}"); } @@ -208,6 +219,8 @@ and a typical output is: ```plaintext `#to_strip()` : _aabdedba___ (in 1.763µs) +`#to_mmm()` : 59ns-644µs-197.3ms (in 245ns) +`#to_nmmm()` : 20000:59ns-644µs-197.3ms (in 312ns) dg=DoomGram { event_count: 20000, @@ -234,14 +247,14 @@ dg=DoomGram { } ``` -showing the exploded `Debug` form of the `DoomGram` instance and its timing strip that, for particular execution, obtains the value `"_aabdedba___"` that indicates that there have been: +showing the exploded `Debug` form of the `DoomGram` instance, its timing strip, and compact min/mean/max summaries. For this execution, the strip obtains the value `"_aabdedba___"`, which indicates that there have been: - 0 events in the 1ns+, 1s+, 10s+, 100s+ magnitudes; - 1-9 events in 10ns+, 100ns+, 100ms+ magnitudes; - 10-99 events in 1µs+, 10ms+ magnitudes; - 1000-9999 events in 10µs+, 10ms+ magnitudes; - 10000-99999 events in the 100µs+ magnitude; -Naturally, in a live system one would not be employing the exploded `Debug` view, relying only on the terse and efficient timing strip format. +Naturally, in a live system one would not be employing the exploded `Debug` view, relying instead on the terse and efficient `to_strip()`, `to_mmm()`, and `to_nmmm()` formats. ### Example - `Ellipsis` diff --git a/benches/doomgram.rs b/benches/doomgram.rs index 54dd5a5..32b17d1 100644 --- a/benches/doomgram.rs +++ b/benches/doomgram.rs @@ -13,15 +13,104 @@ use criterion::{ }; -#[rustfmt::skip] -mod constants { +fn doomgram_empty() -> DoomGram { + DoomGram::default() +} + + +fn doomgram_single() -> DoomGram { + let mut dg = DoomGram::default(); + + dg.push_event_time_ms(13); + + dg +} + + +fn doomgram_uniform() -> DoomGram { + let mut dg = DoomGram::default(); + + dg.push_event_time_s(1); + dg.push_event_time_s(1); + dg.push_event_time_s(1); + + dg +} + + +fn doomgram_min_mean_max() -> DoomGram { + let mut dg = DoomGram::default(); + + dg.push_event_time_s(1); + dg.push_event_time_s(2); + + dg +} + + +fn doomgram_uniform_spread() -> DoomGram { + let mut dg = DoomGram::default(); + + dg.push_event_time_ns(9); + dg.push_event_time_ns(80); + dg.push_event_time_ns(700); + dg.push_event_time_us(6); + dg.push_event_time_us(50); + dg.push_event_time_us(400); + dg.push_event_time_ms(3); + dg.push_event_time_ms(20); + dg.push_event_time_ms(100); + dg.push_event_time_s(9); + dg.push_event_time_s(80); + dg.push_event_time_s(700); + + dg +} + +fn doomgram_overflowed() -> DoomGram { + let mut dg = DoomGram::default(); + + dg.push_event_time_us(18446744073709550); + dg.push_event_time_us(1); + dg.push_event_time_us(0); + dg.push_event_time_us(1); + + dg } -#[rustfmt::skip] -mod implementation { +fn bench_to_mmm( + c : &mut Criterion, + dg : DoomGram, + label : &str, +) { + let id = format!("`DoomGram::to_mmm()` [{label}]"); + c.bench_function(&id, |b| { + b.iter(|| { + let s = black_box(black_box(&dg).to_mmm()); + + black_box(s) + }) + }); +} + + +fn bench_to_nmmm( + c : &mut Criterion, + dg : DoomGram, + label : &str, +) { + let id = format!("`DoomGram::to_nmmm()` [{label}]"); + + c.bench_function(&id, |b| { + b.iter(|| { + let s = black_box(black_box(&dg).to_nmmm()); + + black_box(s) + }) + }); } @@ -86,6 +175,26 @@ pub fn BENCHMARK_DoomGram_push_s(c : &mut Criterion) { } +pub fn BENCHMARK_DoomGram_to_mmm(c : &mut Criterion) { + bench_to_mmm(c, doomgram_empty(), "empty"); + bench_to_mmm(c, doomgram_single(), "single"); + bench_to_mmm(c, doomgram_uniform(), "uniform"); + bench_to_mmm(c, doomgram_min_mean_max(), "min-mean-max"); + bench_to_mmm(c, doomgram_uniform_spread(), "uniform spread"); + bench_to_mmm(c, doomgram_overflowed(), "overflow"); +} + + +pub fn BENCHMARK_DoomGram_to_nmmm(c : &mut Criterion) { + bench_to_nmmm(c, doomgram_empty(), "empty"); + bench_to_nmmm(c, doomgram_single(), "single"); + bench_to_nmmm(c, doomgram_uniform(), "uniform"); + bench_to_nmmm(c, doomgram_min_mean_max(), "min-mean-max"); + bench_to_nmmm(c, doomgram_uniform_spread(), "uniform spread"); + bench_to_nmmm(c, doomgram_overflowed(), "overflow"); +} + + criterion_group!( benches, // construction @@ -96,5 +205,8 @@ criterion_group!( BENCHMARK_DoomGram_push_us, BENCHMARK_DoomGram_push_ms, BENCHMARK_DoomGram_push_s, + // formatting + BENCHMARK_DoomGram_to_mmm, + BENCHMARK_DoomGram_to_nmmm, ); criterion_main!(benches); diff --git a/examples/doomgram.md b/examples/doomgram.md index a19d462..0bdd824 100644 --- a/examples/doomgram.md +++ b/examples/doomgram.md @@ -2,7 +2,7 @@ ## Summary -An example using **Diagnosticism.Rust**'s `DoomGram` type to represent the performance of some time-consuming operations. +An example using **Diagnosticism.Rust**'s `DoomGram` type to represent the performance of some time-consuming operations, including the 12-character histogram strip ([`to_strip()`](https://docs.rs/diagnosticism/latest/diagnosticism/struct.DoomGram.html#method.to_strip)) and compact min/mean/max summaries ([`to_mmm()`](https://docs.rs/diagnosticism/latest/diagnosticism/struct.DoomGram.html#method.to_mmm), [`to_nmmm()`](https://docs.rs/diagnosticism/latest/diagnosticism/struct.DoomGram.html#method.to_nmmm)). ## Source @@ -10,11 +10,14 @@ An example using **Diagnosticism.Rust**'s `DoomGram` type to represent the perfo ```Rust // examples/doomgram.rs : example program illustrating use of `DoomGram` -use diagnosticism::DoomGram; +use diagnosticism::{ + doom_scope, + DoomGram, +}; use rand::{ rngs::StdRng, - RngCore, + Rng, SeedableRng, }; @@ -43,19 +46,15 @@ fn main() { } } - let before = Instant::now(); - - if 0 != i % 2000 { - thread::sleep(Duration::from_nanos(v as u64)); - } else { - // no wait, so should be very low ns - - thread::sleep(Duration::from_secs(0)); - } - - let after = Instant::now(); + doom_scope(&mut dg, || { + if 0 != i % 2000 { + thread::sleep(Duration::from_nanos(v as u64)); + } else { + // no wait, so should be very low ns - dg.push_event_duration(after - before); + thread::sleep(Duration::from_secs(0)); + } + }); } // output results on second run through @@ -65,7 +64,19 @@ fn main() { let after = Instant::now(); eprintln!("`#to_strip()` : {strip} (in {:?})", after - before); - eprintln!(""); + + let before = Instant::now(); + let mmm = dg.to_mmm(); + let after = Instant::now(); + + eprintln!("`#to_mmm()` : {mmm} (in {:?})", after - before); + + let before = Instant::now(); + let nmmm = dg.to_nmmm(); + let after = Instant::now(); + + eprintln!("`#to_nmmm()` : {nmmm} (in {:?})", after - before); + eprintln!(); eprintln!("dg={dg:#?}"); } @@ -87,6 +98,8 @@ it gives the output: ``` `#to_strip()` : _aacdeda____ (in 18.765µs) +`#to_mmm()` : 66ns-593.2µs-13.55ms (in 245ns) +`#to_nmmm()` : 20000:66ns-593.2µs-13.55ms (in 312ns) dg=DoomGram { event_count: 20000, @@ -114,5 +127,12 @@ dg=DoomGram { ``` +The three formatted lines use complementary views of the same data: + +* **`to_strip()`** — 12-character order-of-magnitude histogram (each position counts events in a decade band); +* **`to_mmm()`** — min, mean, and max event durations as compact strings via [`nanoseconds_to_string()`](https://docs.rs/diagnosticism/latest/diagnosticism/fn.nanoseconds_to_string.html); +* **`to_nmmm()`** — the same min/mean/max summary prefixed with the event count (`"{count}:{min}-{mean}-{max}"`). + + diff --git a/examples/doomgram.rs b/examples/doomgram.rs index 856e6a7..cd28a5c 100644 --- a/examples/doomgram.rs +++ b/examples/doomgram.rs @@ -7,7 +7,7 @@ use diagnosticism::{ use rand::{ rngs::StdRng, - RngCore, + Rng, SeedableRng, }; @@ -54,6 +54,18 @@ fn main() { let after = Instant::now(); eprintln!("`#to_strip()` : {strip} (in {:?})", after - before); + + let before = Instant::now(); + let mmm = dg.to_mmm(); + let after = Instant::now(); + + eprintln!("`#to_mmm()` : {mmm} (in {:?})", after - before); + + let before = Instant::now(); + let nmmm = dg.to_nmmm(); + let after = Instant::now(); + + eprintln!("`#to_nmmm()` : {nmmm} (in {:?})", after - before); eprintln!(); eprintln!("dg={dg:#?}"); } diff --git a/src/diagnostics/doomgram.rs b/src/diagnostics/doomgram.rs index e122885..707e0f1 100644 --- a/src/diagnostics/doomgram.rs +++ b/src/diagnostics/doomgram.rs @@ -314,6 +314,29 @@ impl DoomGram { self.num_events_ge_100s } + /// Returns min, mean, and max event times as a compact duration string. + /// + /// Each duration is formatted by [`crate::nanoseconds_to_string`]. When + /// [`Self::event_count()`] is zero, returns an empty string. When + /// [`Self::has_overflowed()`] is true, returns `"OVERFLOW"`. When there + /// is one event, or min and max are equal, returns a single formatted + /// duration; otherwise returns `min-mean-max` separated by `-`. + /// + /// Mean is [`Self::event_time_total_raw()`] divided by + /// [`Self::event_count()`]. + pub fn to_mmm(&self) -> String { + self.to_mmm_impl_() + } + + /// Like [`Self::to_mmm()`], prefixed with the event count and `:`. + /// + /// When [`Self::event_count()`] is zero, returns `"0:"`. When + /// [`Self::has_overflowed()`] is true, returns + /// `":OVERFLOW"`. + pub fn to_nmmm(&self) -> String { + self.to_nmmm_impl_() + } + /// Returns a fixed 12-character ASCII strip for the histogram. /// /// Each position encodes the order-of-magnitude of the event count in @@ -437,6 +460,78 @@ impl DoomGram { } } + fn to_mmm_impl_( + &self, + ) -> String { + use super::time_format::nanoseconds_to_string; + + const OVERFLOW : &str = "OVERFLOW"; + + let count = self.event_count(); + + if 0 == count { + return String::new(); + } + + if self.has_overflowed() { + return OVERFLOW.into(); + } + + let min_ns = self.min_event_time().unwrap() as i64; + let max_ns = self.max_event_time().unwrap() as i64; + + let body = if 1 == count || min_ns == max_ns { + format!("{}", nanoseconds_to_string(min_ns, "")) + } else { + let mean_ns = (self.event_time_total_raw() / count as u64) as i64; + + format!( + "{}-{}-{}", + nanoseconds_to_string(min_ns, ""), + nanoseconds_to_string(mean_ns, ""), + nanoseconds_to_string(max_ns, ""), + ) + }; + + body + } + + fn to_nmmm_impl_( + &self, + ) -> String { + use super::time_format::nanoseconds_to_string; + + const OVERFLOW : &str = "OVERFLOW"; + + let count = self.event_count(); + + if 0 == count { + return "0:".into(); + } + + if self.has_overflowed() { + return format!("{count}:{OVERFLOW}"); + } + + let min_ns = self.min_event_time().unwrap() as i64; + let max_ns = self.max_event_time().unwrap() as i64; + + let body = if 1 == count || min_ns == max_ns { + format!("{count}:{}", nanoseconds_to_string(min_ns, "")) + } else { + let mean_ns = (self.event_time_total_raw() / count as u64) as i64; + + format!( + "{count}:{}-{}-{}", + nanoseconds_to_string(min_ns, ""), + nanoseconds_to_string(mean_ns, ""), + nanoseconds_to_string(max_ns, ""), + ) + }; + + body + } + fn try_add_ns_to_total_and_update_minmax_and_count_( &mut self, time_in_ns : u64, @@ -890,6 +985,82 @@ mod tests { assert_eq!("_a_aa___aa_a", dg.to_strip()); } + + #[test] + fn TEST_DoomGram_to_mmm_EMPTY() { + let dg = DoomGram::default(); + + assert_eq!("", dg.to_mmm()); + assert_eq!("0:", dg.to_nmmm()); + } + + + #[test] + fn TEST_DoomGram_to_mmm_SINGLE() { + let mut dg = DoomGram::default(); + + dg.push_event_time_ms(13); + + assert_eq!("13ms", dg.to_mmm()); + assert_eq!("1:13ms", dg.to_nmmm()); + } + + + #[test] + fn TEST_DoomGram_to_mmm_UNIFORM() { + let mut dg = DoomGram::default(); + + dg.push_event_time_s(1); + dg.push_event_time_s(1); + dg.push_event_time_s(1); + + assert_eq!("1s", dg.to_mmm()); + assert_eq!("3:1s", dg.to_nmmm()); + } + + + #[test] + fn TEST_DoomGram_to_mmm_MIN_MEAN_MAX() { + let mut dg = DoomGram::default(); + + dg.push_event_time_s(1); + dg.push_event_time_s(2); + + assert_eq!("1s-1.500s-2s", dg.to_mmm()); + assert_eq!("2:1s-1.500s-2s", dg.to_nmmm()); + } + + + #[test] + fn TEST_DoomGram_to_mmm_ZERO_EVENTS_ALL_SAME() { + let mut dg = DoomGram::default(); + + dg.push_event_time_ns(0); + dg.push_event_time_us(0); + + assert_eq!("0s", dg.to_mmm()); + assert_eq!("2:0s", dg.to_nmmm()); + } + + + #[test] + fn TEST_DoomGram_to_mmm_OVERFLOW() { + let mut dg = DoomGram::default(); + + dg.push_event_time_us(18446744073709550); + dg.push_event_time_us(1); + dg.push_event_time_us(0); + + assert!(!dg.push_event_time_us(1)); + + assert!(dg.has_overflowed()); + assert_eq!(3, dg.event_count()); + + assert_eq!("OVERFLOW", dg.to_mmm()); + assert_eq!("3:OVERFLOW", dg.to_nmmm()); + } + + #[test] fn TEST_DoomGram_OVERFLOW_BY_SECONDS() { diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index beda3b5..47bd25b 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -7,7 +7,11 @@ declare_and_publish!(doomgram, DoomGram, doom_scope); declare_and_publish!(ellipsis, Ellipsis); mod flf; declare_and_publish!(password, Password); -declare_and_publish!(time_format, NanosecondsStr, nanoseconds_to_string); +declare_and_publish!(pub + time_format, + NanosecondsStr, + nanoseconds_to_string, +); // ///////////////////////////// end of file //////////////////////////// // diff --git a/src/lib.rs b/src/lib.rs index 8602aae..a6d8154 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,7 +42,8 @@ //! * [`DebugSqueezer`] — restrict the length of //! [`Debug`](std::fmt::Debug) output for individual fields; //! * [`DoomGram`] — decimal order-of-magnitude histogram with a compact -//! 12-character strip for logging; +//! 12-character strip for logging, plus [`DoomGram::to_mmm`] and +//! [`DoomGram::to_nmmm`] min/mean/max duration summaries; //! * [`Ellipsis`] — emit `"..."` for redacted //! [`Debug`](std::fmt::Debug) fields; //! * [`Password`] — emit a run of `*` characters for sensitive From 385b60c7ce5ad7932535290a6ca32b2fbccba3cc Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 28 Jun 2026 22:35:05 +1000 Subject: [PATCH 7/9] feature: hardening CI --- .github/workflows/ci.yml | 17 +++++++++++++---- scripts/check_derives.py | 0 2 files changed, 13 insertions(+), 4 deletions(-) mode change 100644 => 100755 scripts/check_derives.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3582a88..3282e31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,16 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: cargo test - run: cargo test + run: cargo test --locked - name: cargo clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --all-targets --locked -- -D warnings + + - name: cargo build (test-doomgram example) + run: cargo build --examples --features test-doomgram --locked + + - name: cargo doc + run: cargo doc --no-deps --locked - name: rustfmt run: ./scripts/fmt --check @@ -41,6 +47,9 @@ jobs: - name: RUST_TEST_NAMING checker run: python3 scripts/check_test_names.py + - name: DERIVE_LAYOUT checker + run: python3 scripts/check_derives.py + msrv: name: MSRV (1.74) runs-on: ubuntu-latest @@ -51,5 +60,5 @@ jobs: - uses: Swatinem/rust-cache@v2 - - name: cargo check (library) - run: cargo check --lib --locked + - name: cargo test + run: cargo test --locked diff --git a/scripts/check_derives.py b/scripts/check_derives.py old mode 100644 new mode 100755 From 533f1f7b7fad60c9c46480815bb01dc78691ffd1 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 28 Jun 2026 22:38:06 +1000 Subject: [PATCH 8/9] fix --- .github/workflows/ci.yml | 6 ++++-- src/diagnostics/time_format/nanoseconds_str.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3282e31..f16c5d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - - name: cargo test - run: cargo test --locked + - name: cargo check (library) + # Full `cargo test` needs dev-deps (criterion → clap_lex 2024 edition), + # which exceeds MSRV 1.74; the stable job runs the full test suite. + run: cargo check --lib --locked diff --git a/src/diagnostics/time_format/nanoseconds_str.rs b/src/diagnostics/time_format/nanoseconds_str.rs index 333b72d..4b817c3 100644 --- a/src/diagnostics/time_format/nanoseconds_str.rs +++ b/src/diagnostics/time_format/nanoseconds_str.rs @@ -12,7 +12,7 @@ const INLINE_CAP : usize = 15; /// Compact storage for a formatted nanosecond duration string. /// -/// Obtain values from [`nanoseconds_to_string`]. Most outputs fit in +/// Obtain values from [`crate::nanoseconds_to_string`]. Most outputs fit in /// [`INLINE_CAP`] UTF-8 bytes and are stored inline without heap /// allocation. Longer results use a [`String`] variant. #[derive(Clone)] From 193b6abc5f46de95f3f9e6fea337e9032449774c Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 28 Jun 2026 22:44:07 +1000 Subject: [PATCH 9/9] consistency --- src/diagnostics/time_format/mod.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/diagnostics/time_format/mod.rs b/src/diagnostics/time_format/mod.rs index db5d713..82cdb64 100644 --- a/src/diagnostics/time_format/mod.rs +++ b/src/diagnostics/time_format/mod.rs @@ -3,11 +3,10 @@ // NOTE: this work was brought in from **asynkio** via **Diagnosticism.Python** // 0.16.0 -mod format; -mod nanoseconds_str; +use crate::macros::declare_and_publish; -pub use format::nanoseconds_to_string; -pub use nanoseconds_str::NanosecondsStr; +declare_and_publish!(format, nanoseconds_to_string); +declare_and_publish!(nanoseconds_str, NanosecondsStr); // ///////////////////////////// end of file //////////////////////////// //