feat(cli): introduce flexible event row output formats and column selection

Replace fixed output modes with customizable table, CSV, TSV, and JSON Lines formats. This allows users to select specific data columns and control header visibility for better integration with scripts and monitoring tools.
This commit is contained in:
Nils Andreas Svee committed 2026-06-22 13:16:33 +07:00
1 parent 4b408d3ab4
commit b02bc53cd8
6 files changed
+1250 -547

No files matched your search

+69 -29
View File
@@ -11,7 +11,8 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address or host, with optional port.
pub server: String,
#[arg(required_unless_present = "list_columns")]
pub server: Option<String>,
#[arg(
long,
@@ -25,18 +26,35 @@ pub struct ClientArgs {
#[command(flatten)]
pub common: CommonClientArgs,
/// Output format.
#[arg(long, value_enum, default_value_t = OutputMode::Human)]
pub output: OutputMode,
/// Event row renderer format.
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
pub format: OutputFormat,
/// Include extra fields in human output.
/// Comma-separated event row columns.
#[arg(short = 'c', long, value_name = "COLUMNS")]
pub columns: Option<String>,
/// Print available event row columns and exit.
#[arg(long)]
pub list_columns: bool,
/// Header policy for table, CSV, and TSV output.
#[arg(long, value_enum, default_value_t = HeaderMode::Auto)]
pub header: HeaderMode,
/// Include extra fields in table output and final summaries.
#[arg(long)]
pub verbose: bool,
}
impl ClientArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration)
self.common.to_client_config(
self.server
.as_deref()
.expect("server is required unless --list-columns is set"),
self.duration,
)
}
pub fn is_continuous(&self) -> bool {
@@ -57,23 +75,33 @@ impl std::ops::Deref for ClientArgs {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputMode {
/// Readable terminal output with a final summary.
Human,
/// Parseable full event fields.
Machine,
/// Simple key=value-ish event stream.
Simple,
/// RTT microseconds only.
RttUs,
pub enum OutputFormat {
/// Readable terminal table output.
Table,
/// Comma-separated event rows.
Csv,
/// Tab-separated event rows.
Tsv,
/// One JSON object per event row.
Jsonl,
}
impl OutputMode {
impl OutputFormat {
pub fn prints_summary(self) -> bool {
matches!(self, Self::Human)
matches!(self, Self::Table)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HeaderMode {
/// Print headers for table, CSV, and TSV output.
Auto,
/// Always print headers where the format supports them.
Always,
/// Never print headers.
Never,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -91,10 +119,12 @@ mod tests {
fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112");
assert_eq!(args.server.as_deref(), Some("127.0.0.1:2112"));
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.output, OutputMode::Human);
assert_eq!(args.format, OutputFormat::Table);
assert_eq!(args.columns, None);
assert_eq!(args.header, HeaderMode::Auto);
assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
@@ -107,17 +137,27 @@ mod tests {
}
#[test]
fn client_parser_accepts_stream_outputs_and_rejects_tui_output() {
for output in ["human", "simple", "machine", "rtt-us"] {
assert!(parse(&["--output", output, "127.0.0.1:2112"]).is_ok());
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
for format in ["table", "csv", "tsv", "jsonl"] {
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
}
assert!(parse(&["--output", "tui", "127.0.0.1:2112"]).is_err());
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
}
#[test]
fn list_columns_does_not_require_server() {
let args = parse(&["--list-columns"]).unwrap();
assert_eq!(args.server, None);
assert!(args.list_columns);
}
#[test]
fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--output <OUTPUT>"));
assert!(help.contains("--format <FORMAT>"));
assert!(help.contains("--columns <COLUMNS>"));
assert!(help.contains("--list-columns"));
assert!(help.contains("--header <HEADER>"));
assert!(help.contains("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>"));
@@ -127,10 +167,10 @@ mod tests {
}
#[test]
fn output_mode_summary_policy_is_human_only() {
assert!(OutputMode::Human.prints_summary());
assert!(!OutputMode::Simple.prints_summary());
assert!(!OutputMode::Machine.prints_summary());
assert!(!OutputMode::RttUs.prints_summary());
fn output_format_summary_policy_is_table_only() {
assert!(OutputFormat::Table.prints_summary());
assert!(!OutputFormat::Csv.prints_summary());
assert!(!OutputFormat::Tsv.prints_summary());
assert!(!OutputFormat::Jsonl.prints_summary());
}
}
+1 -5
View File
@@ -4,9 +4,5 @@ pub mod run;
#[cfg(feature = "stats")]
pub mod summary;
pub use args::{ClientArgs, OutputMode};
pub use output::{
format_event, format_human_event, format_human_event_with_options, HumanEventStats,
HumanIpdvPair, HumanOutputOptions,
};
pub use args::{ClientArgs, HeaderMode, OutputFormat};
pub use run::run_stream;
File diff suppressed because it is too large. Load diff
+67 -54
View File
@@ -8,8 +8,8 @@ use irtt_client::ClientEvent;
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use super::{
args::{ClientArgs, OutputMode},
output::{format_event_with_context, HumanEventStats, HumanOutputOptions, RenderContext},
args::ClientArgs,
output::{EventRenderStats, OutputConfig},
};
#[cfg(feature = "stats")]
use crate::shared::client::expected_probe_count;
@@ -37,7 +37,18 @@ pub fn run_stream(
args: ClientArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output;
if args.list_columns {
print!("{}", OutputConfig::list_columns());
return Ok(());
}
let output_config = OutputConfig::new(
args.format,
args.columns.as_deref(),
args.header,
args.verbose,
)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let continuous = args.is_continuous();
#[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args) {
@@ -48,10 +59,8 @@ pub fn run_stream(
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut stream_output = StreamOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
@@ -110,8 +119,8 @@ pub fn run_stream(
}
struct StreamOutput<'a, W: Write> {
mode: OutputMode,
human_options: HumanOutputOptions,
config: OutputConfig,
header_printed: bool,
print_final_summary: bool,
show_running_only_summary_note: bool,
out: &'a mut W,
@@ -121,6 +130,7 @@ struct StreamOutput<'a, W: Write> {
impl<W: Write> StreamOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
self.print_header()?;
for event in events {
self.print_event(event)?;
}
@@ -132,30 +142,32 @@ impl<W: Write> StreamOutput<'_, W> {
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = HumanEventStats::default();
let stats_update = EventRenderStats::default();
#[cfg(feature = "stats")]
let human_stats = HumanEventStats::from(stats_update);
let event_stats = EventRenderStats::from(stats_update);
#[cfg(not(feature = "stats"))]
let human_stats = stats_update;
let event_stats = stats_update;
let line = format_event_with_context(
event,
RenderContext {
mode: self.mode,
human_stats: Some(&human_stats),
human_options: self.human_options,
},
);
if let Some(line) = line {
if let Some(line) = self.config.render_event(event, Some(&event_stats)) {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_header(&mut self) -> io::Result<()> {
if self.header_printed {
return Ok(());
}
self.header_printed = true;
if let Some(header) = self.config.render_header() {
writeln!(self.out, "{header}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> {
if !self.print_final_summary || !self.mode.prints_summary() {
if !self.print_final_summary || !self.config.prints_summary() {
return Ok(());
}
@@ -167,7 +179,7 @@ impl<W: Write> StreamOutput<'_, W> {
crate::cmd::client::summary::format_summary_with_options(
&self.stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.human_options.verbose,
verbose: self.config.summary_verbose(),
show_running_only_note: self.show_running_only_summary_note,
},
)
@@ -276,6 +288,15 @@ mod tests {
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
}
fn output_config(
format: crate::cmd::client::OutputFormat,
columns: Option<&str>,
header: crate::cmd::client::HeaderMode,
verbose: bool,
) -> OutputConfig {
OutputConfig::new(format, columns, header, verbose).unwrap()
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
@@ -312,8 +333,13 @@ mod tests {
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
config: output_config(
crate::cmd::client::OutputFormat::Tsv,
Some("effective_rtt_us"),
crate::cmd::client::HeaderMode::Never,
false,
),
header_printed: false,
print_final_summary: true,
show_running_only_summary_note: false,
out: &mut out,
@@ -325,7 +351,7 @@ mod tests {
let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot();
assert_eq!(rendered, "1200\n");
assert!(!rendered.is_empty());
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
@@ -336,8 +362,13 @@ mod tests {
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
config: output_config(
crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: true,
show_running_only_summary_note: true,
out: &mut out,
@@ -358,8 +389,13 @@ mod tests {
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
config: output_config(
crate::cmd::client::OutputFormat::Table,
None,
crate::cmd::client::HeaderMode::Auto,
false,
),
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: true,
out: &mut out,
@@ -371,29 +407,6 @@ mod tests {
assert!(out.is_empty());
}
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));