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 9d162e46e0fa477f37c40b821036f5af75483958 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 28 Jun 2026 21:20:41 +1000 Subject: [PATCH 6/9] chore: internal implementation improvements --- CHANGES.md | 5 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/diagnostics/mod.rs | 16 +++--- src/lib.rs | 1 + src/macros.rs | 108 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 src/macros.rs diff --git a/CHANGES.md b/CHANGES.md index dfb61cd..a6c5309 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,11 @@ # Diagnosticism.Rust - CHANGES +## 0.3.1 - 28th June 2026 + +* internal implementation improvements; + + ## 0.3.0 - 27th June 2026 * 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; diff --git a/Cargo.lock b/Cargo.lock index 18899c5..36586bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,7 +206,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "diagnosticism" -version = "0.3.0" +version = "0.3.1" dependencies = [ "criterion", "rand", diff --git a/Cargo.toml b/Cargo.toml index f458600..b479698 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.0" +version = "0.3.1" # ########################################################## diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 3aaeb61..1f44106 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -1,21 +1,17 @@ // diagnostics/mod.rs -macro_rules! declare_and_publish { - ($mod_name:ident, $($type_name:ident),* $(,)?) => { - mod $mod_name; - - pub use $mod_name::{ - $($type_name),* - }; - }; -} +use crate::macros::declare_and_publish; declare_and_publish!(debug_squeezer, DebugSqueezer); 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!( + time_format, + NanosecondsStr, + nanoseconds_to_string, +); // ///////////////////////////// end of file //////////////////////////// // diff --git a/src/lib.rs b/src/lib.rs index 6b40f11..8602aae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,6 +92,7 @@ // lib.rs pub mod diagnostics; +pub(crate) mod macros; pub use diagnostics::{ doom_scope, diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..24dfa9e --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,108 @@ +macro_rules! declare_and_publish_impl_ { + ($mod_name:ident; $($construct_name:ident),*; $vis:vis) => { + mod $mod_name; + + $vis use $mod_name::{ $($construct_name),* }; + }; +} + +/// Declares a private submodule and re-exports selected items from it. +/// +/// Use in a **`mod.rs` barrel file** when a directory contains sibling +/// `.rs` implementation files. Each invocation replaces: +/// +/// ```rust,ignore +/// mod margin; +/// pub use margin::margin; +/// ``` +/// +/// with a single macro call. The first argument is the **module name** +/// (matching `name.rs` or `name/mod.rs`); remaining arguments are **item +/// names** (types, functions, constants) to re-export from that module. +/// +/// # Setup +/// +/// ```rust,ignore +/// use crate::macros::declare_and_publish; +/// ``` +/// +/// # Forms +/// +/// * `declare_and_publish!(mod_name, Item)` — `pub use` (default); +/// * `declare_and_publish!(pub mod_name, Item)` — `pub use` (explicit); +/// * `declare_and_publish!(crate mod_name, Item)` — `pub(crate) use`; +/// * `declare_and_publish!(super mod_name, Item)` — `pub(super) use`; +/// * `declare_and_publish!(self mod_name, Item)` — `pub(self) use`; +/// * `declare_and_publish!(priv mod_name, Item)` — `pub(self) use` (synonym); +/// * `declare_and_publish!($vis mod_name, Item)` — any Rust visibility +/// (e.g. `pub(crate)`, `pub(in crate::api)`). +/// +/// Multiple items may be re-exported from one module: +/// +/// ```rust,ignore +/// declare_and_publish!(doomgram, DoomGram, doom_scope); +/// ``` +/// +/// A parent module (including **`lib.rs`**) may aggregate a child barrel: +/// +/// ```rust,ignore +/// declare_and_publish!( +/// api, +/// evaluate_scalar_eq_approx, +/// margin, +/// ); +/// ``` +/// +/// # Examples +/// +/// Public API surface: +/// +/// ```rust,ignore +/// declare_and_publish!(password, Password); +/// declare_and_publish!( +/// time_format, +/// NanosecondsStr, +/// nanoseconds_to_string, +/// ); +/// ``` +/// +/// Crate-internal wiring: +/// +/// ```rust,ignore +/// declare_and_publish!(crate compare, compare_foo, compare_bar); +/// ``` +/// +/// Submodules that need no re-export remain ordinary private declarations: +/// +/// ```rust,ignore +/// mod flf; +/// ``` +macro_rules! declare_and_publish { + (crate $mod_name:ident $(, $construct_name:ident)* $(,)?) => { + $crate::macros::declare_and_publish_impl_!($mod_name; $($construct_name),*; pub(crate)); + }; + (pub $mod_name:ident $(, $construct_name:ident)* $(,)?) => { + $crate::macros::declare_and_publish_impl_!($mod_name; $($construct_name),*; pub); + }; + (priv $mod_name:ident $(, $construct_name:ident)* $(,)?) => { + $crate::macros::declare_and_publish_impl_!($mod_name; $($construct_name),*; pub(self)); + }; + (self $mod_name:ident $(, $construct_name:ident)* $(,)?) => { + $crate::macros::declare_and_publish_impl_!($mod_name; $($construct_name),*; pub(self)); + }; + (super $mod_name:ident $(, $construct_name:ident)* $(,)?) => { + $crate::macros::declare_and_publish_impl_!($mod_name; $($construct_name),*; pub(super)); + }; + ($mod_name:ident $(, $construct_name:ident)* $(,)?) => { + $crate::macros::declare_and_publish_impl_!($mod_name; $($construct_name),*; pub); + }; + ($vis:vis $mod_name:ident $(, $construct_name:ident)* $(,)?) => { + $crate::macros::declare_and_publish_impl_!($mod_name; $($construct_name),*; $vis); + }; +} + +pub(crate) use declare_and_publish; +pub(crate) use declare_and_publish_impl_; + + +// ///////////////////////////// end of file //////////////////////////// // From 9089aa2165df54fe8c1bfdc98761e1b8d49d0a5b Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 28 Jun 2026 21:32:26 +1000 Subject: [PATCH 7/9] squash-commit --- CHANGES.md | 7 ++++++- src/macros.rs | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a6c5309..ef1f2bf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,11 @@ # Diagnosticism.Rust - CHANGES +## 0.3.1 - 28th June 2026 + +* optimisation of `nanoseconds_to_string()` — uses a custom return type `NanosecondsStr` for highly efficient conversion in vast majority of cases; + + ## 0.3.1 - 28th June 2026 * internal implementation improvements; @@ -8,7 +13,7 @@ ## 0.3.0 - 27th June 2026 -* 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; +* added `nanoseconds_to_string()` — compact human-readable duration formatting (behaviour matches **Diagnosticism.Python** 0.16.0); ## 0.2.1 - 27th June 2026 diff --git a/src/macros.rs b/src/macros.rs index 24dfa9e..96501c1 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -33,7 +33,8 @@ macro_rules! declare_and_publish_impl_ { /// * `declare_and_publish!(crate mod_name, Item)` — `pub(crate) use`; /// * `declare_and_publish!(super mod_name, Item)` — `pub(super) use`; /// * `declare_and_publish!(self mod_name, Item)` — `pub(self) use`; -/// * `declare_and_publish!(priv mod_name, Item)` — `pub(self) use` (synonym); +/// * `declare_and_publish!(priv mod_name, Item)` — `pub(self) use` +/// (synonym); /// * `declare_and_publish!($vis mod_name, Item)` — any Rust visibility /// (e.g. `pub(crate)`, `pub(in crate::api)`). /// From d6644bbc263ea0319705f3ccd5250ca6da0a3940 Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 28 Jun 2026 21:37:43 +1000 Subject: [PATCH 8/9] fix --- src/diagnostics/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 1f44106..47bd25b 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -7,7 +7,7 @@ declare_and_publish!(doomgram, DoomGram, doom_scope); declare_and_publish!(ellipsis, Ellipsis); mod flf; declare_and_publish!(password, Password); -declare_and_publish!( +declare_and_publish!(pub time_format, NanosecondsStr, nanoseconds_to_string, From 8d7a0fba3a395f8dd3a3cedcfa3c92d3231ca02d Mon Sep 17 00:00:00 2001 From: Matt Wilson Date: Sun, 28 Jun 2026 21:41:39 +1000 Subject: [PATCH 9/9] feature: implemented `AsStr` for `NanosecondsStr` --- Cargo.lock | 7 +++++++ Cargo.toml | 2 ++ src/diagnostics/time_format/nanoseconds_str.rs | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 3a961f1..d6c08b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base-traits" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb2de59cca22f51f3301a09c549b0b4a32f56e8a007d0d44b02a1ce42975b37" + [[package]] name = "bumpalo" version = "3.20.3" @@ -208,6 +214,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" name = "diagnosticism" version = "0.3.2" dependencies = [ + "base-traits", "criterion", "rand", ] diff --git a/Cargo.toml b/Cargo.toml index df4a98c..1ddb285 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,8 @@ test-doomgram = [ [dependencies] +base-traits = { version = "0", default-features = false, features = [ +]} rand = { version = "0.10", optional = true } [dev-dependencies] diff --git a/src/diagnostics/time_format/nanoseconds_str.rs b/src/diagnostics/time_format/nanoseconds_str.rs index 333b72d..500c41d 100644 --- a/src/diagnostics/time_format/nanoseconds_str.rs +++ b/src/diagnostics/time_format/nanoseconds_str.rs @@ -1,5 +1,7 @@ // src/diagnostics/time_format/nanoseconds_str.rs : `NanosecondsStr` +use base_traits::AsStr; + use std::{ borrow::Borrow, fmt as std_fmt, @@ -99,6 +101,13 @@ impl AsRef for NanosecondsStr { } +impl AsStr for NanosecondsStr { + fn as_str(&self) -> &str { + self.as_str() + } +} + + impl Borrow for NanosecondsStr { fn borrow(&self) -> &str { self.as_str()