From a03004b22f0f58c12ccdd38eda7520e49a5508c5 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Sat, 15 Aug 2026 20:56:12 +0700 Subject: [PATCH] refactor(cli): unify target argument syntax --- README.md | 14 +- crates/irtt-cli/src/cmd/client/args.rs | 61 ++++--- crates/irtt-cli/src/cmd/tui/args.rs | 58 +++---- crates/irtt-cli/src/shared/client/mod.rs | 4 +- crates/irtt-cli/src/shared/client/prepare.rs | 77 ++++----- crates/irtt-cli/src/shared/client/targets.rs | 169 ++++++++++++++----- crates/irtt-cli/tests/client_multi_target.rs | 79 ++++++--- 7 files changed, 285 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index c082979..e0ab0ae 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,18 @@ Probe several targets concurrently: irtt-cli host-a:2112 host-b:2112 ``` -Assign stable labels to targets: +Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix assigns the logical +target name used in output and the TUI; without it, the target string itself is used. Assign +stable labels to targets: ```sh -irtt-cli \ - --target ams=ams.example.com:2112 \ - --target sg=sg.example.com:2112 +irtt-cli ams=ams.example.com:2112 sg=sg.example.com:2112 +``` + +Labeled and unlabeled targets can be freely mixed in one argument list: + +```sh +irtt-cli host-a:2112 ams=ams.example.com:2112 ``` Multi-target sessions use staggered pacing by default. To send each target's probes together: diff --git a/crates/irtt-cli/src/cmd/client/args.rs b/crates/irtt-cli/src/cmd/client/args.rs index 2b698f6..70bceb3 100644 --- a/crates/irtt-cli/src/cmd/client/args.rs +++ b/crates/irtt-cli/src/cmd/client/args.rs @@ -1,8 +1,8 @@ use std::time::Duration; use crate::shared::client::{ - parse_labelled_target, parse_test_duration, prepare_managed_run, CommonClientArgs, - GroupPacingArg, LabelledTargetArg, ManagedRunSetup, TargetSelection, TimestampArg, + parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg, + ManagedRunSetup, TargetArg, TargetSelection, TimestampArg, }; use clap::{Parser, ValueEnum}; @@ -11,22 +11,14 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10); #[derive(Debug, Clone, Parser)] #[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")] pub struct ClientArgs { - /// Server address or host, with optional port. Repeat for multi-target mode. + /// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode. #[arg( value_name = "TARGET", num_args = 0.., - long_help = "Server address or host, with optional port. Repeat the positional target for multi-target mode, for example: irtt-cli host-a:2112 host-b:2112." + value_parser = parse_target, + long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExamples:\n irtt-cli host-a:2112 host-b:2112\n irtt-cli eu=host-a:2112 us=host-b:2112\n irtt-cli host-a:2112 eu=host-b:2112" )] - pub targets: Vec, - - /// Explicit labelled target, as label=host:port. Repeat for multi-target mode. - #[arg( - long = "target", - value_name = "LABEL=TARGET", - value_parser = parse_labelled_target, - long_help = "Explicit labelled target, as label=host:port. Repeat for multi-target mode. The label is used as the target column and must be unique." - )] - pub labelled_targets: Vec, + pub targets: Vec, /// Managed group pacing for multi-target mode. #[arg( @@ -98,8 +90,7 @@ impl ClientArgs { &self.common, self.duration, TargetSelection { - positional: &self.targets, - labelled: &self.labelled_targets, + targets: &self.targets, pacing: self.pacing, }, ) @@ -167,8 +158,9 @@ mod tests { fn client_parser_keeps_finite_default_duration() { let args = parse(&["127.0.0.1:2112"]).unwrap(); - assert_eq!(args.targets, ["127.0.0.1:2112"]); - assert!(args.labelled_targets.is_empty()); + assert_eq!(args.targets.len(), 1); + assert_eq!(args.targets[0].label, None); + assert_eq!(args.targets[0].addr, "127.0.0.1:2112"); assert_eq!(args.pacing, GroupPacingArg::Staggered); assert_eq!(args.duration, DEFAULT_CLIENT_DURATION); assert_eq!(args.interval, Duration::from_secs(1)); @@ -218,14 +210,8 @@ mod tests { } #[test] - fn repeated_labelled_targets_parse() { - let args = parse(&[ - "--target", - "ams=ams.example.com:2112", - "--target", - "sg=sg.example.com:2112", - ]) - .unwrap(); + fn repeated_labeled_targets_parse() { + let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap(); let specs = args.prepare().unwrap().targets; assert_eq!(specs[0].label, "ams"); @@ -234,9 +220,18 @@ mod tests { assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112"); } + #[test] + fn mixed_labeled_and_unlabeled_targets_preserve_argument_order() { + let args = parse(&["local.example", "eu=a.example", "backup.example"]).unwrap(); + let specs = args.prepare().unwrap().targets; + + let labels: Vec<_> = specs.iter().map(|target| target.label.as_str()).collect(); + assert_eq!(labels, ["local.example", "eu", "backup.example"]); + } + #[test] fn duplicate_labels_are_rejected() { - let args = parse(&["host-a:2112", "--target", "host-a:2112=host-b:2112"]).unwrap(); + let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap(); let err = args.prepare().unwrap_err(); assert!(err.contains("duplicate target label")); @@ -259,9 +254,13 @@ mod tests { #[test] fn invalid_labelled_target_syntax_is_rejected() { - assert!(parse(&["--target", "missing-equals"]).is_err()); - assert!(parse(&["--target", "=127.0.0.1:2112"]).is_err()); - assert!(parse(&["--target", "label="]).is_err()); + assert!(parse(&["=127.0.0.1:2112"]).is_err()); + assert!(parse(&["label="]).is_err()); + } + + #[test] + fn old_target_option_is_rejected() { + assert!(parse(&["--target", "eu=host.example"]).is_err()); } #[test] @@ -284,7 +283,7 @@ mod tests { fn client_help_lists_shared_protocol_options() { let help = ClientArgs::command().render_help().to_string(); assert!(help.contains("--format ")); - assert!(help.contains("--target ")); + assert!(!help.contains("--target ")); assert!(help.contains("--pacing ")); assert!(help.contains("--columns ")); assert!(help.contains("--list-columns")); diff --git a/crates/irtt-cli/src/cmd/tui/args.rs b/crates/irtt-cli/src/cmd/tui/args.rs index 26df3e7..31e640b 100644 --- a/crates/irtt-cli/src/cmd/tui/args.rs +++ b/crates/irtt-cli/src/cmd/tui/args.rs @@ -5,8 +5,8 @@ use clap::Parser; #[cfg(test)] use crate::shared::client::TimestampArg; use crate::shared::client::{ - parse_labelled_target, parse_test_duration, prepare_managed_run, CommonClientArgs, - GroupPacingArg, LabelledTargetArg, ManagedRunSetup, TargetSelection, + parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg, + ManagedRunSetup, TargetArg, TargetSelection, }; pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO; @@ -14,23 +14,15 @@ pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO; #[derive(Debug, Clone, Parser)] #[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")] pub struct TuiArgs { - /// Server address or host, with optional port. Repeat for multi-target mode. + /// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode. #[arg( value_name = "TARGET", - num_args = 0.., - required_unless_present = "labelled_targets", - long_help = "Server address or host, with optional port. Repeat the positional target for multi-target mode, for example: irtt-tui host-a:2112 host-b:2112." + num_args = 1.., + required = true, + value_parser = parse_target, + long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example" )] - pub targets: Vec, - - /// Explicit labelled target, as label=host:port. Repeat for multi-target mode. - #[arg( - long = "target", - value_name = "LABEL=TARGET", - value_parser = parse_labelled_target, - long_help = "Explicit labelled target, as label=host:port. Repeat for multi-target mode. The label is used in the legend and status table and must be unique." - )] - pub labelled_targets: Vec, + pub targets: Vec, /// Managed group pacing for multi-target mode. #[arg( @@ -64,8 +56,7 @@ impl TuiArgs { &self.common, self.duration, TargetSelection { - positional: &self.targets, - labelled: &self.labelled_targets, + targets: &self.targets, pacing: self.pacing, }, ) @@ -115,8 +106,9 @@ mod tests { #[test] fn tui_parser_defaults_to_continuous_and_has_no_output_option() { let args = parse(&["127.0.0.1:2112"]).unwrap(); - assert_eq!(args.targets, ["127.0.0.1:2112"]); - assert!(args.labelled_targets.is_empty()); + assert_eq!(args.targets.len(), 1); + assert_eq!(args.targets[0].label, None); + assert_eq!(args.targets[0].addr, "127.0.0.1:2112"); assert_eq!(args.pacing, GroupPacingArg::Staggered); assert_eq!(args.duration, DEFAULT_TUI_DURATION); assert!(args.is_continuous()); @@ -146,14 +138,8 @@ mod tests { } #[test] - fn repeated_labelled_targets_parse() { - let args = parse(&[ - "--target", - "ams=ams.example.com:2112", - "--target", - "sg=sg.example.com:2112", - ]) - .unwrap(); + fn repeated_labeled_targets_parse() { + let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap(); let specs = args.prepare().unwrap().targets; assert_eq!(specs[0].label, "ams"); @@ -167,12 +153,12 @@ mod tests { // The TUI has no target-free mode, so this is rejected at parse time // rather than during preparation. assert!(parse(&[]).is_err()); - assert!(parse(&["--target", "ams=ams.example.com:2112"]).is_ok()); + assert!(parse(&["ams=ams.example.com:2112"]).is_ok()); } #[test] fn duplicate_labels_are_rejected() { - let args = parse(&["host-a:2112", "--target", "host-a:2112=host-b:2112"]).unwrap(); + let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap(); let err = args.prepare().unwrap_err(); assert!(err.contains("duplicate target label")); @@ -195,9 +181,13 @@ mod tests { #[test] fn invalid_labelled_target_syntax_is_rejected() { - assert!(parse(&["--target", "missing-equals"]).is_err()); - assert!(parse(&["--target", "=127.0.0.1:2112"]).is_err()); - assert!(parse(&["--target", "label="]).is_err()); + assert!(parse(&["=127.0.0.1:2112"]).is_err()); + assert!(parse(&["label="]).is_err()); + } + + #[test] + fn old_target_option_is_rejected() { + assert!(parse(&["--target", "eu=host.example"]).is_err()); } #[test] @@ -283,7 +273,7 @@ mod tests { #[test] fn tui_help_lists_multi_target_options() { let help = TuiArgs::command().render_help().to_string(); - assert!(help.contains("--target ")); + assert!(!help.contains("--target ")); assert!(help.contains("--pacing ")); } } diff --git a/crates/irtt-cli/src/shared/client/mod.rs b/crates/irtt-cli/src/shared/client/mod.rs index b259c1d..12e99aa 100644 --- a/crates/irtt-cli/src/shared/client/mod.rs +++ b/crates/irtt-cli/src/shared/client/mod.rs @@ -12,6 +12,6 @@ pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT}; pub use prepare::{prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY}; pub use session::is_shutdown_requested; pub use targets::{ - parse_labelled_target, prepare_managed_targets, target_specs, GroupPacingArg, - LabelledTargetArg, PreparedTarget, TargetSpec, + parse_target, prepare_managed_targets, target_specs, GroupPacingArg, PreparedTarget, TargetArg, + TargetSpec, }; diff --git a/crates/irtt-cli/src/shared/client/prepare.rs b/crates/irtt-cli/src/shared/client/prepare.rs index 4a5dc98..a91d026 100644 --- a/crates/irtt-cli/src/shared/client/prepare.rs +++ b/crates/irtt-cli/src/shared/client/prepare.rs @@ -16,7 +16,7 @@ use irtt_client::{ use super::{ args::CommonClientArgs, - targets::{prepare_managed_targets, target_specs, GroupPacingArg, LabelledTargetArg}, + targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg}, PreparedTarget, }; @@ -29,8 +29,7 @@ pub const MANAGED_EVENT_CAPACITY: usize = 16_384; /// means and what its default is, so each keeps its own. #[derive(Debug, Clone, Copy)] pub struct TargetSelection<'a> { - pub positional: &'a [String], - pub labelled: &'a [LabelledTargetArg], + pub targets: &'a [TargetArg], pub pacing: GroupPacingArg, } @@ -92,7 +91,7 @@ pub fn prepare_managed_run( duration: Duration, selection: TargetSelection<'_>, ) -> Result { - let specs = target_specs(selection.positional, selection.labelled)?; + let specs = target_specs(selection.targets)?; let targets = prepare_managed_targets(specs)?; Ok(ManagedRunSetup { targets, @@ -119,29 +118,32 @@ mod tests { CommonOnlyArgs::try_parse_from(argv).unwrap().common } + fn unlabeled(addr: &str) -> TargetArg { + TargetArg { + label: None, + addr: addr.to_owned(), + } + } + + fn labeled(label: &str, addr: &str) -> TargetArg { + TargetArg { + label: Some(label.to_owned()), + addr: addr.to_owned(), + } + } + fn prepare( - positional: &[&str], - labelled: &[LabelledTargetArg], + targets: &[TargetArg], pacing: GroupPacingArg, duration: Duration, ) -> Result { - let positional: Vec = positional.iter().map(|value| (*value).to_owned()).collect(); - prepare_managed_run( - &common(&[]), - duration, - TargetSelection { - positional: &positional, - labelled, - pacing, - }, - ) + prepare_managed_run(&common(&[]), duration, TargetSelection { targets, pacing }) } #[test] fn a_single_target_prepares_one_managed_target_and_a_finite_duration() { let setup = prepare( - &["127.0.0.1:2112"], - &[], + &[unlabeled("127.0.0.1:2112")], GroupPacingArg::Staggered, Duration::from_secs(30), ) @@ -158,8 +160,7 @@ mod tests { #[test] fn a_zero_duration_prepares_a_continuous_client_config() { let setup = prepare( - &["127.0.0.1:2112"], - &[], + &[unlabeled("127.0.0.1:2112")], GroupPacingArg::Staggered, Duration::ZERO, ) @@ -170,13 +171,12 @@ mod tests { #[test] fn multiple_targets_keep_argument_order_and_share_one_client_config() { - let labelled = vec![LabelledTargetArg { - label: "sg".to_owned(), - addr: "sg.example.test:2112".to_owned(), - }]; let setup = prepare( - &["host-a:2112", "host-b:2112"], - &labelled, + &[ + unlabeled("host-a:2112"), + unlabeled("host-b:2112"), + labeled("sg", "sg.example.test:2112"), + ], GroupPacingArg::Burst, Duration::from_secs(10), ) @@ -205,13 +205,12 @@ mod tests { fn the_shared_client_config_carries_no_target_of_its_own() { // The managed driver replaces the address, and the CLI's key, per // target, so the template must not pin either to one target. - let positional = vec!["host-a:2112".to_owned(), "host-b:2112".to_owned()]; + let targets = vec![unlabeled("host-a:2112"), unlabeled("host-b:2112")]; let setup = prepare_managed_run( &common(&["--hmac", "secret"]), Duration::from_secs(10), TargetSelection { - positional: &positional, - labelled: &[], + targets: &targets, pacing: GroupPacingArg::Staggered, }, ) @@ -232,8 +231,7 @@ mod tests { #[test] fn an_empty_target_set_is_rejected_with_the_list_columns_hint() { - let err = - prepare(&[], &[], GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err(); + let err = prepare(&[], GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err(); assert!(err.contains("at least one target is required")); assert!(err.contains("--list-columns")); @@ -241,17 +239,12 @@ mod tests { #[test] fn a_duplicate_label_is_rejected_before_any_configuration_is_built() { - let labelled = vec![LabelledTargetArg { - label: "host-a:2112".to_owned(), - addr: "host-b:2112".to_owned(), - }]; - let err = prepare( - &["host-a:2112"], - &labelled, - GroupPacingArg::Staggered, - Duration::from_secs(10), - ) - .unwrap_err(); + let targets = vec![ + unlabeled("host-a:2112"), + labeled("host-a:2112", "host-b:2112"), + ]; + let err = + prepare(&targets, GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err(); assert!(err.contains("duplicate target label")); } diff --git a/crates/irtt-cli/src/shared/client/targets.rs b/crates/irtt-cli/src/shared/client/targets.rs index 77e51ac..35c2d09 100644 --- a/crates/irtt-cli/src/shared/client/targets.rs +++ b/crates/irtt-cli/src/shared/client/targets.rs @@ -4,8 +4,8 @@ use clap::ValueEnum; use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId}; #[derive(Debug, Clone, PartialEq, Eq)] -pub struct LabelledTargetArg { - pub label: String, +pub struct TargetArg { + pub label: Option, pub addr: String, } @@ -36,45 +36,46 @@ impl From for ManagedPacing { } } -pub fn parse_labelled_target(input: &str) -> Result { - let (label, addr) = input - .split_once('=') - .ok_or_else(|| "target must use LABEL=TARGET syntax".to_owned())?; - if label.is_empty() { - return Err("target label must not be empty".to_owned()); +/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`. +pub fn parse_target(input: &str) -> Result { + match input.split_once('=') { + None => Ok(TargetArg { + label: None, + addr: input.to_owned(), + }), + Some((label, addr)) => { + if label.is_empty() { + return Err("target label must not be empty".to_owned()); + } + if addr.is_empty() { + return Err("target address must not be empty".to_owned()); + } + Ok(TargetArg { + label: Some(label.to_owned()), + addr: addr.to_owned(), + }) + } } - if addr.is_empty() { - return Err("target address must not be empty".to_owned()); - } - Ok(LabelledTargetArg { - label: label.to_owned(), - addr: addr.to_owned(), - }) } -pub fn target_specs( - positional_targets: &[String], - labelled_targets: &[LabelledTargetArg], -) -> Result, String> { - let mut specs = Vec::new(); - let mut positional_counts = std::collections::HashMap::<&str, usize>::new(); - for target in positional_targets { - let count = positional_counts.entry(target.as_str()).or_default(); - *count += 1; - let label = if *count == 1 { - target.clone() - } else { - format!("{target}#{}", *count) +pub fn target_specs(targets: &[TargetArg]) -> Result, String> { + let mut specs = Vec::with_capacity(targets.len()); + let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new(); + for target in targets { + let label = match &target.label { + Some(label) => label.clone(), + None => { + let count = unlabeled_counts.entry(target.addr.as_str()).or_default(); + *count += 1; + if *count == 1 { + target.addr.clone() + } else { + format!("{}#{}", target.addr, *count) + } + } }; specs.push(TargetSpec { label, - addr: target.clone(), - }); - } - - for target in labelled_targets { - specs.push(TargetSpec { - label: target.label.clone(), addr: target.addr.clone(), }); } @@ -108,19 +109,101 @@ pub fn prepare_managed_targets(specs: Vec) -> Result TargetArg { + TargetArg { + label: None, + addr: addr.to_owned(), + } + } + + fn labeled(label: &str, addr: &str) -> TargetArg { + TargetArg { + label: Some(label.to_owned()), + addr: addr.to_owned(), + } + } + #[test] - fn target_specs_suffix_repeated_positionals_and_reject_duplicate_labels() { - let positionals = vec!["host-a:2112".to_owned(), "host-a:2112".to_owned()]; - let specs = target_specs(&positionals, &[]).unwrap(); + fn parse_target_without_equals_is_unlabeled() { + let target = parse_target("host.example").unwrap(); + assert_eq!(target.label, None); + assert_eq!(target.addr, "host.example"); + + let target = parse_target("host.example:2112").unwrap(); + assert_eq!(target.label, None); + assert_eq!(target.addr, "host.example:2112"); + + let target = parse_target("[::1]:2112").unwrap(); + assert_eq!(target.label, None); + assert_eq!(target.addr, "[::1]:2112"); + } + + #[test] + fn parse_target_with_equals_splits_on_first() { + let target = parse_target("eu=host.example").unwrap(); + assert_eq!(target.label, Some("eu".to_owned())); + assert_eq!(target.addr, "host.example"); + + let target = parse_target("eu=host.example:2112").unwrap(); + assert_eq!(target.label, Some("eu".to_owned())); + assert_eq!(target.addr, "host.example:2112"); + } + + #[test] + fn parse_target_rejects_empty_label_or_address() { + assert!(parse_target("=host.example").is_err()); + assert!(parse_target("eu=").is_err()); + } + + #[test] + fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() { + let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")]; + let specs = target_specs(&targets).unwrap(); assert_eq!(specs[0].label, "host-a:2112"); assert_eq!(specs[1].label, "host-a:2112#2"); - let labels = vec![LabelledTargetArg { - label: "host-a:2112".to_owned(), - addr: "host-b:2112".to_owned(), - }]; - let err = target_specs(&positionals[..1], &labels).unwrap_err(); + let targets = vec![ + unlabeled("host-a:2112"), + labeled("host-a:2112", "host-b:2112"), + ]; + let err = target_specs(&targets).unwrap_err(); + assert!(err.contains("duplicate target label")); + } + + #[test] + fn target_specs_preserve_argument_order_with_mixed_labels() { + let targets = vec![ + unlabeled("local"), + labeled("eu", "eu.example"), + unlabeled("backup"), + labeled("us", "us.example"), + ]; + let specs = target_specs(&targets).unwrap(); + + let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect(); + assert_eq!(labels, ["local", "eu", "backup", "us"]); + let addrs: Vec<_> = specs.iter().map(|spec| spec.addr.as_str()).collect(); + assert_eq!(addrs, ["local", "eu.example", "backup", "us.example"]); + } + + #[test] + fn target_specs_count_unlabeled_occurrences_independently_of_explicit_labels() { + let targets = vec![unlabeled("foo"), labeled("eu", "foo"), unlabeled("foo")]; + let specs = target_specs(&targets).unwrap(); + + let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect(); + assert_eq!(labels, ["foo", "eu", "foo#2"]); + } + + #[test] + fn target_specs_reject_generated_suffix_collisions() { + let targets = vec![ + unlabeled("host.example"), + unlabeled("host.example"), + labeled("host.example#2", "other.example"), + ]; + let err = target_specs(&targets).unwrap_err(); assert!(err.contains("duplicate target label")); } diff --git a/crates/irtt-cli/tests/client_multi_target.rs b/crates/irtt-cli/tests/client_multi_target.rs index 8fec7d8..c78691c 100644 --- a/crates/irtt-cli/tests/client_multi_target.rs +++ b/crates/irtt-cli/tests/client_multi_target.rs @@ -53,6 +53,18 @@ impl InterruptibleFakeServer { } } +#[test] +fn old_target_option_is_rejected_as_an_unknown_argument() { + let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli")) + .args(["--target", "eu=127.0.0.1:2112"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("unexpected argument"), "{stderr}"); +} + #[test] fn list_columns_succeeds_without_target() { let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli")) @@ -121,7 +133,6 @@ fn single_labeled_target_default_table_includes_target() { "10ms", "--header", "always", - "--target", &format!("eu={}", server.addr), ]) .output() @@ -159,7 +170,6 @@ fn single_labeled_target_default_csv_includes_target_and_event_wall_ns() { "csv", "--header", "always", - "--target", &format!("eu={}", server.addr), ]) .output() @@ -235,9 +245,7 @@ fn multi_target_default_table_includes_both_labels() { "10ms", "--header", "always", - "--target", &format!("a={}", a.addr), - "--target", &format!("b={}", b.addr), ]) .output() @@ -269,6 +277,52 @@ fn multi_target_default_table_includes_both_labels() { ); } +#[test] +fn mixed_labeled_and_unlabeled_positional_targets_both_appear_in_output() { + let a = InTreeServer::start(); + let b = InTreeServer::start(); + let unlabeled_addr = a.addr.to_string(); + + let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli")) + .args([ + "--duration", + "40ms", + "--interval", + "10ms", + "--header", + "always", + &unlabeled_addr, + &format!("eu={}", b.addr), + ]) + .output() + .unwrap(); + + drop(a); + drop(b); + + assert!( + output.status.success(), + "status={:?}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let mut lines = stdout.lines(); + let header = lines.next().unwrap_or_default(); + assert_eq!(header.split_whitespace().next(), Some("target")); + let rows: Vec<&str> = lines.collect(); + assert!( + rows.iter() + .any(|line| line.split_whitespace().next() == Some(unlabeled_addr.as_str())), + "{stdout}" + ); + assert!( + rows.iter() + .any(|line| line.split_whitespace().next() == Some("eu")), + "{stdout}" + ); +} + #[test] fn single_target_custom_target_column_renders_positional_label() { let server = InTreeServer::start(); @@ -358,7 +412,6 @@ fn columns_default_keyword_includes_target_for_single_target() { "default", "--header", "always", - "--target", &format!("eu={}", server.addr), ]) .output() @@ -391,7 +444,6 @@ fn single_labeled_target_default_jsonl_includes_target() { "10ms", "--format", "jsonl", - "--target", &format!("eu={}", server.addr), ]) .output() @@ -523,9 +575,7 @@ fn multi_target_csv_emits_rows_for_both_labels() { "target,seq,effective_rtt_us", "--header", "never", - "--target", &format!("a={}", a.addr), - "--target", &format!("b={}", b.addr), ]) .output() @@ -568,9 +618,7 @@ fn finite_multi_target_peer_close_is_accepted_as_completion() { "target,seq", "--header", "never", - "--target", &format!("a={}", a.addr), - "--target", &format!("b={}", b.addr), ]); @@ -632,9 +680,7 @@ fn continuous_all_peer_closed_targets_exit_nonzero() { "target,seq", "--header", "never", - "--target", &format!("a={}", a.addr), - "--target", &format!("b={}", b.addr), ]); @@ -684,9 +730,7 @@ fn continuous_partial_peer_close_preserves_queued_rows_and_reports_peer_close() "target,seq", "--header", "never", - "--target", &format!("healthy={}", healthy.addr), - "--target", &format!("peer={}", peer_closed.addr), ]); @@ -740,11 +784,8 @@ fn continuous_mixed_peer_close_and_open_failure_exits_nonzero() { "target,seq", "--header", "never", - "--target", &format!("failure={}", failure.addr), - "--target", &format!("healthy={}", healthy.addr), - "--target", &format!("peer={}", peer_closed.addr), ]); @@ -795,9 +836,7 @@ fn explicit_group_interruption_succeeds_without_peer_close_error() { "target,seq", "--header", "never", - "--target", &format!("a={}", a.addr), - "--target", &format!("b={}", b.addr), ]); @@ -851,9 +890,7 @@ fn all_open_failures_exit_nonzero_with_diagnostics() { "0s", "--interval", "10ms", - "--target", &format!("a={}", a.addr), - "--target", &format!("b={}", b.addr), ]); -- 2.54.0