Merge pull request #91 from Lochnair/refactor/cli-scalar-formatting

refactor(cli): unify scalar presentation formatting
This commit was merged in pull request #91.
This commit is contained in:
Nils Andreas Svee authored and GitHub committed 2026-08-15 09:49:50 +07:00
commit 61fdff5c98
5 files changed
+257 -148

No files matched your search

+2 -29
View File
@@ -10,6 +10,7 @@ use irtt_client::{
};
use super::args::{HeaderMode, OutputFormat};
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
#[derive(Debug, Clone)]
pub(super) struct OutputConfig {
@@ -817,7 +818,7 @@ fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext<
}
fn format_table_cell(column: &Column, value: Option<String>) -> String {
let value = value.unwrap_or_else(|| "-".to_owned());
let value = value.unwrap_or_else(|| ABSENT.to_owned());
let width = column.table_width();
if column.align_right() {
format!("{value:>width$}")
@@ -1161,34 +1162,6 @@ fn wall_time_ns(wall: SystemTime) -> Option<u128> {
.map(|duration| duration.as_nanos())
}
fn format_duration(duration: Duration) -> String {
format_ns(duration.as_nanos() as f64)
}
fn format_signed_duration(duration: SignedDuration) -> String {
format_signed_ns(duration.as_nanos() as f64)
}
fn format_signed_ns(ns: f64) -> String {
if ns < 0.0 {
format!("-{}", format_ns(-ns))
} else {
format_ns(ns)
}
}
fn format_ns(ns: f64) -> String {
if ns < 1_000.0 {
format!("{ns:.0}ns")
} else if ns < 1_000_000.0 {
format!("{:.1}µs", ns / 1_000.0)
} else if ns < 1_000_000_000.0 {
format!("{:.1}ms", ns / 1_000_000.0)
} else {
format!("{:.3}s", ns / 1_000_000_000.0)
}
}
fn warning_kind(kind: WarningKind) -> &'static str {
match kind {
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
+5 -28
View File
@@ -2,6 +2,11 @@ use std::fmt::Write as _;
use irtt_stats::{Snapshot, TimeStats};
use crate::cmd::format::{
format_ns_f64, format_optional_ns_f64 as format_ns_f64_opt,
format_optional_ns_i128 as format_ns_i128, format_percent,
};
pub fn format_summary(summary: &Snapshot) -> String {
format_summary_with_options(summary, SummaryFormatOptions::default())
}
@@ -117,34 +122,6 @@ fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
.unwrap();
}
fn format_percent(value: f64) -> String {
format!("{value:.2}%")
}
fn format_ns_i128(value: Option<i128>) -> String {
value
.map(|value| format_ns_f64(value as f64))
.unwrap_or_else(|| "-".to_owned())
}
fn format_ns_f64_opt(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(|| "-".to_owned())
}
fn format_ns_f64(value: f64) -> String {
let sign = if value.is_sign_negative() { "-" } else { "" };
let value = value.abs();
if value < 1_000.0 {
format!("{sign}{value:.0}ns")
} else if value < 1_000_000.0 {
format!("{sign}{:.1}µs", value / 1_000.0)
} else if value < 1_000_000_000.0 {
format!("{sign}{:.1}ms", value / 1_000_000.0)
} else {
format!("{sign}{:.3}s", value / 1_000_000_000.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
+181
View File
@@ -0,0 +1,181 @@
//! Scalar presentation vocabulary shared by the CLI frontends.
//!
//! Stream output, the run summary, and the TUI display different metrics in
//! different layouts, but they render the same *scalars*: nanosecond-scale
//! durations, percentages, and counts. This module owns those spellings so the
//! frontends cannot drift apart on units, rounding, or how an absent value is
//! rendered.
//!
//! It is presentation only. Nothing here belongs in `irtt-stats`, and it is
//! deliberately not a general formatting framework: it holds exactly the
//! vocabulary more than one frontend needs.
use std::time::Duration;
use irtt_client::SignedDuration;
/// Rendering used wherever a scalar is unavailable.
pub(crate) const ABSENT: &str = "-";
/// Format a nanosecond scalar.
///
/// Unit selection and rounding are one policy for every frontend:
///
/// | magnitude | unit | precision |
/// |-----------|------|-----------|
/// | `< 1µs` | `ns` | whole |
/// | `< 1ms` | `µs` | 1 decimal |
/// | `< 1s` | `ms` | 1 decimal |
/// | `>= 1s` | `s` | 3 decimals |
///
/// Negative values are meaningful (clock offsets, one-way deltas, IPDV), so
/// the unit is selected from the magnitude and the sign is preserved. Values
/// that are not finite render as [`ABSENT`].
pub(crate) fn format_ns_f64(value: f64) -> String {
if !value.is_finite() {
return ABSENT.to_owned();
}
let sign = if value.is_sign_negative() { "-" } else { "" };
let magnitude = value.abs();
if magnitude < 1_000.0 {
format!("{sign}{magnitude:.0}ns")
} else if magnitude < 1_000_000.0 {
format!("{sign}{:.1}µs", magnitude / 1_000.0)
} else if magnitude < 1_000_000_000.0 {
format!("{sign}{:.1}ms", magnitude / 1_000_000.0)
} else {
format!("{sign}{:.3}s", magnitude / 1_000_000_000.0)
}
}
/// Format an exact nanosecond scalar with [`format_ns_f64`]'s policy.
pub(crate) fn format_ns_i128(value: i128) -> String {
let sign = if value < 0 { "-" } else { "" };
let magnitude = value.saturating_abs() as f64;
format!("{sign}{}", format_ns_f64(magnitude))
}
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
value.map(format_ns_i128).unwrap_or_else(absent)
}
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(absent)
}
/// Format a duration as a nanosecond scalar.
pub(crate) fn format_duration(value: Duration) -> String {
format_ns_i128(i128::try_from(value.as_nanos()).unwrap_or(i128::MAX))
}
/// Format a signed duration as a nanosecond scalar, preserving its sign.
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
format_ns_i128(value.as_nanos())
}
/// Format a percentage with two decimals; non-finite values render as
/// [`ABSENT`].
pub(crate) fn format_percent(value: f64) -> String {
if value.is_finite() {
format!("{value:.2}%")
} else {
ABSENT.to_owned()
}
}
/// Format `value` as a percentage of `total`, capped at 100%.
///
/// A zero total has no percentage to report and renders as [`ABSENT`].
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
if total == 0 {
ABSENT.to_owned()
} else {
format_percent((value as f64 / total as f64 * 100.0).min(100.0))
}
}
/// Format a scalar count.
pub(crate) fn format_count(value: u64) -> String {
value.to_string()
}
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
value.map(format_count).unwrap_or_else(absent)
}
fn absent() -> String {
ABSENT.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
assert_eq!(format_ns_f64(0.0), "0ns");
assert_eq!(format_ns_f64(999.0), "999ns");
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
}
#[test]
fn negative_nanosecond_scalars_keep_their_sign() {
assert_eq!(format_ns_f64(-500.0), "-500ns");
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
}
#[test]
fn exact_and_floating_nanosecond_scalars_agree() {
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
}
}
#[test]
fn microseconds_use_the_micro_sign() {
assert!(format_ns_f64(1_500.0).ends_with("µs"));
assert!(!format_ns_f64(1_500.0).contains("us"));
}
#[test]
fn durations_render_as_nanosecond_scalars() {
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
assert_eq!(
format_signed_duration(SignedDuration::from_nanos(-1_500)),
"-1.5µs"
);
}
#[test]
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
assert_eq!(format_optional_ns_i128(None), ABSENT);
assert_eq!(format_optional_ns_f64(None), ABSENT);
assert_eq!(format_optional_count(None), ABSENT);
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
assert_eq!(format_percent(f64::NAN), ABSENT);
assert_eq!(format_percent_ratio(1, 0), ABSENT);
}
#[test]
fn percentages_and_counts_have_one_spelling() {
assert_eq!(format_percent(0.0), "0.00%");
assert_eq!(format_percent(12.345), "12.35%");
assert_eq!(format_percent_ratio(1, 4), "25.00%");
assert_eq!(format_percent_ratio(5, 4), "100.00%");
assert_eq!(format_count(42), "42");
assert_eq!(format_optional_count(Some(0)), "0");
}
}
+2
View File
@@ -1,5 +1,7 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(any(feature = "client", feature = "tui"))]
mod format;
#[cfg(feature = "server")]
pub mod server;
#[cfg(feature = "tui")]
+67 -91
View File
@@ -23,7 +23,13 @@ use ratatui::{
};
use crate::{
cmd::tui::args::TuiArgs,
cmd::{
format::{
format_count, format_duration, format_ns_f64, format_optional_count,
format_optional_ns_i128, format_percent, format_percent_ratio, ABSENT,
},
tui::args::TuiArgs,
},
shared::client::{expected_probe_count, GroupPacingArg},
};
@@ -330,7 +336,7 @@ impl TuiState {
recent = Some(format!(
"reply seq={} effective={}",
format_seq(*seq),
format_ns_i128(Some(rtt.effective.as_nanos()))
format_optional_ns_i128(Some(rtt.effective.as_nanos()))
));
}
ClientEvent::EchoLoss { seq, .. } => {
@@ -349,7 +355,7 @@ impl TuiState {
.map(|sample| {
format!(
" effective={}",
format_ns_i128(Some(sample.effective.as_nanos()))
format_optional_ns_i128(Some(sample.effective.as_nanos()))
)
})
.unwrap_or_default();
@@ -1149,8 +1155,8 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> {
let session = selected
.and_then(|target| target.session.as_deref())
.unwrap_or("-");
let elapsed = format_duration(state.started_at.elapsed());
let duration = format_optional_duration(state.config.duration);
let elapsed = format_span(state.started_at.elapsed());
let duration = format_optional_span(state.config.duration);
let mode = if state.config.duration.is_some() {
"finite"
} else {
@@ -1186,8 +1192,8 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> {
)),
Line::from(format!(
"interval: {} timeout: {}",
format_duration(state.config.interval),
format_duration(state.config.timeout)
format_span(state.config.interval),
format_span(state.config.timeout)
)),
Line::from(negotiated),
]);
@@ -1197,7 +1203,7 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> {
Line::from(format!("session: {session}")),
Line::from(format!(
"{mode} elapsed: {elapsed} interval: {}",
format_duration(state.config.interval)
format_span(state.config.interval)
)),
]);
}
@@ -1216,12 +1222,12 @@ fn graph_summary_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'stat
let remote = selected
.and_then(|target| target.remote.as_deref())
.unwrap_or("-");
let elapsed = format_duration(state.started_at.elapsed());
let elapsed = format_span(state.started_at.elapsed());
let packets = snapshot.packets;
let last = state
.selected_target()
.and_then(|target| target.last_sample)
.map(|sample| format_ns_i128(Some(sample.effective_ns)))
.map(|sample| format_optional_ns_i128(Some(sample.effective_ns)))
.unwrap_or_else(|| "-".to_owned());
let target_context = if state.is_multi_target() {
@@ -1295,7 +1301,7 @@ fn packet_panel(state: &TuiState, snapshot: &Snapshot, panel_height: u16) -> Par
)),
Line::from(format!(
"server received {} window {}",
format_optional_u64(packets.server_packets_received),
format_optional_count(packets.server_packets_received),
format_optional_hex(packets.server_received_window)
)),
])
@@ -1314,7 +1320,7 @@ fn target_table_panel(state: &TuiState, panel_height: u16) -> Paragraph<'static>
let snapshot = target.stats.snapshot();
let last = target
.last_sample
.map(|sample| format_ns_i128(Some(sample.effective_ns)))
.map(|sample| format_optional_ns_i128(Some(sample.effective_ns)))
.unwrap_or_else(|| "-".to_owned());
lines.push(Line::from(vec![
target_label_span(&target.label, idx, 16),
@@ -1695,7 +1701,7 @@ fn viewport_x_bounds(viewport: GraphViewportRange) -> [f64; 2] {
fn viewport_x_axis_labels(viewport: GraphViewportRange) -> Vec<Span<'static>> {
vec![
Span::raw(format!("-{}", format_duration(viewport.window))),
Span::raw(format!("-{}", format_span(viewport.window))),
Span::raw(if viewport.is_live { "live" } else { "end" }),
]
}
@@ -1704,7 +1710,7 @@ fn graph_context(state: &TuiState, viewport: GraphViewportRange) -> String {
format!(
"{} | window {}",
graph_viewport_status(state),
format_duration(viewport.window)
format_span(viewport.window)
)
}
@@ -1744,9 +1750,9 @@ fn format_axis_time_ms(value_ms: f64) -> String {
if abs_ms < 1.0 {
let us = abs_ms * 1_000.0;
if us < 10.0 {
format!("{sign}{us:.1}us")
format!("{sign}{us:.1}µs")
} else {
format!("{sign}{us:.0}us")
format!("{sign}{us:.0}µs")
}
} else if abs_ms < 1_000.0 {
if abs_ms < 10.0 {
@@ -1804,23 +1810,23 @@ fn sample_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> {
)),
Line::from(format!(
"raw / adjusted / effective: {} / {} / {}",
last.map(|sample| format_ns_i128(Some(sample.raw_ns)))
last.map(|sample| format_optional_ns_i128(Some(sample.raw_ns)))
.unwrap_or_else(|| "-".to_owned()),
last.map(|sample| format_ns_i128(sample.adjusted_ns))
last.map(|sample| format_optional_ns_i128(sample.adjusted_ns))
.unwrap_or_else(|| "-".to_owned()),
last.map(|sample| format_ns_i128(Some(sample.effective_ns)))
last.map(|sample| format_optional_ns_i128(Some(sample.effective_ns)))
.unwrap_or_else(|| "-".to_owned())
)),
Line::from(format!(
"one-way c2s / s2c: {} / {}",
last.map(|sample| format_ns_i128(sample.client_to_server_ns))
last.map(|sample| format_optional_ns_i128(sample.client_to_server_ns))
.unwrap_or_else(|| "-".to_owned()),
last.map(|sample| format_ns_i128(sample.server_to_client_ns))
last.map(|sample| format_optional_ns_i128(sample.server_to_client_ns))
.unwrap_or_else(|| "-".to_owned())
)),
Line::from(format!(
"server processing: {}",
last.map(|sample| format_ns_i128(sample.server_processing_ns))
last.map(|sample| format_optional_ns_i128(sample.server_processing_ns))
.unwrap_or_else(|| "-".to_owned())
)),
Line::from(format!(
@@ -1888,7 +1894,7 @@ fn graph_viewport_status(state: &TuiState) -> String {
let live_end = state.newest_graph_sample_time().unwrap_or(now).max(now);
format!(
"history -{}",
format_duration(live_end.saturating_duration_since(end))
format_span(live_end.saturating_duration_since(end))
)
}
}
@@ -1906,9 +1912,9 @@ fn push_time_line(lines: &mut Vec<Line<'_>>, label: &str, stats: &TimeStats) {
lines.push(Line::from(format!(
"{label:<18} {:>5} {:>9} {:>9} {:>9} {:>9}",
format_count(stats.count),
format_ns_i128(stats.min_ns),
format_optional_ns_i128(stats.min_ns),
format_ns_f64(stats.mean_ns),
format_ns_i128(stats.max_ns),
format_optional_ns_i128(stats.max_ns),
format_ns_f64(stats.stddev_ns())
)));
}
@@ -1918,7 +1924,7 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String {
let duration = if params.duration_ns == 0 {
"-".to_owned()
} else {
format_ns_i128(Some(i128::from(params.duration_ns)))
format_optional_ns_i128(Some(i128::from(params.duration_ns)))
};
let restrictions = if negotiated.restrictions.is_empty() {
"none".to_owned()
@@ -1928,7 +1934,7 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String {
format!(
"duration={} interval={} length={} clock={:?} timestamps={:?} stats={:?} restrictions={}",
duration,
format_ns_i128(Some(i128::from(params.interval_ns))),
format_optional_ns_i128(Some(i128::from(params.interval_ns))),
params.length,
params.clock,
params.stamp_at,
@@ -1937,80 +1943,43 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String {
)
}
fn format_optional_duration(value: Option<Duration>) -> String {
value.map(format_duration).unwrap_or_else(|| "-".to_owned())
fn format_optional_span(value: Option<Duration>) -> String {
value.map(format_span).unwrap_or_else(|| ABSENT.to_owned())
}
fn format_duration(value: Duration) -> String {
/// Format a wall-clock span shown in the TUI's headers, config lines, and
/// graph window labels.
///
/// Spans are read at a glance rather than compared numerically, so they are
/// deliberately coarser than the shared scalar policy: a zero span is `0s`,
/// whole seconds carry one decimal, and a span of a minute or more is written
/// as minutes and seconds. Below one second there is nothing to gain from a
/// second spelling, so the shared scalar vocabulary is used directly.
fn format_span(value: Duration) -> String {
if value.is_zero() {
return "0s".to_owned();
}
let nanos = value.as_nanos();
if nanos < 1_000 {
format!("{nanos}ns")
} else if nanos < 1_000_000 {
format!("{:.1}us", nanos as f64 / 1_000.0)
} else if nanos < 1_000_000_000 {
format!("{:.1}ms", nanos as f64 / 1_000_000.0)
} else if nanos < 60_000_000_000 {
if nanos < 1_000_000_000 {
return format_duration(value);
}
let secs = value.as_secs();
if nanos < 60_000_000_000 {
format!("{:.1}s", nanos as f64 / 1_000_000_000.0)
} else {
let secs = value.as_secs();
format!("{}m{:02}s", secs / 60, secs % 60)
}
}
fn format_ns_i128(value: Option<i128>) -> String {
value.map(format_ns_value).unwrap_or_else(|| "-".to_owned())
}
fn format_ns_f64(value: f64) -> String {
format_ns_value(value.round() as i128)
}
fn format_ns_value(value: i128) -> String {
let sign = if value < 0 { "-" } else { "" };
let value = value.saturating_abs() as f64;
if value < 1_000.0 {
format!("{sign}{value:.0}ns")
} else if value < 1_000_000.0 {
format!("{sign}{:.1}us", value / 1_000.0)
} else if value < 1_000_000_000.0 {
format!("{sign}{:.1}ms", value / 1_000_000.0)
} else {
format!("{sign}{:.3}s", value / 1_000_000_000.0)
}
}
fn format_percent(value: f64) -> String {
if value.is_finite() {
format!("{value:.2}%")
} else {
"-".to_owned()
}
}
fn format_percent_ratio(value: u64, total: u64) -> String {
if total == 0 {
"-".to_owned()
} else {
format_percent((value as f64 / total as f64 * 100.0).min(100.0))
}
}
fn format_rate(count: u64, elapsed: Duration) -> String {
let secs = elapsed.as_secs_f64();
if secs <= f64::EPSILON {
"-".to_owned()
ABSENT.to_owned()
} else {
format!("{:.2}/s", count as f64 / secs)
}
}
fn format_count(value: u64) -> String {
value.to_string()
}
fn truncate(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_owned();
@@ -2026,14 +1995,10 @@ fn format_seq(value: u32) -> String {
value.to_string()
}
fn format_optional_u64(value: Option<u64>) -> String {
value.map(format_count).unwrap_or_else(|| "-".to_owned())
}
fn format_optional_hex(value: Option<u64>) -> String {
value
.map(|value| format!("0x{value:x}"))
.unwrap_or_else(|| "-".to_owned())
.unwrap_or_else(|| ABSENT.to_owned())
}
fn duration_ns(value: Duration) -> i128 {
@@ -2177,12 +2142,23 @@ mod tests {
}
}
#[test]
fn spans_stay_coarse_above_a_second_and_share_scalar_units_below_it() {
assert_eq!(format_span(Duration::ZERO), "0s");
assert_eq!(format_span(Duration::from_nanos(750)), "750ns");
assert_eq!(format_span(Duration::from_micros(500)), "500.0µs");
assert_eq!(format_span(Duration::from_millis(25)), "25.0ms");
assert_eq!(format_span(Duration::from_millis(1_500)), "1.5s");
assert_eq!(format_span(Duration::from_secs(90)), "1m30s");
}
#[test]
fn formats_signed_durations_and_missing_values() {
assert_eq!(format_ns_i128(Some(-1_500_000)), "-1.5ms");
assert_eq!(format_ns_i128(Some(750)), "750ns");
assert_eq!(format_ns_i128(None), "-");
assert_eq!(format_optional_duration(None), "-");
assert_eq!(format_optional_ns_i128(Some(-1_500_000)), "-1.5ms");
assert_eq!(format_optional_ns_i128(Some(750)), "750ns");
assert_eq!(format_optional_ns_i128(None), "-");
assert_eq!(format_optional_ns_i128(Some(1_500)), "1.5µs");
assert_eq!(format_optional_span(None), "-");
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
assert_eq!(format_percent_ratio(1, 4), "25.00%");
assert_eq!(format_optional_hex(Some(0x1f)), "0x1f");
@@ -2748,7 +2724,7 @@ mod tests {
assert_eq!(
rendered,
vec!["-1.00ms", "-500us", "0.0us", "500us", "1.00ms"]
vec!["-1.00ms", "-500µs", "0.0µs", "500µs", "1.00ms"]
);
}