From e2886d30bb61eeb1f6bab5ff5c585802ec43fec9 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sat, 27 Jun 2026 15:00:29 +1000 Subject: [PATCH 1/3] 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/3] 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/3] 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"),