use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(name = "sonda", version, about = "Synthetic telemetry generator", styles = clap_styles())]
pub struct Cli {
#[arg(short, long, global = true, conflicts_with = "verbose")]
pub quiet: bool,
#[arg(short, long, global = true, conflicts_with = "quiet")]
pub verbose: bool,
#[arg(long, global = true)]
pub dry_run: bool,
#[arg(long, global = true)]
pub pack_path: Option<PathBuf>,
#[arg(long, global = true)]
pub scenario_path: Option<PathBuf>,
#[arg(long, global = true, value_name = "FORMAT")]
pub format: Option<String>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verbosity {
Quiet,
Normal,
Verbose,
}
impl Verbosity {
pub fn from_flags(quiet: bool, verbose: bool) -> Self {
if quiet {
Verbosity::Quiet
} else if verbose {
Verbosity::Verbose
} else {
Verbosity::Normal
}
}
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Metrics(MetricsArgs),
Logs(LogsArgs),
Histogram(HistogramArgs),
Summary(SummaryArgs),
Run(RunArgs),
Catalog(CatalogArgs),
#[command(hide = true)]
Scenarios(ScenariosArgs),
#[command(hide = true)]
Packs(PacksArgs),
Import(ImportArgs),
Init(InitArgs),
}
#[derive(Debug, Args)]
pub struct HistogramArgs {
#[arg(long)]
pub scenario: PathBuf,
#[arg(long, value_parser = parse_on_sink_error)]
pub on_sink_error: Option<sonda_core::OnSinkError>,
}
#[derive(Debug, Args)]
pub struct SummaryArgs {
#[arg(long)]
pub scenario: PathBuf,
#[arg(long, value_parser = parse_on_sink_error)]
pub on_sink_error: Option<sonda_core::OnSinkError>,
}
#[derive(Debug, Args)]
pub struct MetricsArgs {
#[arg(long)]
pub scenario: Option<PathBuf>,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub rate: Option<f64>,
#[arg(long)]
pub duration: Option<String>,
#[arg(long, help_heading = "Generator")]
pub value_mode: Option<String>,
#[arg(long, help_heading = "Generator")]
pub amplitude: Option<f64>,
#[arg(long, help_heading = "Generator")]
pub period_secs: Option<f64>,
#[arg(long, help_heading = "Generator")]
pub value: Option<f64>,
#[arg(long, help_heading = "Generator")]
pub offset: Option<f64>,
#[arg(long, help_heading = "Generator")]
pub min: Option<f64>,
#[arg(long, help_heading = "Generator")]
pub max: Option<f64>,
#[arg(long, help_heading = "Generator")]
pub seed: Option<u64>,
#[arg(long, help_heading = "Schedule")]
pub gap_every: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub gap_for: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub burst_every: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub burst_for: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub burst_multiplier: Option<f64>,
#[arg(long, help_heading = "Schedule")]
pub spike_label: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_every: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_for: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_cardinality: Option<u64>,
#[arg(long, help_heading = "Schedule")]
pub spike_strategy: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_prefix: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_seed: Option<u64>,
#[arg(long, help_heading = "Schedule")]
pub jitter: Option<f64>,
#[arg(long, help_heading = "Schedule")]
pub jitter_seed: Option<u64>,
#[arg(long, value_parser = parse_on_sink_error, help_heading = "Schedule")]
pub on_sink_error: Option<sonda_core::OnSinkError>,
#[arg(long = "label", value_parser = parse_label)]
pub labels: Vec<(String, String)>,
#[arg(long, help_heading = "Encoder")]
pub encoder: Option<String>,
#[arg(long, help_heading = "Encoder")]
pub precision: Option<u8>,
#[arg(long, conflicts_with = "sink", help_heading = "Sink")]
pub output: Option<PathBuf>,
#[arg(long, conflicts_with = "output", help_heading = "Sink")]
pub sink: Option<String>,
#[arg(long, help_heading = "Sink")]
pub endpoint: Option<String>,
#[arg(long, help_heading = "Sink")]
pub signal_type: Option<String>,
#[arg(long, help_heading = "Sink")]
pub batch_size: Option<usize>,
#[arg(long, help_heading = "Sink")]
pub content_type: Option<String>,
#[arg(long, help_heading = "Sink")]
pub brokers: Option<String>,
#[arg(long, help_heading = "Sink")]
pub topic: Option<String>,
#[arg(long, help_heading = "Sink")]
pub retry_max_attempts: Option<u32>,
#[arg(long, help_heading = "Sink")]
pub retry_backoff: Option<String>,
#[arg(long, help_heading = "Sink")]
pub retry_max_backoff: Option<String>,
}
#[derive(Debug, Args)]
pub struct LogsArgs {
#[arg(long)]
pub scenario: Option<PathBuf>,
#[arg(long, help_heading = "Generator")]
pub mode: Option<String>,
#[arg(long, alias = "replay-file", help_heading = "Generator")]
pub file: Option<String>,
#[arg(long)]
pub rate: Option<f64>,
#[arg(long)]
pub duration: Option<String>,
#[arg(long, help_heading = "Encoder")]
pub encoder: Option<String>,
#[arg(long, help_heading = "Encoder")]
pub precision: Option<u8>,
#[arg(long = "label", value_parser = parse_label)]
pub labels: Vec<(String, String)>,
#[arg(long, help_heading = "Schedule")]
pub gap_every: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub gap_for: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub burst_every: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub burst_for: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub burst_multiplier: Option<f64>,
#[arg(long, help_heading = "Schedule")]
pub spike_label: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_every: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_for: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_cardinality: Option<u64>,
#[arg(long, help_heading = "Schedule")]
pub spike_strategy: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_prefix: Option<String>,
#[arg(long, help_heading = "Schedule")]
pub spike_seed: Option<u64>,
#[arg(long, help_heading = "Schedule")]
pub jitter: Option<f64>,
#[arg(long, help_heading = "Schedule")]
pub jitter_seed: Option<u64>,
#[arg(long, value_parser = parse_on_sink_error, help_heading = "Schedule")]
pub on_sink_error: Option<sonda_core::OnSinkError>,
#[arg(long, conflicts_with = "sink", help_heading = "Sink")]
pub output: Option<PathBuf>,
#[arg(long, conflicts_with = "output", help_heading = "Sink")]
pub sink: Option<String>,
#[arg(long, help_heading = "Sink")]
pub endpoint: Option<String>,
#[arg(long, help_heading = "Sink")]
pub signal_type: Option<String>,
#[arg(long, help_heading = "Sink")]
pub batch_size: Option<usize>,
#[arg(long, help_heading = "Sink")]
pub content_type: Option<String>,
#[arg(long, help_heading = "Sink")]
pub brokers: Option<String>,
#[arg(long, help_heading = "Sink")]
pub topic: Option<String>,
#[arg(long, help_heading = "Generator")]
pub message: Option<String>,
#[arg(long = "severity-weights", help_heading = "Generator")]
pub severity_weights: Option<String>,
#[arg(long, help_heading = "Generator")]
pub seed: Option<u64>,
#[arg(long, help_heading = "Sink")]
pub retry_max_attempts: Option<u32>,
#[arg(long, help_heading = "Sink")]
pub retry_backoff: Option<String>,
#[arg(long, help_heading = "Sink")]
pub retry_max_backoff: Option<String>,
}
#[derive(Debug, Args)]
pub struct RunArgs {
#[arg(long)]
pub scenario: PathBuf,
#[arg(long)]
pub duration: Option<String>,
#[arg(long)]
pub rate: Option<f64>,
#[arg(long, help_heading = "Sink")]
pub sink: Option<String>,
#[arg(long, help_heading = "Sink")]
pub endpoint: Option<String>,
#[arg(long, help_heading = "Encoder")]
pub encoder: Option<String>,
#[arg(short = 'o', long, conflicts_with = "sink", help_heading = "Sink")]
pub output: Option<PathBuf>,
#[arg(long = "label", value_parser = parse_label, help_heading = "Scenario")]
pub labels: Vec<(String, String)>,
#[arg(long, value_parser = parse_on_sink_error, help_heading = "Scenario")]
pub on_sink_error: Option<sonda_core::OnSinkError>,
}
#[derive(Debug, Args)]
pub struct ScenariosArgs {
#[command(subcommand)]
pub action: ScenariosAction,
}
#[derive(Debug, Subcommand)]
pub enum ScenariosAction {
List(ScenariosListArgs),
Show(ScenariosShowArgs),
Run(ScenariosRunArgs),
}
#[derive(Debug, Args)]
pub struct ScenariosListArgs {
#[arg(long)]
pub category: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ScenariosShowArgs {
pub name: String,
}
#[derive(Debug, Args)]
pub struct ScenariosRunArgs {
pub name: String,
#[arg(long)]
pub duration: Option<String>,
#[arg(long)]
pub rate: Option<f64>,
#[arg(long, help_heading = "Sink")]
pub sink: Option<String>,
#[arg(long, help_heading = "Sink")]
pub endpoint: Option<String>,
#[arg(long, help_heading = "Encoder")]
pub encoder: Option<String>,
}
#[derive(Debug, Args)]
pub struct PacksArgs {
#[command(subcommand)]
pub action: PacksAction,
}
#[derive(Debug, Subcommand)]
pub enum PacksAction {
List(PacksListArgs),
Show(PacksShowArgs),
Run(PacksRunArgs),
}
#[derive(Debug, Args)]
pub struct PacksListArgs {
#[arg(long)]
pub category: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct PacksShowArgs {
pub name: String,
}
#[derive(Debug, Args)]
pub struct PacksRunArgs {
pub name: String,
#[arg(long)]
pub duration: Option<String>,
#[arg(long)]
pub rate: Option<f64>,
#[arg(long, help_heading = "Sink")]
pub sink: Option<String>,
#[arg(long, help_heading = "Sink")]
pub endpoint: Option<String>,
#[arg(long, help_heading = "Encoder")]
pub encoder: Option<String>,
#[arg(short = 'o', long, conflicts_with = "sink", help_heading = "Sink")]
pub output: Option<PathBuf>,
#[arg(long = "label", value_parser = parse_label)]
pub labels: Vec<(String, String)>,
}
#[derive(Debug, Args)]
pub struct ImportArgs {
pub file: PathBuf,
#[arg(long, conflicts_with_all = &["output", "run"])]
pub analyze: bool,
#[arg(short, long, conflicts_with_all = &["analyze", "run"])]
pub output: Option<PathBuf>,
#[arg(long, conflicts_with_all = &["analyze", "output"])]
pub run: bool,
#[arg(long)]
pub columns: Option<String>,
#[arg(long, default_value = "1.0")]
pub rate: f64,
#[arg(long, default_value = "60s")]
pub duration: String,
}
#[derive(Debug, Args)]
pub struct InitArgs {
#[arg(long)]
pub from: Option<String>,
#[arg(long)]
pub signal_type: Option<String>,
#[arg(long)]
pub domain: Option<String>,
#[arg(long)]
pub situation: Option<String>,
#[arg(long)]
pub metric: Option<String>,
#[arg(long)]
pub pack: Option<String>,
#[arg(long)]
pub rate: Option<f64>,
#[arg(long)]
pub duration: Option<String>,
#[arg(long)]
pub encoder: Option<String>,
#[arg(long)]
pub sink: Option<String>,
#[arg(long)]
pub endpoint: Option<String>,
#[arg(short, long)]
pub output: Option<String>,
#[arg(long = "label", value_name = "KEY=VALUE")]
pub labels: Vec<String>,
#[arg(long)]
pub run_now: bool,
#[arg(long, help_heading = "Logs")]
pub message_template: Option<String>,
#[arg(long, help_heading = "Logs")]
pub severity: Option<String>,
#[arg(long, help_heading = "Sink")]
pub kafka_brokers: Option<String>,
#[arg(long, help_heading = "Sink")]
pub kafka_topic: Option<String>,
#[arg(long, help_heading = "Sink")]
pub otlp_signal_type: Option<String>,
}
#[derive(Debug, Args)]
pub struct CatalogArgs {
#[command(subcommand)]
pub action: CatalogAction,
}
#[derive(Debug, Subcommand)]
pub enum CatalogAction {
List(CatalogListArgs),
Show(CatalogShowArgs),
Run(CatalogRunArgs),
}
#[derive(Debug, Args)]
pub struct CatalogListArgs {
#[arg(long)]
pub category: Option<String>,
#[arg(long = "type", value_name = "KIND")]
pub kind: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct CatalogShowArgs {
pub name: String,
}
#[derive(Debug, Args)]
pub struct CatalogRunArgs {
pub name: String,
#[arg(long)]
pub duration: Option<String>,
#[arg(long)]
pub rate: Option<f64>,
#[arg(long, help_heading = "Sink")]
pub sink: Option<String>,
#[arg(long, help_heading = "Sink")]
pub endpoint: Option<String>,
#[arg(long, help_heading = "Encoder")]
pub encoder: Option<String>,
#[arg(short = 'o', long, conflicts_with = "sink", help_heading = "Sink")]
pub output: Option<PathBuf>,
#[arg(long = "label", value_parser = parse_label)]
pub labels: Vec<(String, String)>,
}
fn clap_styles() -> clap::builder::styling::Styles {
use clap::builder::styling::{AnsiColor, Style, Styles};
Styles::styled()
.header(Style::new().bold().underline())
.usage(Style::new().bold())
.literal(Style::new().fg_color(Some(AnsiColor::Cyan.into())).bold())
.placeholder(Style::new().fg_color(Some(AnsiColor::Green.into())))
.valid(Style::new().fg_color(Some(AnsiColor::Green.into())))
.invalid(Style::new().fg_color(Some(AnsiColor::Red.into())))
}
pub fn parse_on_sink_error(s: &str) -> Result<sonda_core::OnSinkError, String> {
match s {
"warn" => Ok(sonda_core::OnSinkError::Warn),
"fail" => Ok(sonda_core::OnSinkError::Fail),
other => Err(format!(
"invalid --on-sink-error {other:?}: expected 'warn' or 'fail'"
)),
}
}
pub fn parse_label(s: &str) -> Result<(String, String), String> {
match s.find('=') {
Some(pos) => {
let key = s[..pos].to_string();
let value = s[pos + 1..].to_string();
if key.is_empty() {
return Err(format!("label key must not be empty in {:?}", s));
}
Ok((key, value))
}
None => Err(format!(
"label {:?} must be in key=value format (no '=' found)",
s
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_label_simple_key_value() {
let result = parse_label("hostname=t0-a1").expect("should parse");
assert_eq!(result, ("hostname".to_string(), "t0-a1".to_string()));
}
#[test]
fn parse_label_value_with_equals_sign() {
let result = parse_label("key=a=b").expect("should parse");
assert_eq!(result, ("key".to_string(), "a=b".to_string()));
}
#[test]
fn parse_label_empty_value_is_allowed() {
let result = parse_label("key=").expect("should parse empty value");
assert_eq!(result, ("key".to_string(), String::new()));
}
#[test]
fn parse_label_zone_label() {
let result = parse_label("zone=eu1").expect("should parse zone label");
assert_eq!(result, ("zone".to_string(), "eu1".to_string()));
}
#[test]
fn parse_label_no_equals_sign_returns_error() {
let err = parse_label("bad").expect_err("should fail without '='");
assert!(
err.contains("key=value"),
"error should mention key=value format, got: {err}"
);
}
#[test]
fn parse_label_empty_string_returns_error() {
let err = parse_label("").expect_err("empty string should fail");
assert!(
err.contains("key=value") || err.contains("'='"),
"error should mention format, got: {err}"
);
}
#[test]
fn parse_label_empty_key_returns_error() {
let err = parse_label("=value").expect_err("empty key should fail");
assert!(
err.contains("empty"),
"error should mention empty key, got: {err}"
);
}
#[test]
fn verbosity_default_is_normal() {
assert_eq!(Verbosity::from_flags(false, false), Verbosity::Normal);
}
#[test]
fn verbosity_quiet_flag() {
assert_eq!(Verbosity::from_flags(true, false), Verbosity::Quiet);
}
#[test]
fn verbosity_verbose_flag() {
assert_eq!(Verbosity::from_flags(false, true), Verbosity::Verbose);
}
#[test]
fn cli_dry_run_flag_is_parsed() {
let cli = Cli::try_parse_from([
"sonda",
"--dry-run",
"metrics",
"--name",
"test",
"--rate",
"1",
])
.expect("--dry-run should parse");
assert!(cli.dry_run);
}
#[test]
fn cli_verbose_flag_is_parsed() {
let cli = Cli::try_parse_from([
"sonda",
"--verbose",
"metrics",
"--name",
"test",
"--rate",
"1",
])
.expect("--verbose should parse");
assert!(cli.verbose);
}
#[test]
fn cli_quiet_and_verbose_conflict() {
let result = Cli::try_parse_from([
"sonda",
"--quiet",
"--verbose",
"metrics",
"--name",
"test",
"--rate",
"1",
]);
assert!(result.is_err(), "--quiet and --verbose must conflict");
}
#[test]
fn cli_dry_run_orthogonal_to_quiet() {
let cli = Cli::try_parse_from([
"sonda",
"--dry-run",
"--quiet",
"metrics",
"--name",
"test",
"--rate",
"1",
])
.expect("--dry-run + --quiet should parse");
assert!(cli.dry_run);
assert!(cli.quiet);
}
#[test]
fn cli_dry_run_orthogonal_to_verbose() {
let cli = Cli::try_parse_from([
"sonda",
"--dry-run",
"--verbose",
"metrics",
"--name",
"test",
"--rate",
"1",
])
.expect("--dry-run + --verbose should parse");
assert!(cli.dry_run);
assert!(cli.verbose);
}
#[test]
fn cli_value_flag_is_parsed() {
let cli = Cli::try_parse_from([
"sonda", "metrics", "--name", "up", "--rate", "1", "--value", "42",
])
.expect("--value should parse");
match cli.command {
Commands::Metrics(args) => {
assert_eq!(args.value, Some(42.0));
}
_ => panic!("expected Metrics command"),
}
}
#[test]
fn cli_value_flag_without_value_mode_is_accepted() {
let cli = Cli::try_parse_from([
"sonda", "metrics", "--name", "up", "--rate", "1", "--value", "1",
])
.expect("--value without --value-mode should be accepted (defaults to constant)");
match cli.command {
Commands::Metrics(args) => {
assert_eq!(args.value, Some(1.0));
assert!(args.value_mode.is_none());
}
_ => panic!("expected Metrics command"),
}
}
#[test]
fn cli_scenarios_list_is_parsed() {
let cli = Cli::try_parse_from(["sonda", "scenarios", "list"])
.expect("scenarios list should parse");
match cli.command {
Commands::Scenarios(args) => {
assert!(matches!(args.action, ScenariosAction::List(_)));
}
_ => panic!("expected Scenarios command"),
}
}
#[test]
fn cli_scenarios_list_with_category_filter() {
let cli =
Cli::try_parse_from(["sonda", "scenarios", "list", "--category", "infrastructure"])
.expect("scenarios list --category should parse");
match cli.command {
Commands::Scenarios(args) => match args.action {
ScenariosAction::List(list_args) => {
assert_eq!(list_args.category.as_deref(), Some("infrastructure"));
}
_ => panic!("expected List action"),
},
_ => panic!("expected Scenarios command"),
}
}
#[test]
fn cli_scenarios_show_is_parsed() {
let cli = Cli::try_parse_from(["sonda", "scenarios", "show", "cpu-spike"])
.expect("scenarios show should parse");
match cli.command {
Commands::Scenarios(args) => match args.action {
ScenariosAction::Show(show_args) => {
assert_eq!(show_args.name, "cpu-spike");
}
_ => panic!("expected Show action"),
},
_ => panic!("expected Scenarios command"),
}
}
#[test]
fn cli_scenarios_run_is_parsed() {
let cli = Cli::try_parse_from(["sonda", "scenarios", "run", "cpu-spike"])
.expect("scenarios run should parse");
match cli.command {
Commands::Scenarios(args) => match args.action {
ScenariosAction::Run(run_args) => {
assert_eq!(run_args.name, "cpu-spike");
assert!(run_args.duration.is_none());
assert!(run_args.rate.is_none());
assert!(run_args.sink.is_none());
assert!(run_args.endpoint.is_none());
assert!(run_args.encoder.is_none());
}
_ => panic!("expected Run action"),
},
_ => panic!("expected Scenarios command"),
}
}
#[test]
fn cli_scenarios_run_with_overrides() {
let cli = Cli::try_parse_from([
"sonda",
"scenarios",
"run",
"cpu-spike",
"--duration",
"5s",
"--rate",
"2",
"--sink",
"file",
"--endpoint",
"/tmp/out.txt",
"--encoder",
"json_lines",
])
.expect("scenarios run with overrides should parse");
match cli.command {
Commands::Scenarios(args) => match args.action {
ScenariosAction::Run(run_args) => {
assert_eq!(run_args.duration.as_deref(), Some("5s"));
assert_eq!(run_args.rate, Some(2.0));
assert_eq!(run_args.sink.as_deref(), Some("file"));
assert_eq!(run_args.endpoint.as_deref(), Some("/tmp/out.txt"));
assert_eq!(run_args.encoder.as_deref(), Some("json_lines"));
}
_ => panic!("expected Run action"),
},
_ => panic!("expected Scenarios command"),
}
}
#[test]
fn cli_scenarios_show_requires_name() {
let result = Cli::try_parse_from(["sonda", "scenarios", "show"]);
assert!(result.is_err(), "show without name must fail");
}
#[test]
fn cli_scenarios_run_requires_name() {
let result = Cli::try_parse_from(["sonda", "scenarios", "run"]);
assert!(result.is_err(), "run without name must fail");
}
#[test]
fn cli_scenarios_requires_action() {
let result = Cli::try_parse_from(["sonda", "scenarios"]);
assert!(result.is_err(), "scenarios without action must fail");
}
#[test]
fn clap_styles_returns_valid_styles() {
let _styles = clap_styles();
}
#[test]
fn cli_scenarios_list_json_flag_is_parsed() {
let cli = Cli::try_parse_from(["sonda", "scenarios", "list", "--json"])
.expect("--json flag should parse");
match cli.command {
Commands::Scenarios(ref args) => match args.action {
ScenariosAction::List(ref list_args) => {
assert!(list_args.json, "--json must be true");
}
_ => panic!("expected List action"),
},
_ => panic!("expected Scenarios command"),
}
}
#[test]
fn cli_scenarios_list_json_flag_defaults_to_false() {
let cli = Cli::try_parse_from(["sonda", "scenarios", "list"])
.expect("list without --json should parse");
match cli.command {
Commands::Scenarios(ref args) => match args.action {
ScenariosAction::List(ref list_args) => {
assert!(!list_args.json, "--json must default to false");
}
_ => panic!("expected List action"),
},
_ => panic!("expected Scenarios command"),
}
}
#[test]
fn cli_scenarios_list_json_and_category_combined() {
let cli = Cli::try_parse_from([
"sonda",
"scenarios",
"list",
"--json",
"--category",
"infrastructure",
])
.expect("--json + --category should parse together");
match cli.command {
Commands::Scenarios(ref args) => match args.action {
ScenariosAction::List(ref list_args) => {
assert!(list_args.json);
assert_eq!(list_args.category.as_deref(), Some("infrastructure"));
}
_ => panic!("expected List action"),
},
_ => panic!("expected Scenarios command"),
}
}
#[test]
fn cli_packs_list_parses() {
let cli = Cli::try_parse_from(["sonda", "packs", "list"]).expect("packs list must parse");
assert!(matches!(cli.command, Commands::Packs(_)));
match cli.command {
Commands::Packs(ref args) => {
assert!(matches!(args.action, PacksAction::List(_)));
}
_ => panic!("expected Packs command"),
}
}
#[test]
fn cli_packs_list_with_category() {
let cli = Cli::try_parse_from(["sonda", "packs", "list", "--category", "network"])
.expect("packs list --category must parse");
match cli.command {
Commands::Packs(ref args) => match args.action {
PacksAction::List(ref list_args) => {
assert_eq!(list_args.category.as_deref(), Some("network"));
}
_ => panic!("expected List action"),
},
_ => panic!("expected Packs command"),
}
}
#[test]
fn cli_packs_list_with_json() {
let cli = Cli::try_parse_from(["sonda", "packs", "list", "--json"])
.expect("packs list --json must parse");
match cli.command {
Commands::Packs(ref args) => match args.action {
PacksAction::List(ref list_args) => {
assert!(list_args.json);
}
_ => panic!("expected List action"),
},
_ => panic!("expected Packs command"),
}
}
#[test]
fn cli_packs_show_parses() {
let cli = Cli::try_parse_from(["sonda", "packs", "show", "telegraf_snmp_interface"])
.expect("packs show must parse");
match cli.command {
Commands::Packs(ref args) => match args.action {
PacksAction::Show(ref show_args) => {
assert_eq!(show_args.name, "telegraf_snmp_interface");
}
_ => panic!("expected Show action"),
},
_ => panic!("expected Packs command"),
}
}
#[test]
fn cli_packs_run_parses() {
let cli = Cli::try_parse_from([
"sonda",
"packs",
"run",
"telegraf_snmp_interface",
"--rate",
"2",
"--duration",
"10s",
])
.expect("packs run must parse");
match cli.command {
Commands::Packs(ref args) => match args.action {
PacksAction::Run(ref run_args) => {
assert_eq!(run_args.name, "telegraf_snmp_interface");
assert_eq!(run_args.rate, Some(2.0));
assert_eq!(run_args.duration.as_deref(), Some("10s"));
}
_ => panic!("expected Run action"),
},
_ => panic!("expected Packs command"),
}
}
#[test]
fn cli_packs_run_with_label() {
let cli = Cli::try_parse_from([
"sonda",
"packs",
"run",
"telegraf_snmp_interface",
"--label",
"device=rtr-01",
"--label",
"ifName=eth0",
])
.expect("packs run --label must parse");
match cli.command {
Commands::Packs(ref args) => match args.action {
PacksAction::Run(ref run_args) => {
assert_eq!(run_args.labels.len(), 2);
assert_eq!(
run_args.labels[0],
("device".to_string(), "rtr-01".to_string())
);
assert_eq!(
run_args.labels[1],
("ifName".to_string(), "eth0".to_string())
);
}
_ => panic!("expected Run action"),
},
_ => panic!("expected Packs command"),
}
}
#[test]
fn cli_packs_run_with_sink_and_encoder() {
let cli = Cli::try_parse_from([
"sonda",
"packs",
"run",
"node_exporter_cpu",
"--sink",
"file",
"--endpoint",
"/tmp/out.txt",
"--encoder",
"json_lines",
])
.expect("packs run --sink --encoder must parse");
match cli.command {
Commands::Packs(ref args) => match args.action {
PacksAction::Run(ref run_args) => {
assert_eq!(run_args.sink.as_deref(), Some("file"));
assert_eq!(run_args.endpoint.as_deref(), Some("/tmp/out.txt"));
assert_eq!(run_args.encoder.as_deref(), Some("json_lines"));
}
_ => panic!("expected Run action"),
},
_ => panic!("expected Packs command"),
}
}
#[test]
fn cli_packs_list_json_and_category_combined() {
let cli = Cli::try_parse_from([
"sonda",
"packs",
"list",
"--json",
"--category",
"infrastructure",
])
.expect("--json + --category should parse together");
match cli.command {
Commands::Packs(ref args) => match args.action {
PacksAction::List(ref list_args) => {
assert!(list_args.json);
assert_eq!(list_args.category.as_deref(), Some("infrastructure"));
}
_ => panic!("expected List action"),
},
_ => panic!("expected Packs command"),
}
}
#[test]
fn cli_import_analyze_is_parsed() {
let cli = Cli::try_parse_from(["sonda", "import", "foo.csv", "--analyze"])
.expect("import --analyze should parse");
match cli.command {
Commands::Import(ref args) => {
assert_eq!(args.file, PathBuf::from("foo.csv"));
assert!(args.analyze);
assert!(args.output.is_none());
assert!(!args.run);
}
_ => panic!("expected Import command"),
}
}
#[test]
fn cli_import_default_rate_and_duration() {
let cli = Cli::try_parse_from(["sonda", "import", "data.csv", "--analyze"])
.expect("import with defaults should parse");
match cli.command {
Commands::Import(ref args) => {
assert_eq!(args.rate, 1.0, "default rate must be 1.0");
assert_eq!(args.duration, "60s", "default duration must be 60s");
}
_ => panic!("expected Import command"),
}
}
#[test]
fn cli_import_columns_flag_is_parsed() {
let cli = Cli::try_parse_from([
"sonda",
"import",
"data.csv",
"--analyze",
"--columns",
"1,3,5",
])
.expect("import --columns should parse");
match cli.command {
Commands::Import(ref args) => {
assert_eq!(args.columns.as_deref(), Some("1,3,5"));
}
_ => panic!("expected Import command"),
}
}
#[test]
fn cli_import_output_flag_is_parsed() {
let cli = Cli::try_parse_from(["sonda", "import", "data.csv", "-o", "out.yaml"])
.expect("import -o should parse");
match cli.command {
Commands::Import(ref args) => {
assert_eq!(args.output, Some(PathBuf::from("out.yaml")));
assert!(!args.analyze);
assert!(!args.run);
}
_ => panic!("expected Import command"),
}
}
#[test]
fn cli_import_run_flag_is_parsed() {
let cli = Cli::try_parse_from(["sonda", "import", "data.csv", "--run"])
.expect("import --run should parse");
match cli.command {
Commands::Import(ref args) => {
assert!(args.run);
assert!(!args.analyze);
assert!(args.output.is_none());
}
_ => panic!("expected Import command"),
}
}
#[test]
fn cli_import_rate_and_duration_overrides() {
let cli = Cli::try_parse_from([
"sonda",
"import",
"data.csv",
"--run",
"--rate",
"5",
"--duration",
"2m",
])
.expect("import with rate and duration overrides should parse");
match cli.command {
Commands::Import(ref args) => {
assert_eq!(args.rate, 5.0);
assert_eq!(args.duration, "2m");
}
_ => panic!("expected Import command"),
}
}
#[test]
fn cli_import_analyze_conflicts_with_output() {
let result =
Cli::try_parse_from(["sonda", "import", "data.csv", "--analyze", "-o", "out.yaml"]);
assert!(result.is_err(), "--analyze and -o must conflict");
}
#[test]
fn cli_import_analyze_conflicts_with_run() {
let result = Cli::try_parse_from(["sonda", "import", "data.csv", "--analyze", "--run"]);
assert!(result.is_err(), "--analyze and --run must conflict");
}
#[test]
fn cli_import_output_conflicts_with_run() {
let result =
Cli::try_parse_from(["sonda", "import", "data.csv", "-o", "out.yaml", "--run"]);
assert!(result.is_err(), "-o and --run must conflict");
}
#[test]
fn cli_import_requires_file_argument() {
let result = Cli::try_parse_from(["sonda", "import", "--analyze"]);
assert!(result.is_err(), "import without file must fail");
}
#[test]
fn cli_import_run_with_columns() {
let cli = Cli::try_parse_from([
"sonda",
"import",
"data.csv",
"--run",
"--columns",
"2,4",
"--rate",
"10",
"--duration",
"5m",
])
.expect("import --run with all options should parse");
match cli.command {
Commands::Import(ref args) => {
assert!(args.run);
assert_eq!(args.columns.as_deref(), Some("2,4"));
assert_eq!(args.rate, 10.0);
assert_eq!(args.duration, "5m");
}
_ => panic!("expected Import command"),
}
}
#[test]
fn cli_import_verbose_flag_with_run() {
let cli = Cli::try_parse_from(["sonda", "--verbose", "import", "data.csv", "--run"])
.expect("import with --verbose should parse");
assert!(cli.verbose);
assert!(matches!(cli.command, Commands::Import(_)));
}
#[test]
fn cli_init_is_parsed() {
let cli = Cli::try_parse_from(["sonda", "init"]).expect("init should parse");
assert!(matches!(cli.command, Commands::Init(_)));
}
#[test]
fn cli_init_with_quiet_flag() {
let cli = Cli::try_parse_from(["sonda", "--quiet", "init"])
.expect("init with --quiet should parse");
assert!(cli.quiet);
assert!(matches!(cli.command, Commands::Init(_)));
}
#[test]
fn cli_init_with_pack_path() {
let cli = Cli::try_parse_from(["sonda", "--pack-path", "/custom/packs", "init"])
.expect("init with --pack-path should parse");
assert_eq!(
cli.pack_path,
Some(std::path::PathBuf::from("/custom/packs"))
);
assert!(matches!(cli.command, Commands::Init(_)));
}
#[test]
fn cli_init_from_builtin_scenario() {
let cli =
Cli::try_parse_from(["sonda", "init", "--from", "@cpu-spike"]).expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(args.from.as_deref(), Some("@cpu-spike"));
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_from_csv_file() {
let cli =
Cli::try_parse_from(["sonda", "init", "--from", "data.csv"]).expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(args.from.as_deref(), Some("data.csv"));
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_all_flags() {
let cli = Cli::try_parse_from([
"sonda",
"init",
"--signal-type",
"metrics",
"--domain",
"network",
"--situation",
"flap",
"--metric",
"bgp_state",
"--rate",
"2.5",
"--duration",
"5m",
"--encoder",
"prometheus_text",
"--sink",
"stdout",
"--endpoint",
"http://localhost:9090",
"-o",
"output.yaml",
"--label",
"env=prod",
"--label",
"dc=us-east",
])
.expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(args.signal_type.as_deref(), Some("metrics"));
assert_eq!(args.domain.as_deref(), Some("network"));
assert_eq!(args.situation.as_deref(), Some("flap"));
assert_eq!(args.metric.as_deref(), Some("bgp_state"));
assert_eq!(args.rate, Some(2.5));
assert_eq!(args.duration.as_deref(), Some("5m"));
assert_eq!(args.encoder.as_deref(), Some("prometheus_text"));
assert_eq!(args.sink.as_deref(), Some("stdout"));
assert_eq!(args.endpoint.as_deref(), Some("http://localhost:9090"));
assert_eq!(args.output.as_deref(), Some("output.yaml"));
assert_eq!(args.labels, vec!["env=prod", "dc=us-east"]);
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_pack_flag() {
let cli = Cli::try_parse_from(["sonda", "init", "--pack", "telegraf_snmp"])
.expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(args.pack.as_deref(), Some("telegraf_snmp"));
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_no_flags_defaults_to_none() {
let cli = Cli::try_parse_from(["sonda", "init"]).expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert!(args.from.is_none());
assert!(args.signal_type.is_none());
assert!(args.domain.is_none());
assert!(args.situation.is_none());
assert!(args.metric.is_none());
assert!(args.pack.is_none());
assert!(args.rate.is_none());
assert!(args.duration.is_none());
assert!(args.encoder.is_none());
assert!(args.sink.is_none());
assert!(args.endpoint.is_none());
assert!(args.output.is_none());
assert!(args.labels.is_empty());
assert!(!args.run_now);
assert!(args.message_template.is_none());
assert!(args.severity.is_none());
assert!(args.kafka_brokers.is_none());
assert!(args.kafka_topic.is_none());
assert!(args.otlp_signal_type.is_none());
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_output_short_flag() {
let cli =
Cli::try_parse_from(["sonda", "init", "-o", "my-scenario.yaml"]).expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(args.output.as_deref(), Some("my-scenario.yaml"));
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_run_now_flag() {
let cli = Cli::try_parse_from(["sonda", "init", "--run-now"]).expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert!(args.run_now);
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_message_template_flag() {
let cli = Cli::try_parse_from([
"sonda",
"init",
"--message-template",
"Connection from {ip} failed",
])
.expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(
args.message_template.as_deref(),
Some("Connection from {ip} failed")
);
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_severity_flag() {
let cli =
Cli::try_parse_from(["sonda", "init", "--severity", "balanced"]).expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(args.severity.as_deref(), Some("balanced"));
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_kafka_flags() {
let cli = Cli::try_parse_from([
"sonda",
"init",
"--sink",
"kafka",
"--kafka-brokers",
"broker1:9092,broker2:9092",
"--kafka-topic",
"my-topic",
])
.expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(args.sink.as_deref(), Some("kafka"));
assert_eq!(
args.kafka_brokers.as_deref(),
Some("broker1:9092,broker2:9092")
);
assert_eq!(args.kafka_topic.as_deref(), Some("my-topic"));
} else {
panic!("expected Init command");
}
}
#[test]
fn cli_init_otlp_signal_type_flag() {
let cli = Cli::try_parse_from([
"sonda",
"init",
"--sink",
"otlp_grpc",
"--otlp-signal-type",
"metrics",
])
.expect("should parse");
if let Commands::Init(ref args) = cli.command {
assert_eq!(args.sink.as_deref(), Some("otlp_grpc"));
assert_eq!(args.otlp_signal_type.as_deref(), Some("metrics"));
} else {
panic!("expected Init command");
}
}
}