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:
1 parent
4b408d3ab4
commit
b02bc53cd8
6 files changed
+1250
-547
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -51,7 +51,8 @@ fn canonical_client_subcommand_dispatches_to_client() {
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible stream client"));
|
||||
assert!(text.contains("--output <OUTPUT>"));
|
||||
assert!(text.contains("--format <FORMAT>"));
|
||||
assert!(text.contains("--columns <COLUMNS>"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -65,7 +66,8 @@ fn client_applet_name_dispatches_to_client() {
|
||||
|
||||
assert!(output.status.success(), "{text}");
|
||||
assert!(text.contains("Minimal IRTT-compatible stream client"));
|
||||
assert!(text.contains("--output <OUTPUT>"));
|
||||
assert!(text.contains("--format <FORMAT>"));
|
||||
assert!(text.contains("--columns <COLUMNS>"));
|
||||
}
|
||||
|
||||
#[cfg(all(unix, feature = "tui"))]
|
||||
|
||||
Reference in new issue
Block a user