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 36df5e4..f887c03 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,11 @@ # Diagnosticism.Rust - CHANGES +## 0.3.2 - 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; diff --git a/Cargo.lock b/Cargo.lock index 36586bf..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" @@ -206,8 +212,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "diagnosticism" -version = "0.3.1" +version = "0.3.2" dependencies = [ + "base-traits", "criterion", "rand", ] diff --git a/Cargo.toml b/Cargo.toml index b479698..1ddb285 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.1" +version = "0.3.2" # ########################################################## @@ -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/README.md b/README.md index 98c255f..0d6e7ea 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,17 @@ 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; + +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, "+")); +``` ### Macros @@ -99,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 1d9f5c8..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, nanoseconds_to_string); +declare_and_publish!(pub + 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..500c41d --- /dev/null +++ b/src/diagnostics/time_format/nanoseconds_str.rs @@ -0,0 +1,366 @@ +// src/diagnostics/time_format/nanoseconds_str.rs : `NanosecondsStr` + +use base_traits::AsStr; + +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 AsStr for NanosecondsStr { + fn as_str(&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 edf5b99..8602aae 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) //! @@ -99,6 +100,7 @@ pub use diagnostics::{ DebugSqueezer, DoomGram, Ellipsis, + NanosecondsStr, Password, };