use std::io::IsTerminal;
use std::sync::Mutex;
use rgb::RGB8;
use serde_json::Value;
use textplots::{Chart, ColorPlot, Plot, Shape};
use super::metrics::{format_metric_value, format_timestamp};
static COLOR_OVERRIDE_LOCK: Mutex<()> = Mutex::new(());
const CHART_LINE_COLOR: RGB8 = RGB8 {
r: 95,
g: 215,
b: 95,
};
const CHART_HEIGHT_ROWS: u32 = 8;
const CHART_WIDTH_MIN_COLS: u16 = 40;
const CHART_WIDTH_MAX_COLS: u16 = 120;
const FALLBACK_WIDTH_COLS: u16 = 100;
const Y_LABEL_MARGIN_COLS: u16 = 12;
const SPARKLINE_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
const ANSI_DIM: &str = "\x1b[2m";
const ANSI_RESET: &str = "\x1b[0m";
#[derive(Debug, Clone)]
struct MetricSeries {
display_name: String,
unit: String,
points: Vec<(String, Option<f64>)>,
}
pub fn print_metrics_chart(result: &Value, metric_names: &[String]) {
let (w, is_tty) = detect_width_and_tty();
let output = render_metrics_chart(result, metric_names, w, is_tty);
print!("{}", output);
}
pub fn render_metrics_chart(
result: &Value,
metric_names: &[String],
term_width: u16,
color: bool,
) -> String {
let results = match result.get("results").and_then(|v| v.as_array()) {
Some(r) => r,
None => {
return serde_json::to_string_pretty(result).unwrap_or_default() + "\n";
}
};
let granularity = result
.get("granularity")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let series_list = parse_series(results, metric_names);
if series_list.is_empty() {
return "No metric data returned.\n".to_string();
}
let all_empty = series_list
.iter()
.all(|s| s.points.iter().all(|(_, v)| v.is_none()));
if all_empty {
return "No metric data returned.\n".to_string();
}
let chart_cols = chart_columns(term_width);
let narrow = (term_width as i32 - 4) < CHART_WIDTH_MIN_COLS as i32;
let mut out = String::new();
for (i, series) in series_list.iter().enumerate() {
if i > 0 {
out.push('\n');
}
render_block(&mut out, series, &granularity, chart_cols, narrow, color);
}
out
}
fn parse_series(results: &[Value], metric_names: &[String]) -> Vec<MetricSeries> {
let mut list = Vec::with_capacity(results.len());
for (i, metric_result) in results.iter().enumerate() {
let backend_name = metric_result
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let unit = metric_result
.get("unit")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let display_name = metric_names
.get(i)
.cloned()
.unwrap_or_else(|| backend_name.to_string());
let mut points: Vec<(String, Option<f64>)> = Vec::new();
if let Some(values) = metric_result.get("values").and_then(|v| v.as_array()) {
for point in values {
let ts = point
.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let val = point.get("value").and_then(|v| {
if v.is_null() {
return None;
}
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
});
points.push((ts, val));
}
}
list.push(MetricSeries {
display_name,
unit,
points,
});
}
list
}
fn render_block(
out: &mut String,
series: &MetricSeries,
granularity: &str,
chart_cols: u16,
narrow: bool,
color: bool,
) {
let non_null: Vec<(usize, f64)> = series
.points
.iter()
.enumerate()
.filter_map(|(i, (_, v))| v.map(|x| (i, x)))
.collect();
let title = if series.unit.is_empty() {
series.display_name.clone()
} else {
format!("{} ({})", series.display_name, series.unit)
};
let range_label = time_range_label(&series.points);
let header = format!(
"{} gran {} {}",
title,
if granularity.is_empty() {
"auto"
} else {
granularity
},
range_label
);
push_dim_line(out, &header, color);
if non_null.is_empty() {
push_line(out, " last: no data");
return;
}
let min = non_null
.iter()
.map(|(_, v)| *v)
.fold(f64::INFINITY, f64::min);
let max = non_null
.iter()
.map(|(_, v)| *v)
.fold(f64::NEG_INFINITY, f64::max);
let avg = non_null.iter().map(|(_, v)| *v).sum::<f64>() / non_null.len() as f64;
let last = non_null.last().map(|(_, v)| *v).unwrap();
let summary = format!(
"min {} max {} avg {} last {}",
format_metric_value(min),
format_metric_value(max),
format_metric_value(avg),
format_metric_value(last)
);
push_line(out, &summary);
if non_null.len() < 2 {
push_line(out, " <single point>");
return;
}
if narrow {
let spark = sparkline(&non_null, min, max);
push_line(out, &format!(" {}", spark));
return;
}
let chart_body = render_textplot(&non_null, &series.points, chart_cols, max, color);
out.push_str(&chart_body);
if !chart_body.ends_with('\n') {
out.push('\n');
}
}
fn render_textplot(
non_null: &[(usize, f64)],
points: &[(String, Option<f64>)],
chart_cols: u16,
max_val: f64,
color: bool,
) -> String {
let data: Vec<(f32, f32)> = non_null
.iter()
.map(|(i, v)| (*i as f32, *v as f32))
.collect();
let xmin = data.first().map(|p| p.0).unwrap_or(0.0);
let xmax = data.last().map(|p| p.0).unwrap_or(1.0);
let xmax = if xmax <= xmin { xmin + 1.0 } else { xmax };
let ymax = pick_ymax(max_val) as f32;
let canvas_cols = (chart_cols as u32).saturating_sub(Y_LABEL_MARGIN_COLS as u32);
let width_dots = (canvas_cols * 2).max(32);
let height_dots = CHART_HEIGHT_ROWS * 4;
let shape = Shape::Lines(data.as_slice());
let mut chart_owner = Chart::new_with_y_range(width_dots, height_dots, xmin, xmax, 0.0, ymax);
let chart = if color {
chart_owner.linecolorplot(&shape, CHART_LINE_COLOR)
} else {
chart_owner.lineplot(&shape)
};
chart.axis();
chart.borders();
chart.figures();
let raw = {
let _guard = COLOR_OVERRIDE_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev = colored::control::SHOULD_COLORIZE.should_colorize();
colored::control::set_override(color);
let s = format!("{}", chart);
if prev {
colored::control::set_override(true);
} else {
colored::control::unset_override();
}
s
};
let body = strip_last_line(&raw);
let x_axis = format_x_time_axis(points, canvas_cols as usize);
format!("{}{}", body, x_axis)
}
fn pick_ymax(max_val: f64) -> f64 {
let target = max_val.max(0.0) * 1.25;
nice_ceil(target)
}
fn nice_ceil(v: f64) -> f64 {
if !v.is_finite() || v <= 0.0 {
return 1.0;
}
if v <= 1.0 {
return 1.0;
}
let magnitude = 10f64.powf(v.log10().floor());
let fraction = v / magnitude;
let nice = if fraction <= 1.0 {
1.0
} else if fraction <= 2.0 {
2.0
} else if fraction <= 3.0 {
3.0
} else if fraction <= 4.0 {
4.0
} else if fraction <= 5.0 {
5.0
} else if fraction <= 6.0 {
6.0
} else if fraction <= 7.0 {
7.0
} else if fraction <= 8.0 {
8.0
} else {
10.0
};
nice * magnitude
}
fn format_x_time_axis(points: &[(String, Option<f64>)], canvas_cols: usize) -> String {
let first_ts = points.first().map(|(t, _)| t.as_str()).unwrap_or("");
let last_ts = points.last().map(|(t, _)| t.as_str()).unwrap_or("");
if first_ts.is_empty() || last_ts.is_empty() || canvas_cols == 0 {
return String::new();
}
let multi_day = first_ts.len() >= 10 && last_ts.len() >= 10 && first_ts[..10] != last_ts[..10];
let first = format_x_tick(first_ts, multi_day);
let last = format_x_tick(last_ts, multi_day);
let gap = canvas_cols
.saturating_sub(first.chars().count() + last.chars().count())
.max(1);
let spaces: String = std::iter::repeat_n(' ', gap).collect();
format!("{}{}{}\n", first, spaces, last)
}
fn format_x_tick(ts: &str, multi_day: bool) -> String {
if ts.len() < 16 {
return ts.to_string();
}
if multi_day {
format!("{} {}", &ts[5..10], &ts[11..16]) } else {
ts[11..16].to_string() }
}
fn strip_last_line(s: &str) -> String {
let trimmed = s.trim_end_matches('\n');
match trimmed.rfind('\n') {
Some(idx) => format!("{}\n", &trimmed[..idx]),
None => String::new(),
}
}
fn sparkline(non_null: &[(usize, f64)], min: f64, max: f64) -> String {
let span = max - min;
if span.abs() < f64::EPSILON {
return SPARKLINE_CHARS[0].to_string().repeat(non_null.len().max(1));
}
non_null
.iter()
.map(|(_, v)| {
let ratio = ((v - min) / span).clamp(0.0, 1.0);
let idx = ((ratio * (SPARKLINE_CHARS.len() - 1) as f64).round() as usize)
.min(SPARKLINE_CHARS.len() - 1);
SPARKLINE_CHARS[idx]
})
.collect()
}
fn time_range_label(points: &[(String, Option<f64>)]) -> String {
let first = points.first().map(|(t, _)| t.as_str()).unwrap_or("");
let last = points.last().map(|(t, _)| t.as_str()).unwrap_or("");
if first.is_empty() || last.is_empty() {
return String::new();
}
format!("{} -> {}", format_timestamp(first), format_timestamp(last))
}
fn chart_columns(term_width: u16) -> u16 {
let usable = term_width.saturating_sub(4);
usable.clamp(CHART_WIDTH_MIN_COLS, CHART_WIDTH_MAX_COLS)
}
fn detect_width_and_tty() -> (u16, bool) {
let is_tty = std::io::stdout().is_terminal();
let w = if is_tty {
crossterm::terminal::size()
.ok()
.map(|(w, _)| w)
.unwrap_or(FALLBACK_WIDTH_COLS)
} else {
FALLBACK_WIDTH_COLS
};
(w, is_tty)
}
fn push_line(out: &mut String, s: &str) {
out.push_str(s);
out.push('\n');
}
fn push_dim_line(out: &mut String, s: &str, color: bool) {
if color {
out.push_str(ANSI_DIM);
out.push_str(s);
out.push_str(ANSI_RESET);
} else {
out.push_str(s);
}
out.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fixture_series(values: Vec<Option<f64>>) -> Value {
let pts: Vec<Value> = values
.into_iter()
.enumerate()
.map(|(i, v)| {
let ts = format!("2026-04-24T10:{:02}:00Z", i);
match v {
Some(x) => json!({"timestamp": ts, "value": x}),
None => json!({"timestamp": ts, "value": null}),
}
})
.collect();
json!({
"granularity": "PT1M",
"results": [
{"name": "REQ_SEARCH_COUNT", "unit": "count/s", "values": pts}
]
})
}
#[test]
fn renders_fixed_series_with_summary_and_chart_lines() {
let values: Vec<Option<f64>> = (0..24)
.map(|i| Some((i as f64).sin() * 10.0 + 20.0))
.collect();
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 100, false);
assert!(
out.contains("SEARCH_QPS (count/s)"),
"missing header: {}",
out
);
assert!(out.contains("gran PT1M"), "missing granularity: {}", out);
assert!(out.contains("min"), "missing summary: {}", out);
assert!(out.contains("last"), "missing last: {}", out);
assert!(
out.lines().count() > 4,
"chart body missing, got {} lines:\n{}",
out.lines().count(),
out
);
}
#[test]
fn null_values_do_not_panic_and_ignore_in_summary() {
let values = vec![
Some(1.0),
Some(2.0),
None,
None,
Some(10.0),
Some(12.0),
None,
Some(5.0),
];
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 100, false);
assert!(
out.contains("min 1"),
"min wrong, nulls not ignored: {}",
out
);
assert!(
out.contains("max 12"),
"max wrong, nulls not ignored: {}",
out
);
assert!(
out.contains("last 5"),
"last should be last non-null: {}",
out
);
}
#[test]
fn all_null_series_shows_no_data_and_no_chart() {
let values = vec![None, None, None];
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 100, false);
assert_eq!(out, "No metric data returned.\n", "got: {}", out);
}
#[test]
fn narrow_terminal_emits_sparkline_not_multirow_chart() {
let values: Vec<Option<f64>> = (0..12).map(|i| Some(i as f64)).collect();
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 30, false);
let has_spark = SPARKLINE_CHARS.iter().any(|c| out.contains(*c));
assert!(
has_spark,
"expected sparkline chars in narrow-width output:\n{}",
out
);
assert!(
!out.contains('⠤') && !out.contains('⠉'),
"narrow output should not contain braille chart chars:\n{}",
out
);
}
#[test]
fn non_tty_output_has_no_ansi_escapes() {
let values: Vec<Option<f64>> = (0..10).map(|i| Some(i as f64)).collect();
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 100, false);
assert!(
!out.contains("\x1b["),
"found ANSI escape in non-color output:\n{:?}",
out
);
}
#[test]
fn tty_path_includes_ansi_escapes_in_header() {
let values: Vec<Option<f64>> = (0..10).map(|i| Some(i as f64)).collect();
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 100, true);
assert!(
out.contains("\x1b[2m"),
"expected dim escape in colored output:\n{:?}",
out
);
}
#[test]
fn tty_path_colorizes_chart_line() {
let values: Vec<Option<f64>> = (0..20)
.map(|i| Some((i as f64).sin() * 10.0 + 20.0))
.collect();
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 100, true);
let plain = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 100, false);
assert_ne!(
out, plain,
"expected colored output to differ from plain output:\n{:?}",
out
);
let has_truecolor = out.contains("\x1b[38;2;");
let has_named_color = (30..=37)
.chain(90..=97)
.any(|c| out.contains(&format!("\x1b[{}m", c)));
assert!(
has_truecolor || has_named_color,
"expected a foreground-color ANSI escape on chart line in colored output:\n{:?}",
out
);
}
#[test]
fn empty_results_prints_global_empty_message() {
let result = json!({"results": []});
let out = render_metrics_chart(&result, &[], 100, false);
assert_eq!(out, "No metric data returned.\n");
}
#[test]
fn multiple_metrics_render_stacked_blocks() {
let result = json!({
"granularity": "PT1M",
"results": [
{"name": "A", "unit": "x", "values": [{"timestamp": "2026-04-24T10:00:00Z", "value": 1.0}, {"timestamp": "2026-04-24T10:01:00Z", "value": 2.0}]},
{"name": "B", "unit": "y", "values": [{"timestamp": "2026-04-24T10:00:00Z", "value": 3.0}, {"timestamp": "2026-04-24T10:01:00Z", "value": 4.0}]},
]
});
let out = render_metrics_chart(
&result,
&["SEARCH_QPS".to_string(), "INSERT_QPS".to_string()],
100,
false,
);
assert!(
out.contains("SEARCH_QPS (x)"),
"first block missing: {}",
out
);
assert!(
out.contains("INSERT_QPS (y)"),
"second block missing: {}",
out
);
assert!(
out.contains("\n\n"),
"blocks not separated by blank line: {}",
out
);
}
#[test]
fn flat_series_renders_full_chart_with_fixed_y_range() {
let values = vec![Some(1.0); 6];
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["REPLICA_COUNT".to_string()], 100, false);
assert!(
out.chars().any(|c| matches!(c, '⠀'..='⣿')),
"flat series should still render a braille chart (ymin=0 gives a valid range):\n{}",
out
);
assert!(
out.contains("2.0") || out.contains(" 2"),
"expected integer ymax '2' for constant-1 series:\n{}",
out
);
assert!(
out.contains("0.0"),
"expected ymin '0.0' for constant-1 series:\n{}",
out
);
}
#[test]
fn textplot_does_not_emit_x_index_label_line() {
let values: Vec<Option<f64>> = (0..24)
.map(|i| Some((i as f64).sin() * 5.0 + 10.0))
.collect();
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], 100, false);
for line in out.lines() {
let trimmed = line.trim();
if trimmed.starts_with('0') && trimmed.ends_with(char::is_numeric) {
let only_digits_and_ws = trimmed
.chars()
.all(|c| c.is_ascii_digit() || c.is_whitespace() || c == '.');
assert!(
!only_digits_and_ws,
"x-index label line leaked into output: {:?}",
line
);
}
}
}
#[test]
fn nice_ceil_rounds_to_integer_human_friendly_numbers() {
assert_eq!(nice_ceil(0.0), 1.0);
assert_eq!(nice_ceil(0.8), 1.0);
assert_eq!(nice_ceil(1.0), 1.0);
assert_eq!(nice_ceil(1.2), 2.0); assert_eq!(nice_ceil(7.5), 8.0);
assert_eq!(nice_ceil(8.0), 8.0);
assert_eq!(nice_ceil(8.5), 10.0);
assert_eq!(nice_ceil(125.0), 200.0);
assert_eq!(nice_ceil(608.0), 700.0);
assert_eq!(nice_ceil(1858.0), 2000.0);
for v in [0.5, 1.5, 3.3, 9.9, 49.0, 501.0, 9999.0] {
let ceil = nice_ceil(v);
assert_eq!(
ceil,
ceil.trunc(),
"nice_ceil({}) = {} is not an integer",
v,
ceil
);
}
assert_eq!(nice_ceil(f64::NAN), 1.0);
assert_eq!(nice_ceil(f64::INFINITY), 1.0);
}
#[test]
fn pick_ymax_gives_integer_headroom() {
assert_eq!(pick_ymax(100.0), 200.0); assert_eq!(pick_ymax(487.0), 700.0); assert_eq!(pick_ymax(1487.0), 2000.0); assert_eq!(pick_ymax(7.0), 10.0); assert_eq!(pick_ymax(1.0), 2.0); assert_eq!(pick_ymax(0.3), 1.0); }
#[test]
fn chart_has_zero_baseline_and_nice_ymax_label() {
let values: Vec<Option<f64>> = (0..20).map(|i| Some(10.0 + i as f64)).collect();
let result = fixture_series(values);
let out = render_metrics_chart(&result, &["X".to_string()], 100, false);
assert!(
out.contains("40.0"),
"expected nice ymax '40.0' in output:\n{}",
out
);
assert!(
out.contains("0.0"),
"expected ymin '0.0' in output:\n{}",
out
);
}
#[test]
fn chart_x_axis_shows_same_day_time_labels() {
let result = json!({
"granularity": "PT1M",
"results": [{
"name": "X",
"unit": "",
"values": [
{"timestamp": "2026-04-24T10:00:00Z", "value": 1.0},
{"timestamp": "2026-04-24T10:05:00Z", "value": 5.0},
{"timestamp": "2026-04-24T10:10:00Z", "value": 3.0}
]
}]
});
let out = render_metrics_chart(&result, &["X".to_string()], 100, false);
let last = out.lines().last().unwrap_or("");
assert!(
last.starts_with("10:00"),
"x-axis should start with 10:00, got: {:?}",
last
);
assert!(
last.trim_end().ends_with("10:10"),
"x-axis should end with 10:10, got: {:?}",
last
);
assert!(
!last.contains("04-24"),
"unexpected date prefix in same-day labels: {:?}",
last
);
}
#[test]
fn chart_x_axis_shows_multi_day_labels_with_date() {
let result = json!({
"granularity": "PT1H",
"results": [{
"name": "X",
"unit": "",
"values": [
{"timestamp": "2026-04-24T00:00:00Z", "value": 10.0},
{"timestamp": "2026-04-24T12:00:00Z", "value": 30.0},
{"timestamp": "2026-04-25T23:00:00Z", "value": 20.0}
]
}]
});
let out = render_metrics_chart(&result, &["X".to_string()], 100, false);
let last = out.lines().last().unwrap_or("");
assert!(
last.starts_with("04-24 00:00"),
"expected multi-day prefix, got: {:?}",
last
);
assert!(
last.trim_end().ends_with("04-25 23:00"),
"expected multi-day suffix, got: {:?}",
last
);
}
#[test]
fn y_labels_fit_within_terminal_width_without_wrap() {
let values: Vec<Option<f64>> = (0..20).map(|i| Some(1000.0 + i as f64)).collect();
let result = fixture_series(values);
let term_width: u16 = 80;
let out = render_metrics_chart(&result, &["SEARCH_QPS".to_string()], term_width, false);
for line in out.lines() {
let visible_len = line.chars().count();
assert!(
visible_len <= term_width as usize,
"line overflows term width {}: {} chars: {:?}",
term_width,
visible_len,
line
);
}
}
}