use std::collections::BTreeMap;
use std::io;
use std::io::IsTerminal;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use owo_colors::OwoColorize;
use owo_colors::Stream::Stderr;
use crate::packs::PackCatalog;
use super::yaml_gen::{
required_encoder_for_sink, DeliveryAnswers, HistogramAnswers, LogAnswers, MetricAnswers,
PackAnswers, ParamValue, ScenarioKind, SummaryAnswers,
};
#[derive(Debug, Default, Clone)]
pub struct Prefill {
pub signal_type: Option<String>,
pub domain: Option<String>,
pub situation: Option<String>,
pub metric: Option<String>,
pub pack: Option<String>,
pub rate: Option<f64>,
pub duration: Option<String>,
pub encoder: Option<String>,
pub sink: Option<String>,
pub endpoint: Option<String>,
pub labels: BTreeMap<String, String>,
pub message_template: Option<String>,
pub severity: Option<String>,
pub kafka_brokers: Option<String>,
pub kafka_topic: Option<String>,
pub otlp_signal_type: Option<String>,
}
const ALL_SINKS: &[&str] = &[
"stdout",
"http_push",
"file",
"remote_write",
"loki",
"otlp_grpc",
"kafka",
"tcp",
"udp",
];
const ALL_ENCODERS: &[&str] = &[
"prometheus_text",
"influx_lp",
"json_lines",
"syslog",
"remote_write",
"otlp",
];
const SITUATIONS: &[&str] = &[
"steady",
"spike_event",
"flap",
"leak",
"saturation",
"degradation",
];
const SITUATION_DESCRIPTIONS: &[&str] = &[
"steady - stable value with gentle oscillation and noise",
"spike_event - baseline with periodic spikes (anomaly testing)",
"flap - value toggling between two states (up/down)",
"leak - gradual climb to a ceiling (memory leak)",
"saturation - repeating fill-and-reset cycles",
"degradation - slow ramp with increasing noise",
];
pub const SECTION_WIDTH: usize = 45;
const METRIC_ENCODERS: &[&str] = &["prometheus_text", "influx_lp", "json_lines"];
const LOG_ENCODERS: &[&str] = &["json_lines", "syslog"];
const SINKS: &[&str] = &["stdout", "http_push", "file", "Advanced..."];
const ADVANCED_SINKS: &[&str] = &["remote_write", "loki", "otlp_grpc", "kafka", "tcp", "udp"];
const ADVANCED_SINK_DESCRIPTIONS: &[&str] = &[
"remote_write - Prometheus remote write (protobuf + snappy)",
"loki - Grafana Loki log push (HTTP)",
"otlp_grpc - OpenTelemetry Collector (gRPC)",
"kafka - Apache Kafka producer",
"tcp - TCP socket (host:port)",
"udp - UDP socket (host:port)",
];
const DISTRIBUTION_MODELS: &[&str] = &["normal", "exponential", "uniform"];
const DISTRIBUTION_MODEL_DESCRIPTIONS: &[&str] = &[
"normal - Gaussian (mean + stddev)",
"exponential - latency-style (rate parameter)",
"uniform - even spread over [min, max]",
];
const DOMAINS: &[&str] = &["infrastructure", "network", "application", "custom"];
type SinkPromptResult = (String, Option<String>, BTreeMap<String, String>);
pub fn print_section(step: usize, total: usize, title: &str) {
let prefix = "\u{2500}\u{2500}";
let tag = format!("[{step}/{total}]");
let prefix_display = 2; let used = prefix_display + 1 + tag.len() + 1 + title.len() + 1;
let remaining = if SECTION_WIDTH > used {
SECTION_WIDTH - used
} else {
3
};
let tail: String = "\u{2500}".repeat(remaining);
let rule = format!("{prefix} {tag} {title} {tail}");
eprintln!("\n{}", rule.if_supports_color(Stderr, |t| t.dimmed()));
eprintln!();
}
pub fn run_prompts(
pack_catalog: &PackCatalog,
prefill: &Prefill,
) -> Result<(ScenarioKind, DeliveryAnswers), io::Error> {
let theme = ColorfulTheme::default();
print_section(1, 4, "Signal");
let signal_type = prompt_signal_type(&theme, prefill)?;
let domain = prompt_domain(&theme, prefill)?;
match signal_type.as_str() {
"metrics" => run_metrics_prompts(&theme, &domain, pack_catalog, prefill),
"logs" => run_logs_prompts(&theme, &domain, prefill),
"histogram" => run_histogram_prompts(&theme, &domain, prefill),
"summary" => run_summary_prompts(&theme, &domain, prefill),
_ => unreachable!("signal type is constrained by prompt"),
}
}
fn prompt_signal_type(theme: &ColorfulTheme, prefill: &Prefill) -> Result<String, io::Error> {
let items = &["metrics", "logs", "histogram", "summary"];
if let Some(ref val) = prefill.signal_type {
if items.contains(&val.as_str()) {
return Ok(val.clone());
}
print_invalid_prefill("signal_type", val, items);
}
let selection = Select::with_theme(theme)
.with_prompt("What type of signal?")
.items(items)
.default(0)
.interact()?;
Ok(items[selection].to_string())
}
fn prompt_domain(theme: &ColorfulTheme, prefill: &Prefill) -> Result<String, io::Error> {
if let Some(ref val) = prefill.domain {
if DOMAINS.contains(&val.as_str()) {
return Ok(val.clone());
}
print_invalid_prefill("domain", val, DOMAINS);
}
let selection = Select::with_theme(theme)
.with_prompt("What domain?")
.items(DOMAINS)
.default(0)
.interact()?;
Ok(DOMAINS[selection].to_string())
}
fn run_metrics_prompts(
theme: &ColorfulTheme,
domain: &str,
pack_catalog: &PackCatalog,
prefill: &Prefill,
) -> Result<(ScenarioKind, DeliveryAnswers), io::Error> {
let available_packs = pack_catalog.list();
print_section(2, 4, "Metric");
let kind = if prefill.pack.is_some() {
prompt_pack(theme, pack_catalog, domain, prefill)?
} else if !available_packs.is_empty() && prefill.metric.is_none() && prefill.situation.is_none()
{
let approach_items = &["Single metric", "Use a metric pack"];
let approach = Select::with_theme(theme)
.with_prompt("How would you like to define metrics?")
.items(approach_items)
.default(0)
.interact()?;
match approach {
0 => prompt_single_metric(theme, prefill)?,
1 => prompt_pack(theme, pack_catalog, domain, prefill)?,
_ => unreachable!(),
}
} else {
prompt_single_metric(theme, prefill)?
};
print_section(3, 4, "Delivery");
let rate = prompt_rate(theme, prefill)?;
let duration = prompt_duration(theme, prefill)?;
let encoder = prompt_encoder(theme, METRIC_ENCODERS, prefill)?;
let (sink, endpoint, sink_extra) = prompt_sink(theme, prefill)?;
let encoder = enforce_encoder_for_sink(encoder, &sink);
let delivery = DeliveryAnswers {
domain: domain.to_string(),
rate,
duration,
encoder,
sink,
endpoint,
sink_extra,
};
Ok((kind, delivery))
}
fn run_logs_prompts(
theme: &ColorfulTheme,
domain: &str,
prefill: &Prefill,
) -> Result<(ScenarioKind, DeliveryAnswers), io::Error> {
print_section(2, 4, "Log");
let name = if let Some(ref val) = prefill.metric {
val.clone()
} else {
Input::with_theme(theme)
.with_prompt("Log scenario name")
.default("app_logs".to_string())
.interact_text()?
};
let message_template = if let Some(ref val) = prefill.message_template {
val.clone()
} else {
Input::with_theme(theme)
.with_prompt("Message template (use {field} for placeholders)")
.default("Request to {endpoint} completed with status {status}".to_string())
.interact_text()?
};
let severity_weights = if let Some(ref val) = prefill.severity {
match severity_preset_weights(val) {
Some(weights) => weights,
None => {
print_invalid_prefill("severity", val, &["mostly_info", "balanced", "error_heavy"]);
prompt_severity_interactive(theme)?
}
}
} else {
prompt_severity_interactive(theme)?
};
let labels = prompt_labels(theme, &prefill.labels)?;
print_section(3, 4, "Delivery");
let rate = prompt_rate(theme, prefill)?;
let duration = prompt_duration(theme, prefill)?;
let encoder = prompt_encoder(theme, LOG_ENCODERS, prefill)?;
let (sink, endpoint, sink_extra) = prompt_sink(theme, prefill)?;
let encoder = enforce_encoder_for_sink(encoder, &sink);
let kind = ScenarioKind::Logs(LogAnswers {
name,
message_template,
severity_weights,
labels,
});
let delivery = DeliveryAnswers {
domain: domain.to_string(),
rate,
duration,
encoder,
sink,
endpoint,
sink_extra,
};
Ok((kind, delivery))
}
fn run_histogram_prompts(
theme: &ColorfulTheme,
domain: &str,
prefill: &Prefill,
) -> Result<(ScenarioKind, DeliveryAnswers), io::Error> {
print_section(2, 4, "Histogram");
let name = if let Some(ref val) = prefill.metric {
val.clone()
} else {
Input::with_theme(theme)
.with_prompt("Metric name")
.default("http_request_duration_seconds".to_string())
.interact_text()?
};
let use_defaults = prefill.signal_type.is_some();
let distribution_type = if use_defaults {
"normal".to_string()
} else {
let dist_idx = Select::with_theme(theme)
.with_prompt("Distribution model")
.items(DISTRIBUTION_MODEL_DESCRIPTIONS)
.default(0)
.interact()?;
DISTRIBUTION_MODELS[dist_idx].to_string()
};
let distribution_params = if use_defaults {
default_distribution_params(&distribution_type)
} else {
prompt_distribution_params(theme, &distribution_type)?
};
let observations_per_tick: u64 = if use_defaults {
100
} else {
Input::with_theme(theme)
.with_prompt("Observations per tick")
.default(100u64)
.interact_text()?
};
let buckets: Option<Vec<f64>> = if use_defaults {
None
} else {
let bucket_items = &["Prometheus defaults", "Custom"];
let bucket_idx = Select::with_theme(theme)
.with_prompt("Bucket boundaries")
.items(bucket_items)
.default(0)
.interact()?;
if bucket_idx == 1 {
let raw: String = Input::with_theme(theme)
.with_prompt("Custom buckets (comma-separated floats)")
.default("0.01, 0.05, 0.1, 0.5, 1.0, 5.0".to_string())
.interact_text()?;
let parsed: Vec<f64> = raw
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.collect();
if parsed.is_empty() {
None
} else {
Some(parsed)
}
} else {
None
}
};
let seed: u64 = if use_defaults {
42
} else {
Input::with_theme(theme)
.with_prompt("RNG seed")
.default(42u64)
.interact_text()?
};
let labels = prompt_labels(theme, &prefill.labels)?;
print_section(3, 4, "Delivery");
let rate = prompt_rate(theme, prefill)?;
let duration = prompt_duration(theme, prefill)?;
let encoder = prompt_encoder(theme, METRIC_ENCODERS, prefill)?;
let (sink, endpoint, sink_extra) = prompt_sink(theme, prefill)?;
let encoder = enforce_encoder_for_sink(encoder, &sink);
let kind = ScenarioKind::Histogram(HistogramAnswers {
name,
distribution_type,
distribution_params,
observations_per_tick,
buckets,
seed,
labels,
});
let delivery = DeliveryAnswers {
domain: domain.to_string(),
rate,
duration,
encoder,
sink,
endpoint,
sink_extra,
};
Ok((kind, delivery))
}
fn run_summary_prompts(
theme: &ColorfulTheme,
domain: &str,
prefill: &Prefill,
) -> Result<(ScenarioKind, DeliveryAnswers), io::Error> {
print_section(2, 4, "Summary");
let name = if let Some(ref val) = prefill.metric {
val.clone()
} else {
Input::with_theme(theme)
.with_prompt("Metric name")
.default("rpc_duration_seconds".to_string())
.interact_text()?
};
let use_defaults = prefill.signal_type.is_some();
let distribution_type = if use_defaults {
"normal".to_string()
} else {
let dist_idx = Select::with_theme(theme)
.with_prompt("Distribution model")
.items(DISTRIBUTION_MODEL_DESCRIPTIONS)
.default(0)
.interact()?;
DISTRIBUTION_MODELS[dist_idx].to_string()
};
let distribution_params = if use_defaults {
default_distribution_params(&distribution_type)
} else {
prompt_distribution_params(theme, &distribution_type)?
};
let observations_per_tick: u64 = if use_defaults {
100
} else {
Input::with_theme(theme)
.with_prompt("Observations per tick")
.default(100u64)
.interact_text()?
};
let quantiles: Option<Vec<f64>> = if use_defaults {
None
} else {
let quantile_items = &["Standard quantiles", "Custom"];
let quantile_idx = Select::with_theme(theme)
.with_prompt("Quantile targets")
.items(quantile_items)
.default(0)
.interact()?;
if quantile_idx == 1 {
let raw: String = Input::with_theme(theme)
.with_prompt("Custom quantiles (comma-separated, values in (0,1))")
.default("0.5, 0.9, 0.95, 0.99".to_string())
.interact_text()?;
let parsed: Vec<f64> = raw
.split(',')
.filter_map(|s| {
let v = s.trim().parse::<f64>().ok()?;
if v > 0.0 && v < 1.0 {
Some(v)
} else {
None
}
})
.collect();
if parsed.is_empty() {
None
} else {
Some(parsed)
}
} else {
None
}
};
let seed: u64 = if use_defaults {
42
} else {
Input::with_theme(theme)
.with_prompt("RNG seed")
.default(42u64)
.interact_text()?
};
let labels = prompt_labels(theme, &prefill.labels)?;
print_section(3, 4, "Delivery");
let rate = prompt_rate(theme, prefill)?;
let duration = prompt_duration(theme, prefill)?;
let encoder = prompt_encoder(theme, METRIC_ENCODERS, prefill)?;
let (sink, endpoint, sink_extra) = prompt_sink(theme, prefill)?;
let encoder = enforce_encoder_for_sink(encoder, &sink);
let kind = ScenarioKind::Summary(SummaryAnswers {
name,
distribution_type,
distribution_params,
observations_per_tick,
quantiles,
seed,
labels,
});
let delivery = DeliveryAnswers {
domain: domain.to_string(),
rate,
duration,
encoder,
sink,
endpoint,
sink_extra,
};
Ok((kind, delivery))
}
fn prompt_distribution_params(
theme: &ColorfulTheme,
distribution_type: &str,
) -> Result<Vec<(String, ParamValue)>, io::Error> {
let params = match distribution_type {
"normal" => {
let mean: f64 = Input::with_theme(theme)
.with_prompt("Mean")
.default(0.1)
.interact_text()?;
let stddev: f64 = Input::with_theme(theme)
.with_prompt("Standard deviation")
.default(0.03)
.interact_text()?;
vec![
("mean".to_string(), ParamValue::Float(mean)),
("stddev".to_string(), ParamValue::Float(stddev)),
]
}
"exponential" => {
let rate: f64 = Input::with_theme(theme)
.with_prompt("Rate (lambda)")
.default(10.0)
.interact_text()?;
vec![("rate".to_string(), ParamValue::Float(rate))]
}
"uniform" => {
let min: f64 = Input::with_theme(theme)
.with_prompt("Min value")
.default(0.0)
.interact_text()?;
let max: f64 = Input::with_theme(theme)
.with_prompt("Max value")
.default(1.0)
.interact_text()?;
vec![
("min".to_string(), ParamValue::Float(min)),
("max".to_string(), ParamValue::Float(max)),
]
}
_ => vec![],
};
Ok(params)
}
fn default_distribution_params(distribution_type: &str) -> Vec<(String, ParamValue)> {
match distribution_type {
"normal" => vec![
("mean".to_string(), ParamValue::Float(0.1)),
("stddev".to_string(), ParamValue::Float(0.03)),
],
"exponential" => vec![("rate".to_string(), ParamValue::Float(10.0))],
"uniform" => vec![
("min".to_string(), ParamValue::Float(0.0)),
("max".to_string(), ParamValue::Float(1.0)),
],
_ => vec![],
}
}
fn prompt_single_metric(
theme: &ColorfulTheme,
prefill: &Prefill,
) -> Result<ScenarioKind, io::Error> {
let name = if let Some(ref val) = prefill.metric {
val.clone()
} else {
Input::with_theme(theme)
.with_prompt("Metric name")
.default("my_metric".to_string())
.interact_text()?
};
let situation = prompt_situation(theme, prefill)?;
let situation_params = prompt_situation_params(theme, &situation, prefill)?;
let labels = prompt_labels(theme, &prefill.labels)?;
Ok(ScenarioKind::SingleMetric(MetricAnswers {
name,
situation,
situation_params,
labels,
}))
}
fn prompt_situation(theme: &ColorfulTheme, prefill: &Prefill) -> Result<String, io::Error> {
if let Some(ref val) = prefill.situation {
if SITUATIONS.contains(&val.as_str()) {
return Ok(val.clone());
}
print_invalid_prefill("situation", val, SITUATIONS);
}
let situation_idx = Select::with_theme(theme)
.with_prompt("What situation should this metric simulate?")
.items(SITUATION_DESCRIPTIONS)
.default(0)
.interact()?;
Ok(SITUATIONS[situation_idx].to_string())
}
fn prompt_pack(
theme: &ColorfulTheme,
catalog: &PackCatalog,
domain: &str,
prefill: &Prefill,
) -> Result<ScenarioKind, io::Error> {
let pack_name = if let Some(ref prefill_pack) = prefill.pack {
if catalog.find(prefill_pack).is_some() {
prefill_pack.clone()
} else {
let warning = format!(
"Pack '{}' not found in catalog, falling through to prompt.",
prefill_pack
);
eprintln!(" {}", warning.if_supports_color(Stderr, |t| t.dimmed()));
prompt_pack_interactive(theme, catalog, domain)?
}
} else {
prompt_pack_interactive(theme, catalog, domain)?
};
let mut labels = prefill.labels.clone();
if let Some(Ok(yaml_content)) = catalog.read_yaml(&pack_name) {
if let Ok(pack_def) =
serde_yaml_ng::from_str::<sonda_core::packs::MetricPackDef>(&yaml_content)
{
if let Some(shared_labels) = &pack_def.shared_labels {
for (key, value) in shared_labels {
if labels.contains_key(key) {
continue;
}
if value.is_empty() {
let label_value: String = Input::with_theme(theme)
.with_prompt(format!("Value for label '{key}'"))
.default(format!("my-{key}"))
.interact_text()?;
labels.insert(key.clone(), label_value);
} else {
labels.insert(key.clone(), value.clone());
}
}
}
}
}
if prefill.labels.is_empty() {
let empty = BTreeMap::new();
let extra_labels = prompt_labels(theme, &empty)?;
for (k, v) in extra_labels {
labels.insert(k, v);
}
}
Ok(ScenarioKind::Pack(PackAnswers { pack_name, labels }))
}
fn prompt_pack_interactive(
theme: &ColorfulTheme,
catalog: &PackCatalog,
domain: &str,
) -> Result<String, io::Error> {
let domain_packs = catalog.list_by_category(domain);
let packs_to_show = if domain_packs.is_empty() {
eprintln!(
" {}",
format!("No packs found for domain \"{domain}\", showing all packs.")
.if_supports_color(Stderr, |t| t.dimmed()),
);
catalog.list().iter().collect()
} else {
eprintln!(
" {}",
format!("Showing packs for domain: {domain}").if_supports_color(Stderr, |t| t.dimmed()),
);
domain_packs
};
let pack_names: Vec<String> = packs_to_show
.iter()
.map(|p| {
format!(
"{} - {} ({} metrics)",
p.name, p.description, p.metric_count
)
})
.collect();
let pack_idx = Select::with_theme(theme)
.with_prompt("Which metric pack?")
.items(&pack_names)
.default(0)
.interact()?;
let selected_pack = packs_to_show[pack_idx];
Ok(selected_pack.name.clone())
}
fn default_situation_params(situation: &str) -> Vec<(String, ParamValue)> {
match situation {
"steady" => vec![
("center".to_string(), ParamValue::Float(50.0)),
("amplitude".to_string(), ParamValue::Float(10.0)),
("period".to_string(), ParamValue::String("60s".to_string())),
],
"spike_event" => vec![
("baseline".to_string(), ParamValue::Float(0.0)),
("spike_height".to_string(), ParamValue::Float(100.0)),
(
"spike_duration".to_string(),
ParamValue::String("10s".to_string()),
),
(
"spike_interval".to_string(),
ParamValue::String("30s".to_string()),
),
],
"flap" => vec![
("up_value".to_string(), ParamValue::Float(1.0)),
("down_value".to_string(), ParamValue::Float(0.0)),
(
"up_duration".to_string(),
ParamValue::String("10s".to_string()),
),
(
"down_duration".to_string(),
ParamValue::String("5s".to_string()),
),
],
"leak" => vec![
("baseline".to_string(), ParamValue::Float(0.0)),
("ceiling".to_string(), ParamValue::Float(100.0)),
(
"time_to_ceiling".to_string(),
ParamValue::String("10m".to_string()),
),
],
"saturation" => vec![
("baseline".to_string(), ParamValue::Float(0.0)),
("ceiling".to_string(), ParamValue::Float(100.0)),
(
"time_to_saturate".to_string(),
ParamValue::String("5m".to_string()),
),
],
"degradation" => vec![
("baseline".to_string(), ParamValue::Float(0.0)),
("ceiling".to_string(), ParamValue::Float(100.0)),
(
"time_to_degrade".to_string(),
ParamValue::String("5m".to_string()),
),
("noise".to_string(), ParamValue::Float(1.0)),
],
_ => vec![],
}
}
fn prompt_situation_params(
theme: &ColorfulTheme,
situation: &str,
prefill: &Prefill,
) -> Result<Vec<(String, ParamValue)>, io::Error> {
if prefill.situation.is_some() {
return Ok(default_situation_params(situation));
}
let params = match situation {
"steady" => {
let center: f64 = Input::with_theme(theme)
.with_prompt("Center value")
.default(50.0)
.interact_text()?;
let amplitude: f64 = Input::with_theme(theme)
.with_prompt("Amplitude (oscillation range)")
.default(10.0)
.interact_text()?;
let period: String = Input::with_theme(theme)
.with_prompt("Oscillation period")
.default("60s".to_string())
.interact_text()?;
vec![
("center".to_string(), ParamValue::Float(center)),
("amplitude".to_string(), ParamValue::Float(amplitude)),
("period".to_string(), ParamValue::String(period)),
]
}
"spike_event" => {
let baseline: f64 = Input::with_theme(theme)
.with_prompt("Baseline value (between spikes)")
.default(0.0)
.interact_text()?;
let spike_height: f64 = Input::with_theme(theme)
.with_prompt("Spike height (amount added during spike)")
.default(100.0)
.interact_text()?;
let spike_duration: String = Input::with_theme(theme)
.with_prompt("Spike duration")
.default("10s".to_string())
.interact_text()?;
let spike_interval: String = Input::with_theme(theme)
.with_prompt("Spike interval (time between spikes)")
.default("30s".to_string())
.interact_text()?;
vec![
("baseline".to_string(), ParamValue::Float(baseline)),
("spike_height".to_string(), ParamValue::Float(spike_height)),
(
"spike_duration".to_string(),
ParamValue::String(spike_duration),
),
(
"spike_interval".to_string(),
ParamValue::String(spike_interval),
),
]
}
"flap" => {
let up_value: f64 = Input::with_theme(theme)
.with_prompt("Up-state value")
.default(1.0)
.interact_text()?;
let down_value: f64 = Input::with_theme(theme)
.with_prompt("Down-state value")
.default(0.0)
.interact_text()?;
let up_duration: String = Input::with_theme(theme)
.with_prompt("Up-state duration")
.default("10s".to_string())
.interact_text()?;
let down_duration: String = Input::with_theme(theme)
.with_prompt("Down-state duration")
.default("5s".to_string())
.interact_text()?;
vec![
("up_value".to_string(), ParamValue::Float(up_value)),
("down_value".to_string(), ParamValue::Float(down_value)),
("up_duration".to_string(), ParamValue::String(up_duration)),
(
"down_duration".to_string(),
ParamValue::String(down_duration),
),
]
}
"leak" => {
let baseline: f64 = Input::with_theme(theme)
.with_prompt("Starting value")
.default(0.0)
.interact_text()?;
let ceiling: f64 = Input::with_theme(theme)
.with_prompt("Ceiling value")
.default(100.0)
.interact_text()?;
let time_to_ceiling: String = Input::with_theme(theme)
.with_prompt("Time to reach ceiling")
.default("10m".to_string())
.interact_text()?;
vec![
("baseline".to_string(), ParamValue::Float(baseline)),
("ceiling".to_string(), ParamValue::Float(ceiling)),
(
"time_to_ceiling".to_string(),
ParamValue::String(time_to_ceiling),
),
]
}
"saturation" => {
let baseline: f64 = Input::with_theme(theme)
.with_prompt("Baseline value")
.default(0.0)
.interact_text()?;
let ceiling: f64 = Input::with_theme(theme)
.with_prompt("Ceiling value")
.default(100.0)
.interact_text()?;
let time_to_saturate: String = Input::with_theme(theme)
.with_prompt("Time to saturate")
.default("5m".to_string())
.interact_text()?;
vec![
("baseline".to_string(), ParamValue::Float(baseline)),
("ceiling".to_string(), ParamValue::Float(ceiling)),
(
"time_to_saturate".to_string(),
ParamValue::String(time_to_saturate),
),
]
}
"degradation" => {
let baseline: f64 = Input::with_theme(theme)
.with_prompt("Starting value")
.default(0.0)
.interact_text()?;
let ceiling: f64 = Input::with_theme(theme)
.with_prompt("Ceiling value")
.default(100.0)
.interact_text()?;
let time_to_degrade: String = Input::with_theme(theme)
.with_prompt("Time to degrade")
.default("5m".to_string())
.interact_text()?;
let noise: f64 = Input::with_theme(theme)
.with_prompt("Noise amplitude")
.default(1.0)
.interact_text()?;
vec![
("baseline".to_string(), ParamValue::Float(baseline)),
("ceiling".to_string(), ParamValue::Float(ceiling)),
(
"time_to_degrade".to_string(),
ParamValue::String(time_to_degrade),
),
("noise".to_string(), ParamValue::Float(noise)),
]
}
_ => vec![],
};
Ok(params)
}
fn severity_preset_weights(preset: &str) -> Option<Vec<(String, f64)>> {
match preset {
"mostly_info" => Some(vec![
("info".to_string(), 0.7),
("warn".to_string(), 0.2),
("error".to_string(), 0.1),
]),
"balanced" => Some(vec![
("info".to_string(), 0.4),
("warn".to_string(), 0.3),
("error".to_string(), 0.2),
("debug".to_string(), 0.1),
]),
"error_heavy" => Some(vec![
("error".to_string(), 0.6),
("warn".to_string(), 0.3),
("info".to_string(), 0.1),
]),
_ => None,
}
}
fn prompt_severity_interactive(theme: &ColorfulTheme) -> Result<Vec<(String, f64)>, io::Error> {
let severity_items = &[
"Mostly info info 70% warn 20% error 10%",
"Balanced info 40% warn 30% error 20% debug 10%",
"Error-heavy error 60% warn 30% info 10%",
];
let severity_idx = Select::with_theme(theme)
.with_prompt("Severity distribution")
.items(severity_items)
.default(0)
.interact()?;
let weights = match severity_idx {
0 => vec![
("info".to_string(), 0.7),
("warn".to_string(), 0.2),
("error".to_string(), 0.1),
],
1 => vec![
("info".to_string(), 0.4),
("warn".to_string(), 0.3),
("error".to_string(), 0.2),
("debug".to_string(), 0.1),
],
2 => vec![
("error".to_string(), 0.6),
("warn".to_string(), 0.3),
("info".to_string(), 0.1),
],
_ => unreachable!(),
};
Ok(weights)
}
fn prompt_labels(
theme: &ColorfulTheme,
prefilled: &BTreeMap<String, String>,
) -> Result<BTreeMap<String, String>, io::Error> {
if !prefilled.is_empty() {
return Ok(prefilled.clone());
}
if !io::stdin().is_terminal() {
return Ok(BTreeMap::new());
}
let mut labels = BTreeMap::new();
loop {
let input: String = Input::with_theme(theme)
.with_prompt("Add a label (key=value, empty to finish)")
.default(String::new())
.allow_empty(true)
.interact_text()?;
if input.is_empty() {
break;
}
if let Some(pos) = input.find('=') {
let key = input[..pos].trim().to_string();
let value = input[pos + 1..].trim().to_string();
if !key.is_empty() {
labels.insert(key, value);
let summary = format_label_summary(&labels);
eprintln!(" {}", summary.if_supports_color(Stderr, |t| t.dimmed()));
}
} else {
eprintln!(" Labels must be in key=value format. Try again.");
}
}
Ok(labels)
}
fn format_label_summary(labels: &BTreeMap<String, String>) -> String {
let pairs: Vec<String> = labels.iter().map(|(k, v)| format!("{k}={v}")).collect();
format!("Labels: {}", pairs.join(", "))
}
fn prompt_rate(theme: &ColorfulTheme, prefill: &Prefill) -> Result<f64, io::Error> {
if let Some(val) = prefill.rate {
if val > 0.0 {
return Ok(val);
}
let warning = format!(
"Invalid --rate value '{}': must be strictly positive. Using default 1.0.",
val
);
eprintln!(" {}", warning.if_supports_color(Stderr, |t| t.dimmed()));
if !std::io::stdin().is_terminal() {
return Ok(1.0);
}
}
let rate: f64 = Input::with_theme(theme)
.with_prompt("Events per second (rate)")
.default(1.0)
.interact_text()?;
Ok(rate)
}
fn prompt_duration(theme: &ColorfulTheme, prefill: &Prefill) -> Result<String, io::Error> {
if let Some(ref val) = prefill.duration {
if sonda_core::config::validate::parse_duration(val).is_ok() {
return Ok(val.clone());
}
let warning = format!(
"Invalid --duration value '{}': expected format like 30s, 5m, 1h. Using default 60s.",
val
);
eprintln!(" {}", warning.if_supports_color(Stderr, |t| t.dimmed()));
if !std::io::stdin().is_terminal() {
return Ok("60s".to_string());
}
}
let duration: String = Input::with_theme(theme)
.with_prompt("Duration (e.g., 30s, 5m, 1h)")
.default("60s".to_string())
.interact_text()?;
Ok(duration)
}
fn prompt_encoder(
theme: &ColorfulTheme,
options: &[&str],
prefill: &Prefill,
) -> Result<String, io::Error> {
if let Some(ref val) = prefill.encoder {
if ALL_ENCODERS.contains(&val.as_str()) {
return Ok(val.clone());
}
print_invalid_prefill("encoder", val, ALL_ENCODERS);
}
let selection = Select::with_theme(theme)
.with_prompt("Output encoding format")
.items(options)
.default(0)
.interact()?;
Ok(options[selection].to_string())
}
fn prompt_sink(theme: &ColorfulTheme, prefill: &Prefill) -> Result<SinkPromptResult, io::Error> {
if let Some(ref val) = prefill.sink {
if ALL_SINKS.contains(&val.as_str()) {
let sink = val.clone();
let endpoint = prefill.endpoint.clone();
let mut extra = BTreeMap::new();
match sink.as_str() {
"kafka" => {
let brokers = if let Some(ref b) = prefill.kafka_brokers {
b.clone()
} else {
Input::with_theme(theme)
.with_prompt("Kafka broker(s) (host:port)")
.default("localhost:9092".to_string())
.interact_text()?
};
let topic = if let Some(ref t) = prefill.kafka_topic {
t.clone()
} else {
Input::with_theme(theme)
.with_prompt("Kafka topic")
.default("sonda-events".to_string())
.interact_text()?
};
extra.insert("brokers".to_string(), brokers);
extra.insert("topic".to_string(), topic);
}
"otlp_grpc" => {
if let Some(ref st) = prefill.otlp_signal_type {
extra.insert("signal_type".to_string(), st.clone());
} else {
let signal_items = &["metrics", "logs"];
let signal_idx = Select::with_theme(theme)
.with_prompt("OTLP signal type")
.items(signal_items)
.default(0)
.interact()?;
extra.insert(
"signal_type".to_string(),
signal_items[signal_idx].to_string(),
);
}
}
_ => {}
}
return Ok((sink, endpoint, extra));
}
print_invalid_prefill("sink", val, ALL_SINKS);
}
let sink_idx = Select::with_theme(theme)
.with_prompt("Where should output be sent?")
.items(SINKS)
.default(0)
.interact()?;
let selected = SINKS[sink_idx];
if selected == "Advanced..." {
return prompt_advanced_sink(theme);
}
let sink = selected.to_string();
let extra = BTreeMap::new();
let endpoint = match sink.as_str() {
"http_push" => {
let url: String = Input::with_theme(theme)
.with_prompt("Endpoint URL")
.default("http://localhost:9090/api/v1/write".to_string())
.interact_text()?;
Some(url)
}
"file" => {
let path: String = Input::with_theme(theme)
.with_prompt("Output file path")
.default("/tmp/sonda-output.txt".to_string())
.interact_text()?;
Some(path)
}
_ => None,
};
Ok((sink, endpoint, extra))
}
fn prompt_advanced_sink(theme: &ColorfulTheme) -> Result<SinkPromptResult, io::Error> {
eprintln!(
" {}",
"Advanced sinks may require feature flags at compile time."
.if_supports_color(Stderr, |t| t.dimmed()),
);
let adv_idx = Select::with_theme(theme)
.with_prompt("Which advanced sink?")
.items(ADVANCED_SINK_DESCRIPTIONS)
.default(0)
.interact()?;
let sink = ADVANCED_SINKS[adv_idx].to_string();
let mut extra = BTreeMap::new();
let endpoint = match sink.as_str() {
"remote_write" => {
let url: String = Input::with_theme(theme)
.with_prompt("Remote write endpoint URL")
.default("http://localhost:8428/api/v1/write".to_string())
.interact_text()?;
Some(url)
}
"loki" => {
let url: String = Input::with_theme(theme)
.with_prompt("Loki base URL")
.default("http://localhost:3100".to_string())
.interact_text()?;
Some(url)
}
"otlp_grpc" => {
let endpoint_url: String = Input::with_theme(theme)
.with_prompt("OTLP gRPC endpoint")
.default("http://localhost:4317".to_string())
.interact_text()?;
let signal_items = &["metrics", "logs"];
let signal_idx = Select::with_theme(theme)
.with_prompt("OTLP signal type")
.items(signal_items)
.default(0)
.interact()?;
extra.insert(
"signal_type".to_string(),
signal_items[signal_idx].to_string(),
);
Some(endpoint_url)
}
"kafka" => {
let brokers: String = Input::with_theme(theme)
.with_prompt("Kafka broker(s) (host:port)")
.default("localhost:9092".to_string())
.interact_text()?;
let topic: String = Input::with_theme(theme)
.with_prompt("Kafka topic")
.default("sonda-events".to_string())
.interact_text()?;
extra.insert("brokers".to_string(), brokers);
extra.insert("topic".to_string(), topic);
None
}
"tcp" => {
let address: String = Input::with_theme(theme)
.with_prompt("TCP address (host:port)")
.default("127.0.0.1:9999".to_string())
.interact_text()?;
Some(address)
}
"udp" => {
let address: String = Input::with_theme(theme)
.with_prompt("UDP address (host:port)")
.default("127.0.0.1:9999".to_string())
.interact_text()?;
Some(address)
}
_ => None,
};
Ok((sink, endpoint, extra))
}
fn enforce_encoder_for_sink(user_encoder: String, sink: &str) -> String {
if let Some(required) = required_encoder_for_sink(sink) {
if user_encoder != required {
let note =
format!("Encoder overridden to '{required}' (required by the {sink} sink).",);
eprintln!(" {}", note.if_supports_color(Stderr, |t| t.dimmed()));
return required.to_string();
}
}
user_encoder
}
pub fn prompt_run_now(theme: &ColorfulTheme) -> Result<bool, io::Error> {
let run_now = Confirm::with_theme(theme)
.with_prompt("Run it now?")
.default(true)
.interact()?;
Ok(run_now)
}
pub fn prompt_output_path(theme: &ColorfulTheme, suggested: &str) -> Result<String, io::Error> {
let default_path = format!("./scenarios/{suggested}");
let path: String = Input::with_theme(theme)
.with_prompt("Output file path")
.default(default_path)
.interact_text()?;
Ok(path)
}
fn print_invalid_prefill(field: &str, value: &str, valid: &[&str]) {
let warning = format!(
"Invalid --{field} value '{value}', valid options: {}. Falling through to prompt.",
valid.join(", ")
);
eprintln!(" {}", warning.if_supports_color(Stderr, |t| t.dimmed()));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn situations_list_has_all_aliases() {
assert!(SITUATIONS.contains(&"steady"));
assert!(SITUATIONS.contains(&"spike_event"));
assert!(SITUATIONS.contains(&"flap"));
assert!(SITUATIONS.contains(&"leak"));
assert!(SITUATIONS.contains(&"saturation"));
assert!(SITUATIONS.contains(&"degradation"));
}
#[test]
fn situations_and_descriptions_have_same_length() {
assert_eq!(
SITUATIONS.len(),
SITUATION_DESCRIPTIONS.len(),
"each situation must have a description"
);
}
#[test]
fn descriptions_contain_their_situation_name() {
for (i, &situation) in SITUATIONS.iter().enumerate() {
assert!(
SITUATION_DESCRIPTIONS[i].contains(situation),
"description for '{situation}' must contain the situation name"
);
}
}
#[test]
fn metric_encoders_include_prometheus_text() {
assert!(METRIC_ENCODERS.contains(&"prometheus_text"));
}
#[test]
fn log_encoders_include_json_lines() {
assert!(LOG_ENCODERS.contains(&"json_lines"));
}
#[test]
fn sinks_include_stdout() {
assert!(SINKS.contains(&"stdout"));
}
#[test]
fn domains_include_infrastructure() {
assert!(DOMAINS.contains(&"infrastructure"));
}
#[test]
fn format_label_summary_single_label() {
let mut labels = BTreeMap::new();
labels.insert("instance".to_string(), "web-01".to_string());
assert_eq!(format_label_summary(&labels), "Labels: instance=web-01");
}
#[test]
fn format_label_summary_multiple_labels_sorted() {
let mut labels = BTreeMap::new();
labels.insert("job".to_string(), "node_exporter".to_string());
labels.insert("instance".to_string(), "web-01".to_string());
assert_eq!(
format_label_summary(&labels),
"Labels: instance=web-01, job=node_exporter"
);
}
#[test]
fn format_label_summary_empty() {
let labels = BTreeMap::new();
assert_eq!(format_label_summary(&labels), "Labels: ");
}
#[test]
fn section_width_is_reasonable() {
assert!(
SECTION_WIDTH >= 30,
"section width must be wide enough for readable headers"
);
}
#[test]
fn advanced_sinks_list_has_expected_entries() {
assert!(ADVANCED_SINKS.contains(&"remote_write"));
assert!(ADVANCED_SINKS.contains(&"loki"));
assert!(ADVANCED_SINKS.contains(&"otlp_grpc"));
assert!(ADVANCED_SINKS.contains(&"kafka"));
assert!(ADVANCED_SINKS.contains(&"tcp"));
assert!(ADVANCED_SINKS.contains(&"udp"));
}
#[test]
fn advanced_sinks_and_descriptions_have_same_length() {
assert_eq!(
ADVANCED_SINKS.len(),
ADVANCED_SINK_DESCRIPTIONS.len(),
"each advanced sink must have a description"
);
}
#[test]
fn advanced_sink_descriptions_contain_their_name() {
for (i, &sink) in ADVANCED_SINKS.iter().enumerate() {
assert!(
ADVANCED_SINK_DESCRIPTIONS[i].contains(sink),
"description for '{sink}' must contain the sink name"
);
}
}
#[test]
fn primary_sinks_include_advanced_option() {
assert!(
SINKS.contains(&"Advanced..."),
"primary sink menu must include 'Advanced...' option"
);
}
#[test]
fn primary_sinks_preserve_original_entries() {
assert!(SINKS.contains(&"stdout"));
assert!(SINKS.contains(&"http_push"));
assert!(SINKS.contains(&"file"));
}
#[test]
fn advanced_sinks_do_not_overlap_with_primary() {
let primary: Vec<&&str> = SINKS.iter().filter(|s| **s != "Advanced...").collect();
for &adv in ADVANCED_SINKS {
assert!(
!primary.contains(&&adv),
"advanced sink '{adv}' must not appear in primary menu"
);
}
}
#[test]
fn enforce_encoder_overrides_for_remote_write_sink() {
let result = enforce_encoder_for_sink("prometheus_text".to_string(), "remote_write");
assert_eq!(result, "remote_write");
}
#[test]
fn enforce_encoder_overrides_for_otlp_grpc_sink() {
let result = enforce_encoder_for_sink("json_lines".to_string(), "otlp_grpc");
assert_eq!(result, "otlp");
}
#[test]
fn enforce_encoder_no_op_when_already_correct_remote_write() {
let result = enforce_encoder_for_sink("remote_write".to_string(), "remote_write");
assert_eq!(result, "remote_write");
}
#[test]
fn enforce_encoder_no_op_when_already_correct_otlp() {
let result = enforce_encoder_for_sink("otlp".to_string(), "otlp_grpc");
assert_eq!(result, "otlp");
}
#[test]
fn enforce_encoder_no_op_for_stdout_sink() {
let result = enforce_encoder_for_sink("prometheus_text".to_string(), "stdout");
assert_eq!(result, "prometheus_text");
}
#[test]
fn enforce_encoder_no_op_for_http_push_sink() {
let result = enforce_encoder_for_sink("influx_lp".to_string(), "http_push");
assert_eq!(result, "influx_lp");
}
#[test]
fn enforce_encoder_no_op_for_file_sink() {
let result = enforce_encoder_for_sink("json_lines".to_string(), "file");
assert_eq!(result, "json_lines");
}
#[test]
fn enforce_encoder_no_op_for_tcp_sink() {
let result = enforce_encoder_for_sink("prometheus_text".to_string(), "tcp");
assert_eq!(result, "prometheus_text");
}
#[test]
fn enforce_encoder_no_op_for_loki_sink() {
let result = enforce_encoder_for_sink("json_lines".to_string(), "loki");
assert_eq!(result, "json_lines");
}
#[test]
fn enforce_encoder_no_op_for_kafka_sink() {
let result = enforce_encoder_for_sink("json_lines".to_string(), "kafka");
assert_eq!(result, "json_lines");
}
#[test]
fn prefill_default_has_all_none_fields() {
let pf = Prefill::default();
assert!(pf.signal_type.is_none());
assert!(pf.domain.is_none());
assert!(pf.situation.is_none());
assert!(pf.metric.is_none());
assert!(pf.pack.is_none());
assert!(pf.rate.is_none());
assert!(pf.duration.is_none());
assert!(pf.encoder.is_none());
assert!(pf.sink.is_none());
assert!(pf.endpoint.is_none());
assert!(pf.labels.is_empty());
assert!(pf.message_template.is_none());
assert!(pf.severity.is_none());
assert!(pf.kafka_brokers.is_none());
assert!(pf.kafka_topic.is_none());
assert!(pf.otlp_signal_type.is_none());
}
#[test]
fn prefill_clone_preserves_values() {
let mut pf = Prefill::default();
pf.signal_type = Some("metrics".to_string());
pf.rate = Some(5.0);
pf.labels.insert("env".to_string(), "staging".to_string());
let clone = pf.clone();
assert_eq!(clone.signal_type.as_deref(), Some("metrics"));
assert_eq!(clone.rate, Some(5.0));
assert_eq!(clone.labels.get("env").map(String::as_str), Some("staging"));
}
#[test]
fn all_sinks_contains_primary_sinks() {
for &s in SINKS {
if s == "Advanced..." {
continue;
}
assert!(
ALL_SINKS.contains(&s),
"primary sink '{s}' must be in ALL_SINKS"
);
}
}
#[test]
fn all_sinks_contains_advanced_sinks() {
for &s in ADVANCED_SINKS {
assert!(
ALL_SINKS.contains(&s),
"advanced sink '{s}' must be in ALL_SINKS"
);
}
}
#[test]
fn all_encoders_contains_metric_encoders() {
for &e in METRIC_ENCODERS {
assert!(
ALL_ENCODERS.contains(&e),
"metric encoder '{e}' must be in ALL_ENCODERS"
);
}
}
#[test]
fn all_encoders_contains_log_encoders() {
for &e in LOG_ENCODERS {
assert!(
ALL_ENCODERS.contains(&e),
"log encoder '{e}' must be in ALL_ENCODERS"
);
}
}
#[test]
fn default_situation_params_steady_has_three_params() {
let params = default_situation_params("steady");
assert_eq!(params.len(), 3);
assert_eq!(params[0].0, "center");
assert_eq!(params[1].0, "amplitude");
assert_eq!(params[2].0, "period");
}
#[test]
fn default_situation_params_spike_event_has_four_params() {
let params = default_situation_params("spike_event");
assert_eq!(params.len(), 4);
assert_eq!(params[0].0, "baseline");
assert_eq!(params[1].0, "spike_height");
assert_eq!(params[2].0, "spike_duration");
assert_eq!(params[3].0, "spike_interval");
}
#[test]
fn default_situation_params_flap_has_four_params() {
let params = default_situation_params("flap");
assert_eq!(params.len(), 4);
assert_eq!(params[0].0, "up_value");
assert_eq!(params[1].0, "down_value");
assert_eq!(params[2].0, "up_duration");
assert_eq!(params[3].0, "down_duration");
}
#[test]
fn default_situation_params_leak_has_three_params() {
let params = default_situation_params("leak");
assert_eq!(params.len(), 3);
assert_eq!(params[0].0, "baseline");
assert_eq!(params[1].0, "ceiling");
assert_eq!(params[2].0, "time_to_ceiling");
}
#[test]
fn default_situation_params_saturation_has_three_params() {
let params = default_situation_params("saturation");
assert_eq!(params.len(), 3);
assert_eq!(params[0].0, "baseline");
assert_eq!(params[1].0, "ceiling");
assert_eq!(params[2].0, "time_to_saturate");
}
#[test]
fn default_situation_params_degradation_has_four_params() {
let params = default_situation_params("degradation");
assert_eq!(params.len(), 4);
assert_eq!(params[0].0, "baseline");
assert_eq!(params[1].0, "ceiling");
assert_eq!(params[2].0, "time_to_degrade");
assert_eq!(params[3].0, "noise");
}
#[test]
fn default_situation_params_unknown_returns_empty() {
let params = default_situation_params("nonexistent");
assert!(params.is_empty());
}
#[test]
fn default_situation_params_covers_all_situations() {
for &sit in SITUATIONS {
let params = default_situation_params(sit);
assert!(
!params.is_empty(),
"default_situation_params({sit}) must return non-empty"
);
}
}
#[test]
fn severity_preset_mostly_info_returns_three_weights() {
let weights = severity_preset_weights("mostly_info").expect("should be valid");
assert_eq!(weights.len(), 3);
assert_eq!(weights[0].0, "info");
}
#[test]
fn severity_preset_balanced_returns_four_weights() {
let weights = severity_preset_weights("balanced").expect("should be valid");
assert_eq!(weights.len(), 4);
}
#[test]
fn severity_preset_error_heavy_returns_three_weights() {
let weights = severity_preset_weights("error_heavy").expect("should be valid");
assert_eq!(weights.len(), 3);
assert_eq!(weights[0].0, "error");
}
#[test]
fn severity_preset_invalid_returns_none() {
assert!(severity_preset_weights("unknown_preset").is_none());
}
#[test]
fn prefill_default_has_new_fields_none() {
let pf = Prefill::default();
assert!(pf.message_template.is_none());
assert!(pf.severity.is_none());
assert!(pf.kafka_brokers.is_none());
assert!(pf.kafka_topic.is_none());
assert!(pf.otlp_signal_type.is_none());
}
#[test]
fn prompt_labels_returns_prefilled_labels_immediately() {
let theme = ColorfulTheme::default();
let mut prefilled = BTreeMap::new();
prefilled.insert("env".to_string(), "prod".to_string());
prefilled.insert("region".to_string(), "us-west".to_string());
let result = prompt_labels(&theme, &prefilled).expect("should succeed");
assert_eq!(result.len(), 2);
assert_eq!(result.get("env").map(String::as_str), Some("prod"));
assert_eq!(result.get("region").map(String::as_str), Some("us-west"));
}
#[test]
fn prompt_labels_with_empty_prefill_returns_empty_in_non_tty() {
let theme = ColorfulTheme::default();
let prefilled = BTreeMap::new();
if !std::io::stdin().is_terminal() {
let result = prompt_labels(&theme, &prefilled).expect("should succeed");
assert!(
result.is_empty(),
"non-TTY stdin with no prefilled labels must return empty map"
);
}
}
#[test]
fn distribution_models_and_descriptions_have_same_length() {
assert_eq!(
DISTRIBUTION_MODELS.len(),
DISTRIBUTION_MODEL_DESCRIPTIONS.len(),
"each distribution model must have a description"
);
}
#[test]
fn distribution_model_descriptions_contain_their_name() {
for (i, &model) in DISTRIBUTION_MODELS.iter().enumerate() {
assert!(
DISTRIBUTION_MODEL_DESCRIPTIONS[i].contains(model),
"description for '{model}' must contain the model name"
);
}
}
#[test]
fn distribution_models_include_expected_entries() {
assert!(DISTRIBUTION_MODELS.contains(&"normal"));
assert!(DISTRIBUTION_MODELS.contains(&"exponential"));
assert!(DISTRIBUTION_MODELS.contains(&"uniform"));
}
#[test]
fn default_distribution_params_normal_has_mean_and_stddev() {
let params = default_distribution_params("normal");
assert_eq!(params.len(), 2, "normal distribution has two parameters");
assert_eq!(params[0].0, "mean");
assert_eq!(params[0].1, ParamValue::Float(0.1));
assert_eq!(params[1].0, "stddev");
assert_eq!(params[1].1, ParamValue::Float(0.03));
}
#[test]
fn default_distribution_params_exponential_has_rate() {
let params = default_distribution_params("exponential");
assert_eq!(
params.len(),
1,
"exponential distribution has one parameter"
);
assert_eq!(params[0].0, "rate");
assert_eq!(params[0].1, ParamValue::Float(10.0));
}
#[test]
fn default_distribution_params_uniform_has_min_and_max() {
let params = default_distribution_params("uniform");
assert_eq!(params.len(), 2, "uniform distribution has two parameters");
assert_eq!(params[0].0, "min");
assert_eq!(params[0].1, ParamValue::Float(0.0));
assert_eq!(params[1].0, "max");
assert_eq!(params[1].1, ParamValue::Float(1.0));
}
#[test]
fn default_distribution_params_unknown_returns_empty() {
let params = default_distribution_params("unknown_model");
assert!(
params.is_empty(),
"unknown distribution model returns no parameters"
);
}
#[test]
fn default_distribution_params_covers_all_models() {
for &model in DISTRIBUTION_MODELS {
let params = default_distribution_params(model);
assert!(
!params.is_empty(),
"distribution model '{model}' must have default parameters"
);
}
}
}