zilliz 1.4.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
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};

/// Serializes access to `colored::control::set_override`, which is process-wide
/// global state. Without this, concurrent renders with different `color` flags
/// (most likely in parallel tests) can race and produce the wrong escapes.
static COLOR_OVERRIDE_LOCK: Mutex<()> = Mutex::new(());

/// Bright terminal green used to highlight the chart line in TTY / color mode.
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;
/// Terminal columns reserved on the right of the braille canvas for
/// textplots' Y-axis tick labels (ymax / ymin are appended per row).
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 };

    // Fix the y-axis floor at 0 and round the ceiling up to a "nice" number
    // with ~25% headroom above max. This keeps the y-labels readable round
    // values and prevents the curve from hugging the top edge of the chart.
    let ymax = pick_ymax(max_val) as f32;

    // Reserve room on the right for the Y-axis tick labels textplots appends
    // after each canvas row; without this margin wide values (e.g. "1,234.56")
    // wrap and split the label across two lines. textplots' canvas uses 2
    // dots per terminal column and 4 dots per terminal row.
    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();
    // textplots emits colored braille chars via the `colored` crate, which
    // auto-strips ANSI escapes when stdout is not a TTY. Our `color` flag is
    // already the TTY gate, so force colorization on / off to match it
    // (otherwise piped runs that set color=true via tests or nested invocations
    // would silently lose colors). The colored-override is process-wide global
    // state; serialize the save/format/restore window with a mutex so parallel
    // renders don't race.
    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
    };

    // textplots always appends an x-axis tick line (xmin ... xmax) as the last
    // non-empty line. We plot on point-index coordinates, so those labels read
    // "0 ... N-1" and are meaningless to the user. Strip that line and append
    // our own time-based x-axis instead.
    let body = strip_last_line(&raw);
    let x_axis = format_x_time_axis(points, canvas_cols as usize);
    format!("{}{}", body, x_axis)
}

/// Pick a "nice" y-axis ceiling: round up `max * 1.25` (25% headroom) to the
/// smallest integer of the form `k * 10^n` with `k ∈ {1..=8, 10}`. With ymin
/// fixed at 0, this biases the curve toward the lower-middle of the chart
/// and keeps the y-axis label an integer so it reads cleanly (e.g. `2`, `10`,
/// `700`, `2000`).
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;
    }
    // Values below 1 snap to ymax=1 so the axis label stays a whole integer.
    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
}

/// Render the x-axis time labels aligned to the canvas. Starts at column 0
/// (chart's left edge) and ends at column `canvas_cols` (right edge). When
/// the range spans multiple days, uses `MM-DD HH:MM`; otherwise `HH:MM`.
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 {
    // Input: "2026-04-24T10:00:00Z"
    if ts.len() < 16 {
        return ts.to_string();
    }
    if multi_day {
        format!("{} {}", &ts[5..10], &ts[11..16]) // "MM-DD HH:MM"
    } else {
        ts[11..16].to_string() // "HH:MM"
    }
}

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);
        // Chart body should have more than 3 lines after header + summary.
        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
        );
        // No braille block rows.
        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() {
        // When color is enabled, the braille chart line is wrapped in an ANSI
        // foreground-color escape via textplots' linecolorplot. The specific
        // escape depends on the terminal's reported truecolor support (the
        // `colored` crate emits `\x1b[38;2;R;G;B` when `COLORTERM=truecolor`
        // and falls back to the closest named ANSI color otherwise), so the
        // assertion accepts any SGR foreground-color escape on a braille char.
        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
        );
        // Blank line separator between blocks.
        assert!(
            out.contains("\n\n"),
            "blocks not separated by blank line: {}",
            out
        );
    }

    #[test]
    fn flat_series_renders_full_chart_with_fixed_y_range() {
        // All values equal (e.g. REPLICA_COUNT=1) still renders as a full
        // braille chart: ymin is fixed at 0 and ymax is nice_ceil(1 * 1.25) = 2
        // (next integer ≥ 1.25), so textplots can draw a flat line at y=1
        // inside a valid 0..2 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);
        // Multi-row braille chart body must be present.
        assert!(
            out.chars().any(|c| matches!(c, ''..='')),
            "flat series should still render a braille chart (ymin=0 gives a valid range):\n{}",
            out
        );
        // Y ticks show integer 2 at top, 0 at bottom.
        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() {
        // Regression guard: we strip the last line that textplots appends (the
        // xmin / xmax tick labels). Those are point-index labels and confuse
        // users, since the time range is already shown in the header.
        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);
        // textplots would emit a trailing line like "0   23" (xmin and xmax
        // separated by padding). After stripping, no line may begin with "0 "
        // followed only by whitespace and a plain integer.
        for line in out.lines() {
            let trimmed = line.trim();
            if trimmed.starts_with('0') && trimmed.ends_with(char::is_numeric) {
                // Further check: line should not be "0 ... <N>"; allow Braille rows.
                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); // snaps to next integer, not 1.5
        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);
        // All returned values are integers (k * 10^n, integer k).
        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
            );
        }
        // Infinite / NaN must not panic and must yield a safe positive number.
        assert_eq!(nice_ceil(f64::NAN), 1.0);
        assert_eq!(nice_ceil(f64::INFINITY), 1.0);
    }

    #[test]
    fn pick_ymax_gives_integer_headroom() {
        // 25% headroom above max, then nice_ceil (integer).
        assert_eq!(pick_ymax(100.0), 200.0); // 100*1.25=125 -> 200
        assert_eq!(pick_ymax(487.0), 700.0); // 487*1.25=608.75 -> 700
        assert_eq!(pick_ymax(1487.0), 2000.0); // 1487*1.25=1858.75 -> 2000
        assert_eq!(pick_ymax(7.0), 10.0); // 7*1.25=8.75 -> 10
        assert_eq!(pick_ymax(1.0), 2.0); // 1*1.25=1.25 -> 2 (not 1.5)
        assert_eq!(pick_ymax(0.3), 1.0); // small fractional -> 1
    }

    #[test]
    fn chart_has_zero_baseline_and_nice_ymax_label() {
        // Values 10..30 -> max=29, pick_ymax(29) = nice_ceil(36.25) = 40.
        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);
        // Y-axis ticks are appended at the end of top and bottom canvas rows.
        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() {
        // Two points, same day: expect "HH:MM" format.
        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);
        // Last line of the block should be the x-axis labels.
        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
        );
        // No "MM-DD" prefix when both timestamps are on the same day.
        assert!(
            !last.contains("04-24"),
            "unexpected date prefix in same-day labels: {:?}",
            last
        );
    }

    #[test]
    fn chart_x_axis_shows_multi_day_labels_with_date() {
        // First and last on different days: expect "MM-DD HH:MM" format.
        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() {
        // Regression guard: values like "1,234.56" must not cause textplots'
        // Y-tick append to overflow the terminal and wrap to a new line.
        // The canvas-plus-label total width should be <= term width.
        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() {
            // Use char count rather than byte count since Braille chars are multi-byte.
            let visible_len = line.chars().count();
            assert!(
                visible_len <= term_width as usize,
                "line overflows term width {}: {} chars: {:?}",
                term_width,
                visible_len,
                line
            );
        }
    }
}