sonda 1.9.0

CLI for Sonda — synthetic telemetry generator for testing observability pipelines
//! YAML rendering for `sonda new`.

use std::collections::HashMap;
use std::fmt::Write as _;

use sonda_core::analysis::pattern::Pattern;

use super::csv_reader::ColumnMeta;
use super::prompts::{Answers, SignalKind, SinkKind};

pub enum ParamValue {
    Float(f64),
    String(String),
}

pub struct ScenarioSpec {
    pub name: String,
    pub generator_type: String,
    pub generator_params: Vec<(String, ParamValue)>,
    pub labels: HashMap<String, String>,
}

pub fn minimal_template() -> String {
    "version: 2\nkind: runnable\n\
defaults:\n  rate: 1\n  duration: 60s\n  encoder:\n    type: prometheus_text\n  sink:\n    type: stdout\n\n\
scenarios:\n  - id: example\n    signal_type: metrics\n    name: example_metric\n    generator:\n      type: constant\n      value: 1.0\n"
        .to_string()
}

pub fn spec_from_pattern(
    pattern: &Pattern,
    meta: &ColumnMeta,
    rate: f64,
    duration: &str,
) -> ScenarioSpec {
    let name = meta
        .metric_name
        .clone()
        .unwrap_or_else(|| format!("column_{}", meta.index));
    let labels = meta.labels.clone();

    let (generator_type, params): (&str, Vec<(&str, ParamValue)>) = match pattern {
        Pattern::Steady { center, amplitude } => (
            "steady",
            vec![
                ("center", ParamValue::Float(*center)),
                ("amplitude", ParamValue::Float(*amplitude)),
                ("period", ParamValue::String("60s".to_string())),
            ],
        ),
        Pattern::Spike {
            baseline,
            spike_height,
            spike_duration_points,
            spike_interval_points,
        } => (
            "spike_event",
            vec![
                ("baseline", ParamValue::Float(*baseline)),
                ("spike_height", ParamValue::Float(*spike_height)),
                (
                    "spike_duration",
                    ParamValue::String(format_duration(points_to_secs(
                        *spike_duration_points,
                        rate,
                    ))),
                ),
                (
                    "spike_interval",
                    ParamValue::String(format_duration(points_to_secs(
                        *spike_interval_points,
                        rate,
                    ))),
                ),
            ],
        ),
        Pattern::Climb { baseline, ceiling } => (
            "leak",
            vec![
                ("baseline", ParamValue::Float(*baseline)),
                ("ceiling", ParamValue::Float(*ceiling)),
                ("time_to_ceiling", ParamValue::String(duration.to_string())),
            ],
        ),
        Pattern::Sawtooth {
            min,
            max,
            period_points,
        } => (
            "sawtooth",
            vec![
                ("min", ParamValue::Float(*min)),
                ("max", ParamValue::Float(*max)),
                (
                    "period_secs",
                    ParamValue::Float(points_to_secs(*period_points, rate)),
                ),
            ],
        ),
        Pattern::Flap {
            up_value,
            down_value,
            up_duration_points,
            down_duration_points,
        } => (
            "flap",
            vec![
                ("up_value", ParamValue::Float(*up_value)),
                ("down_value", ParamValue::Float(*down_value)),
                (
                    "up_duration",
                    ParamValue::String(format_duration(points_to_secs(*up_duration_points, rate))),
                ),
                (
                    "down_duration",
                    ParamValue::String(format_duration(points_to_secs(
                        *down_duration_points,
                        rate,
                    ))),
                ),
            ],
        ),
        Pattern::Step { start, step_size } => (
            "step",
            vec![
                ("start", ParamValue::Float(*start)),
                ("step_size", ParamValue::Float(*step_size)),
            ],
        ),
    };

    ScenarioSpec {
        name,
        generator_type: generator_type.to_string(),
        generator_params: params
            .into_iter()
            .map(|(k, v)| (k.to_string(), v))
            .collect(),
        labels,
    }
}

pub fn render_v2(specs: &[ScenarioSpec], rate: f64, duration: &str) -> String {
    let mut out = String::new();
    write_header(&mut out, rate, duration, "prometheus_text", "stdout", None);
    out.push_str("scenarios:\n");
    for spec in specs {
        let _ = writeln!(out, "  - id: {}", spec.name);
        out.push_str("    signal_type: metrics\n");
        let _ = writeln!(out, "    name: {}", spec.name);
        out.push_str("    generator:\n");
        let _ = writeln!(out, "      type: {}", spec.generator_type);
        for (k, v) in &spec.generator_params {
            write_param(&mut out, "      ", k, v);
        }
        if !spec.labels.is_empty() {
            out.push_str("    labels:\n");
            let mut sorted: Vec<_> = spec.labels.iter().collect();
            sorted.sort_by_key(|(k, _)| k.as_str());
            for (k, v) in sorted {
                let _ = writeln!(out, "      {k}: {v}");
            }
        }
        out.push('\n');
    }
    out
}

pub fn render_from_answers(answers: &Answers) -> String {
    let mut out = String::new();
    let encoder = match answers.signal {
        SignalKind::Logs => "json_lines",
        _ => "prometheus_text",
    };
    let (sink_kind, sink_endpoint) = match &answers.sink {
        SinkKind::Stdout => ("stdout", None),
        SinkKind::File { path } => ("file", Some(("path", path.as_str()))),
        SinkKind::HttpPush { endpoint } => ("http_push", Some(("endpoint", endpoint.as_str()))),
    };
    write_header(
        &mut out,
        answers.rate,
        &answers.duration,
        encoder,
        sink_kind,
        sink_endpoint,
    );
    out.push_str("scenarios:\n");
    let _ = writeln!(out, "  - id: {}", answers.id);
    let _ = writeln!(out, "    signal_type: {}", answers.signal.as_str());
    match answers.signal {
        SignalKind::Metrics => {
            let _ = writeln!(out, "    name: {}", answers.id);
            out.push_str("    generator:\n");
            let _ = writeln!(out, "      type: {}", answers.generator_type);
            if answers.generator_type == "constant" {
                out.push_str("      value: 1.0\n");
            }
        }
        SignalKind::Logs => {
            let _ = writeln!(out, "    name: {}", answers.id);
            out.push_str("    log_generator:\n      type: template\n      templates:\n        - message: \"example log line\"\n          severity: info\n");
        }
        SignalKind::Histogram => {
            let _ = writeln!(out, "    name: {}", answers.id);
            out.push_str(
                "    distribution:\n      type: uniform\n      min: 0.0\n      max: 1.0\n",
            );
            out.push_str("    observations_per_tick: 10\n    buckets: [0.1, 0.5, 1.0]\n");
        }
        SignalKind::Summary => {
            let _ = writeln!(out, "    name: {}", answers.id);
            out.push_str(
                "    distribution:\n      type: uniform\n      min: 0.0\n      max: 1.0\n",
            );
            out.push_str("    observations_per_tick: 10\n    quantiles: [0.5, 0.9, 0.99]\n");
        }
    }
    out
}

fn write_header(
    out: &mut String,
    rate: f64,
    duration: &str,
    encoder: &str,
    sink_kind: &str,
    sink_extra: Option<(&str, &str)>,
) {
    out.push_str("version: 2\nkind: runnable\n\ndefaults:\n");
    let _ = writeln!(out, "  rate: {}", format_rate(rate));
    let _ = writeln!(out, "  duration: {duration}");
    out.push_str("  encoder:\n");
    let _ = writeln!(out, "    type: {encoder}");
    out.push_str("  sink:\n");
    let _ = writeln!(out, "    type: {sink_kind}");
    if let Some((k, v)) = sink_extra {
        let _ = writeln!(out, "    {k}: {v}");
    }
    out.push('\n');
}

fn write_param(out: &mut String, indent: &str, key: &str, value: &ParamValue) {
    match value {
        ParamValue::Float(v) => {
            let _ = writeln!(out, "{indent}{key}: {}", format_float(*v));
        }
        ParamValue::String(s) => {
            let _ = writeln!(out, "{indent}{key}: \"{s}\"");
        }
    }
}

fn points_to_secs(points: usize, rate: f64) -> f64 {
    if rate <= 0.0 {
        points as f64
    } else {
        points as f64 / rate
    }
}

fn format_duration(secs: f64) -> String {
    if secs <= 0.0 {
        return "1s".to_string();
    }
    let rounded = (secs * 10.0).round() / 10.0;
    if rounded >= 60.0 && (rounded % 60.0).abs() < 0.01 {
        format!("{}m", (rounded / 60.0).round() as u64)
    } else if (rounded - rounded.round()).abs() < 0.01 {
        format!("{}s", rounded.round() as u64)
    } else {
        format!("{rounded:.1}s")
    }
}

fn format_float(v: f64) -> String {
    if v == v.trunc() && v.is_finite() {
        format!("{v:.1}")
    } else {
        format!("{v}")
    }
}

fn format_rate(v: f64) -> String {
    if v == v.trunc() && v.is_finite() {
        format!("{}", v as i64)
    } else {
        format!("{v}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn minimal_template_compiles_via_v2_pipeline() {
        let yaml = minimal_template();
        let resolver = sonda_core::compiler::expand::InMemoryPackResolver::new();
        sonda_core::compile_scenario_file(&yaml, &resolver).expect("must compile");
    }

    #[test]
    fn spec_from_steady_pattern_uses_alias() {
        let meta = ColumnMeta {
            index: 1,
            metric_name: Some("cpu".to_string()),
            labels: HashMap::new(),
        };
        let spec = spec_from_pattern(
            &Pattern::Steady {
                center: 50.0,
                amplitude: 10.0,
            },
            &meta,
            1.0,
            "60s",
        );
        assert_eq!(spec.generator_type, "steady");
        assert_eq!(spec.name, "cpu");
    }

    #[test]
    fn spec_from_spike_pattern_uses_alias() {
        let meta = ColumnMeta {
            index: 1,
            metric_name: Some("err".to_string()),
            labels: HashMap::new(),
        };
        let spec = spec_from_pattern(
            &Pattern::Spike {
                baseline: 10.0,
                spike_height: 90.0,
                spike_duration_points: 5,
                spike_interval_points: 30,
            },
            &meta,
            1.0,
            "60s",
        );
        assert_eq!(spec.generator_type, "spike_event");
    }

    #[test]
    fn render_v2_emits_v2_header_and_runnable_kind() {
        let spec = ScenarioSpec {
            name: "cpu".to_string(),
            generator_type: "steady".to_string(),
            generator_params: vec![("center".to_string(), ParamValue::Float(50.0))],
            labels: HashMap::new(),
        };
        let yaml = render_v2(&[spec], 1.0, "60s");
        assert!(yaml.starts_with("version: 2\n"));
        assert!(yaml.contains("kind: runnable"));
        assert!(yaml.contains("id: cpu"));
    }
}