use std::collections::HashMap;
use super::TopError;
use super::config::{TopConfig, TopOutputMode};
use super::metrics::{self, MetricSample, MetricType};
pub fn run_oneshot(config: &TopConfig) -> Result<(), TopError> {
let body = metrics::fetch_metrics_http(&config.metrics_url)?;
let scrape = metrics::parse_prometheus(&body);
let samples = filter_samples(&scrape.samples, config.filter.as_deref());
match config.output_mode {
TopOutputMode::Once => print_table(&samples, &config.metrics_url),
TopOutputMode::Json => print_json(&samples),
TopOutputMode::Tui => unreachable!("oneshot called with TUI mode"),
}
Ok(())
}
fn filter_samples<'a>(samples: &'a [MetricSample], filter: Option<&str>) -> Vec<&'a MetricSample> {
match filter {
Some(pattern) => samples
.iter()
.filter(|s| match_filter(&s.name, pattern))
.collect(),
None => samples.iter().collect(),
}
}
fn match_filter(name: &str, pattern: &str) -> bool {
if !has_regex_chars(pattern) {
return name.contains(pattern);
}
regex_match(name, pattern)
}
fn has_regex_chars(s: &str) -> bool {
s.chars().any(|c| {
matches!(
c,
'.' | '*' | '+' | '?' | '[' | ']' | '(' | ')' | '{' | '}' | '|' | '^' | '$' | '\\'
)
})
}
fn regex_match(name: &str, pattern: &str) -> bool {
let anchored_start = pattern.starts_with('^');
let anchored_end = pattern.ends_with('$');
let pattern = pattern
.strip_prefix('^')
.unwrap_or(pattern)
.strip_suffix('$')
.unwrap_or(pattern);
let parts: Vec<&str> = pattern.split(".*").collect();
if parts.len() == 1 {
if anchored_start && anchored_end {
return name == pattern;
}
if anchored_start {
return name.starts_with(pattern);
}
if anchored_end {
return name.ends_with(pattern);
}
return name.contains(pattern);
}
let mut remaining = name;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
match remaining.find(part) {
Some(pos) => {
if i == 0 && anchored_start && pos != 0 {
return false;
}
remaining = &remaining[pos + part.len()..];
}
None => return false,
}
}
if anchored_end
&& !remaining.is_empty()
&& let Some(last) = parts.iter().rev().find(|p| !p.is_empty())
{
return name.ends_with(last);
}
true
}
fn print_table(samples: &[&MetricSample], url: &str) {
let counters = samples
.iter()
.filter(|s| s.metric_type == MetricType::Counter)
.count();
let gauges = samples
.iter()
.filter(|s| s.metric_type == MetricType::Gauge)
.count();
let histograms = samples
.iter()
.filter(|s| s.metric_type == MetricType::Histogram)
.count();
eprintln!(
"{} metrics ({counters} counters, {gauges} gauges, {histograms} histograms) from {url}",
samples.len()
);
println!("NAME\tTYPE\tVALUE\tLABELS");
for sample in samples {
let labels = format_labels_tsv(&sample.labels);
println!(
"{}\t{}\t{}\t{}",
sample.name,
sample.metric_type.as_str(),
format_value(sample),
labels
);
}
}
fn format_labels_tsv(labels: &HashMap<String, String>) -> String {
if labels.is_empty() {
return String::new();
}
let mut pairs: Vec<String> = labels.iter().map(|(k, v)| format!("{k}={v}")).collect();
pairs.sort();
pairs.join(",")
}
fn print_json(samples: &[&MetricSample]) {
println!("[");
for (i, sample) in samples.iter().enumerate() {
let comma = if i + 1 < samples.len() { "," } else { "" };
let labels_json = if sample.labels.is_empty() {
"{}".to_string()
} else {
let pairs: Vec<String> = sample
.labels
.iter()
.map(|(k, v)| format!(" \"{k}\": \"{v}\""))
.collect();
format!("{{\n{}\n }}", pairs.join(",\n"))
};
let value_str = format_value_json(sample.value);
println!(" {{");
println!(" \"name\": \"{}\",", sample.name);
println!(" \"type\": \"{}\",", sample.metric_type.as_str());
println!(" \"value\": {value_str},");
println!(" \"labels\": {labels_json}");
println!(" }}{comma}");
}
println!("]");
}
fn format_value(sample: &MetricSample) -> String {
let v = sample.value;
if v.is_nan() {
return "NaN".to_string();
}
if v.is_infinite() {
return if v.is_sign_positive() { "+Inf" } else { "-Inf" }.to_string();
}
if v.fract() == 0.0 && v.abs() < 1e15 {
#[allow(clippy::cast_possible_truncation)]
return format!("{}", v as i64);
}
if v.abs() < 1.0 {
return format!("{v:.6}");
}
format!("{v:.2}")
}
fn format_value_json(v: f64) -> String {
if v.is_nan() {
return "\"NaN\"".to_string();
}
if v.is_infinite() {
return if v.is_sign_positive() {
"\"+Inf\""
} else {
"\"-Inf\""
}
.to_string();
}
if v.fract() == 0.0 && v.abs() < 1e15 {
#[allow(clippy::cast_possible_truncation)]
return format!("{}", v as i64);
}
format!("{v}")
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn make_sample(name: &str, value: f64, mt: MetricType) -> MetricSample {
MetricSample {
name: name.to_string(),
labels: HashMap::new(),
value,
metric_type: mt,
}
}
fn make_sample_with_labels(
name: &str,
value: f64,
mt: MetricType,
labels: Vec<(&str, &str)>,
) -> MetricSample {
MetricSample {
name: name.to_string(),
labels: labels
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
value,
metric_type: mt,
}
}
#[test]
fn test_filter_no_pattern() {
let samples = vec![
make_sample("cpu_total", 1.0, MetricType::Counter),
make_sample("mem_bytes", 2.0, MetricType::Gauge),
];
let filtered = filter_samples(&samples, None);
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filter_plain_substring() {
let samples = vec![
make_sample("loader_kafka_lag", 10.0, MetricType::Gauge),
make_sample("loader_buffer_rows", 20.0, MetricType::Gauge),
make_sample("loader_kafka_offsets", 30.0, MetricType::Counter),
];
let filtered = filter_samples(&samples, Some("kafka"));
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].name, "loader_kafka_lag");
assert_eq!(filtered[1].name, "loader_kafka_offsets");
}
#[test]
fn test_filter_wildcard_pattern() {
let samples = vec![
make_sample("loader_buffer_rows", 10.0, MetricType::Gauge),
make_sample("loader_buffer_bytes", 20.0, MetricType::Gauge),
make_sample("loader_insert_total", 30.0, MetricType::Counter),
];
let filtered = filter_samples(&samples, Some("buffer.*rows"));
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "loader_buffer_rows");
}
#[test]
fn test_filter_no_match() {
let samples = vec![make_sample("cpu_total", 1.0, MetricType::Counter)];
let filtered = filter_samples(&samples, Some("nonexistent"));
assert!(filtered.is_empty());
}
#[test]
fn test_format_value_integer() {
let sample = make_sample("test", 42.0, MetricType::Counter);
assert_eq!(format_value(&sample), "42");
}
#[test]
#[allow(clippy::approx_constant)]
fn test_format_value_float() {
let sample = make_sample("test", 3.14, MetricType::Gauge);
assert_eq!(format_value(&sample), "3.14");
}
#[test]
fn test_format_value_small() {
let sample = make_sample("test", 0.000_123, MetricType::Gauge);
assert_eq!(format_value(&sample), "0.000123");
}
#[test]
fn test_format_value_json_nan() {
assert_eq!(format_value_json(f64::NAN), "\"NaN\"");
}
#[test]
fn test_format_value_json_inf() {
assert_eq!(format_value_json(f64::INFINITY), "\"+Inf\"");
assert_eq!(format_value_json(f64::NEG_INFINITY), "\"-Inf\"");
}
#[test]
fn test_format_value_json_integer() {
assert_eq!(format_value_json(42.0), "42");
}
#[test]
#[allow(clippy::approx_constant)]
fn test_format_value_json_float() {
assert_eq!(format_value_json(3.14), "3.14");
}
#[test]
fn test_has_regex_chars() {
assert!(!has_regex_chars("kafka_lag"));
assert!(has_regex_chars("kafka.*lag"));
assert!(has_regex_chars("^kafka"));
assert!(has_regex_chars("lag$"));
assert!(has_regex_chars("kafka.lag"));
}
#[test]
fn test_match_filter_simple_contains() {
assert!(match_filter("loader_kafka_lag", "kafka"));
assert!(!match_filter("loader_buffer_rows", "kafka"));
}
#[test]
fn test_match_filter_wildcard() {
assert!(match_filter("loader_kafka_lag", "loader.*lag"));
assert!(!match_filter("loader_kafka_lag", "buffer.*lag"));
}
#[test]
fn test_print_table_with_labels() {
let sample = make_sample_with_labels(
"kafka_lag",
42.0,
MetricType::Gauge,
vec![("topic", "events"), ("partition", "0")],
);
let samples = vec![&sample];
print_table(&samples, "http://localhost:9090/metrics");
}
#[test]
fn test_format_labels_tsv_empty() {
assert_eq!(format_labels_tsv(&HashMap::new()), "");
}
#[test]
fn test_format_labels_tsv_sorted() {
let mut labels = HashMap::new();
labels.insert("topic".to_string(), "events".to_string());
labels.insert("partition".to_string(), "0".to_string());
let result = format_labels_tsv(&labels);
assert_eq!(result, "partition=0,topic=events");
}
}