Skip to main content

hyperdb_mcp/
chart.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Chart rendering for query results.
5//!
6//! Converts a list of JSON rows (typically from [`crate::engine::Engine::execute_query_to_json`])
7//! into a PNG or SVG image via the [`plotters`] crate. The output is raw bytes plus a
8//! MIME type ready to drop into an MCP [`ImageContent`].
9//!
10//! # Supported Chart Types
11//!
12//! - **Bar** — categorical x-axis by default; multi-series supported via `series` column.
13//! - **Line** — auto-detects categorical x (DATE/TIMESTAMP/TEXT); override with `x_as_category`.
14//! - **Scatter** — same auto-detection as line.
15//! - **Histogram** — single numeric column binned into N buckets (default 20).
16//!
17//! # Rendering Pipeline
18//!
19//! 1. The MCP `chart` tool runs a read-only SQL query via [`crate::engine::Engine`].
20//! 2. Rows are grouped into series via `group_series` (categorical x values get
21//!    synthetic sequential indices; numeric x values pass through directly).
22//! 3. The chart is drawn on either a [`BitMapBackend`] (PNG, written to a temp file)
23//!    or an [`SVGBackend`] (SVG, rendered to an in-memory string).
24//! 4. The result is returned as base64-encoded [`ImageContent`] plus a JSON stats block.
25//!
26//! # Color Palette
27//!
28//! Multi-series charts cycle through an 8-color palette designed for white backgrounds.
29//! The palette is defined in `series_color`.
30//!
31//! [`BitMapBackend`]: plotters::prelude::BitMapBackend
32//! [`SVGBackend`]: plotters::prelude::SVGBackend
33//! [`ImageContent`]: rmcp::model::ImageContent
34
35#![allow(
36    clippy::cast_precision_loss,
37    reason = "chart rendering: rows/columns displayed to user; any values approaching 2^53 would saturate to Infinity in the chart anyway"
38)]
39
40use crate::error::{ErrorCode, McpError};
41use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc};
42use plotters::prelude::*;
43use plotters::style::colors;
44use serde_json::Value;
45use std::collections::BTreeMap;
46
47/// A single chart series' data points.
48///
49/// Each entry is `(x, y, x_label)` where the numeric `x` drives
50/// positioning on the axis and `x_label` preserves the original
51/// string form of the x value so categorical axes can render
52/// human-readable tick labels (the `group_series` function maps
53/// category strings through a `BTreeMap<String, f64>` to assign
54/// stable, deterministic x positions).
55type SeriesPoints = Vec<(f64, f64, String)>;
56
57/// Series name → its points. Uses `BTreeMap` (not `HashMap`) so
58/// multi-series charts render in deterministic order, which makes
59/// the resulting image bytes reproducible across runs.
60type SeriesMap = BTreeMap<String, SeriesPoints>;
61
62/// Supported chart types.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ChartType {
65    Bar,
66    Line,
67    Scatter,
68    Histogram,
69}
70
71impl ChartType {
72    /// Parse a string into a [`ChartType`].
73    ///
74    /// # Errors
75    ///
76    /// Returns [`ErrorCode::SchemaMismatch`] if `s` (case-insensitive) is
77    /// not one of `bar`, `line`, `scatter`, `histogram`, or `hist`.
78    pub fn parse(s: &str) -> Result<Self, McpError> {
79        match s.to_lowercase().as_str() {
80            "bar" => Ok(ChartType::Bar),
81            "line" => Ok(ChartType::Line),
82            "scatter" => Ok(ChartType::Scatter),
83            "histogram" | "hist" => Ok(ChartType::Histogram),
84            other => Err(McpError::new(
85                ErrorCode::SchemaMismatch,
86                format!(
87                    "Unknown chart type '{other}'. Expected one of: bar, line, scatter, histogram"
88                ),
89            )),
90        }
91    }
92}
93
94/// Output format for the rendered chart.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ChartFormat {
97    Png,
98    Svg,
99}
100
101impl ChartFormat {
102    /// Parse a string into a [`ChartFormat`].
103    ///
104    /// # Errors
105    ///
106    /// Returns [`ErrorCode::UnsupportedFormat`] if `s` (case-insensitive)
107    /// is not `png` or `svg`.
108    pub fn parse(s: &str) -> Result<Self, McpError> {
109        match s.to_lowercase().as_str() {
110            "png" => Ok(ChartFormat::Png),
111            "svg" => Ok(ChartFormat::Svg),
112            other => Err(McpError::new(
113                ErrorCode::UnsupportedFormat,
114                format!("Unknown chart format '{other}'. Expected 'png' or 'svg'"),
115            )),
116        }
117    }
118
119    #[must_use]
120    pub fn mime_type(&self) -> &'static str {
121        match self {
122            ChartFormat::Png => "image/png",
123            ChartFormat::Svg => "image/svg+xml",
124        }
125    }
126
127    /// File extension without leading dot (`"png"` / `"svg"`). Used when
128    /// synthesizing default filenames under the system temp dir.
129    #[must_use]
130    pub fn extension(&self) -> &'static str {
131        match self {
132            ChartFormat::Png => "png",
133            ChartFormat::Svg => "svg",
134        }
135    }
136}
137
138/// Resolve the effective output format from an explicit `format` parameter
139/// and/or an `output_path`'s extension.
140///
141/// Rules:
142/// - Both set: they must agree. Conflict returns `InvalidArgument` naming
143///   both values so the caller can fix one.
144/// - Only `format` set: parse it via [`ChartFormat::parse`].
145/// - Only `output_path` set: derive from its extension (`.png` / `.svg`).
146///   Unknown extensions return `InvalidArgument`.
147/// - Neither set: default to PNG (matches the pre-change behavior).
148///
149/// The path is only inspected for its extension — the file need not exist.
150///
151/// # Errors
152///
153/// - Returns [`ErrorCode::InvalidArgument`] if both `explicit_format` and
154///   `output_path` are set and they disagree on the format.
155/// - Propagates [`ErrorCode::UnsupportedFormat`] from [`ChartFormat::parse`]
156///   for unknown format strings.
157/// - Returns [`ErrorCode::InvalidArgument`] (via `format_from_extension`)
158///   when `output_path` has an extension other than `.png` or `.svg`.
159pub fn resolve_chart_format(
160    explicit_format: Option<&str>,
161    output_path: Option<&str>,
162) -> Result<ChartFormat, McpError> {
163    let ext_from_path = output_path.and_then(extract_extension);
164
165    match (explicit_format, ext_from_path.as_deref()) {
166        (Some(f), Some(ext)) => {
167            let from_format = ChartFormat::parse(f)?;
168            let from_ext = format_from_extension(ext)?;
169            if from_format != from_ext {
170                return Err(McpError::new(
171                    ErrorCode::InvalidArgument,
172                    format!(
173                        "chart: format=\"{f}\" conflicts with output_path extension \".{ext}\" — \
174                         remove one or make them agree"
175                    ),
176                ));
177            }
178            Ok(from_format)
179        }
180        (Some(f), None) => ChartFormat::parse(f),
181        (None, Some(ext)) => format_from_extension(ext),
182        (None, None) => Ok(ChartFormat::Png),
183    }
184}
185
186/// Lowercase extension of `path` with the leading dot stripped, or `None`
187/// if the path has no extension or a non-UTF-8 extension.
188fn extract_extension(path: &str) -> Option<String> {
189    std::path::Path::new(path)
190        .extension()
191        .and_then(|e| e.to_str())
192        .map(str::to_ascii_lowercase)
193}
194
195/// Map a file extension (no leading dot, lowercased) to a `ChartFormat`.
196/// Unknown extensions return `InvalidArgument` with a list of what's allowed.
197fn format_from_extension(ext: &str) -> Result<ChartFormat, McpError> {
198    match ext {
199        "png" => Ok(ChartFormat::Png),
200        "svg" => Ok(ChartFormat::Svg),
201        other => Err(McpError::new(
202            ErrorCode::InvalidArgument,
203            format!(
204                "chart: unsupported output_path extension \".{other}\" (use .png or .svg, \
205                 or omit output_path to auto-generate one)"
206            ),
207        )),
208    }
209}
210
211/// How the `chart` tool should deliver the rendered image: write it to
212/// disk, return it inline in the MCP tool result, or both. This is a
213/// pure decision based on the caller's `inline` / `output_path` flags —
214/// no I/O happens here; `write_chart_to_disk` does the actual write.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum ChartDisposition {
217    /// Write to `path`, don't return inline. Path is either caller-supplied
218    /// or auto-generated under the system temp dir.
219    WriteOnly { path: std::path::PathBuf },
220    /// Return inline, don't write to disk.
221    InlineOnly,
222    /// Write to `path` and also return inline.
223    WriteAndInline { path: std::path::PathBuf },
224}
225
226impl ChartDisposition {
227    /// The target path, if any. `InlineOnly` has no path.
228    #[must_use]
229    pub fn path(&self) -> Option<&std::path::Path> {
230        match self {
231            ChartDisposition::WriteOnly { path } | ChartDisposition::WriteAndInline { path } => {
232                Some(path)
233            }
234            ChartDisposition::InlineOnly => None,
235        }
236    }
237
238    /// Whether to include `Content::image(...)` in the tool result.
239    #[must_use]
240    pub fn wants_inline(&self) -> bool {
241        matches!(
242            self,
243            ChartDisposition::InlineOnly | ChartDisposition::WriteAndInline { .. }
244        )
245    }
246}
247
248/// Decide what the chart tool should do with the rendered bytes based on
249/// the caller's `inline` and `output_path` flags plus the already-resolved
250/// `format`.
251///
252/// Semantics (see the `chart` tool docs):
253/// - `inline=true` + no path → `InlineOnly` (skip disk)
254/// - `inline=true` + path    → `WriteAndInline` (both)
255/// - `inline=false`/absent + path → `WriteOnly`
256/// - `inline=false`/absent + no path → `WriteOnly` with auto-generated path
257///   under `std::env::temp_dir()/hyperdb-charts/`
258///
259/// This is the default path most callers take: keeps the MCP transcript small
260/// by writing the PNG/SVG to disk and letting the caller `Read(path)` when
261/// they want to display it.
262#[must_use]
263pub fn resolve_chart_disposition(
264    inline: bool,
265    output_path: Option<&str>,
266    format: ChartFormat,
267) -> ChartDisposition {
268    match (inline, output_path) {
269        (true, None) => ChartDisposition::InlineOnly,
270        (true, Some(p)) => ChartDisposition::WriteAndInline {
271            path: std::path::PathBuf::from(p),
272        },
273        (false, Some(p)) => ChartDisposition::WriteOnly {
274            path: std::path::PathBuf::from(p),
275        },
276        (false, None) => ChartDisposition::WriteOnly {
277            path: auto_generated_chart_path(format),
278        },
279    }
280}
281
282/// Synthesize a unique path under `std::env::temp_dir()/hyperdb-charts/` for
283/// a default-disposition chart write. The filename encodes a monotonic
284/// counter + PID + unix-nanos so two calls in the same nanosecond (or on two
285/// hosts with sync'd clocks) don't collide.
286///
287/// The parent directory is *not* created here — the caller does that right
288/// before writing, to keep this function pure and cheap for testing.
289pub fn auto_generated_chart_path(format: ChartFormat) -> std::path::PathBuf {
290    use std::sync::atomic::{AtomicU64, Ordering};
291    static COUNTER: AtomicU64 = AtomicU64::new(0);
292
293    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
294    let pid = std::process::id();
295    let nanos = std::time::SystemTime::now()
296        .duration_since(std::time::UNIX_EPOCH)
297        .map_or(0, |d| d.as_nanos());
298
299    std::env::temp_dir().join("hyperdb-charts").join(format!(
300        "chart-{nanos}-{pid}-{counter}.{ext}",
301        ext = format.extension()
302    ))
303}
304
305/// Write chart bytes to `path`, creating the parent directory if needed and
306/// honoring the `overwrite` flag.
307///
308/// Errors:
309/// - `PermissionDenied` if `path` exists and `overwrite=false` (matches
310///   `export`'s pre-flight check).
311/// - `InternalError` wrapping the underlying `std::io::Error` for mkdir or
312///   write failures.
313///
314/// Returns the number of bytes written.
315///
316/// # Errors
317///
318/// - Returns [`ErrorCode::PermissionDenied`] if `path` exists and
319///   `overwrite` is `false`.
320/// - Returns [`ErrorCode::InternalError`] wrapping the underlying
321///   [`std::io::Error`] for `create_dir_all` or `write` failures.
322pub fn write_chart_to_disk(
323    path: &std::path::Path,
324    bytes: &[u8],
325    overwrite: bool,
326) -> Result<u64, McpError> {
327    // Reject `..` components to prevent traversal attacks via LLM-generated paths.
328    // (We can't canonicalize a non-existent path, but rejecting `..` covers the
329    // most common attack pattern.)
330    if path
331        .components()
332        .any(|c| matches!(c, std::path::Component::ParentDir))
333    {
334        return Err(McpError::new(
335            ErrorCode::InvalidArgument,
336            format!(
337                "Chart output path '{}' may not contain '..' components",
338                path.display()
339            ),
340        ));
341    }
342
343    if !overwrite && path.exists() {
344        return Err(McpError::new(
345            ErrorCode::PermissionDenied,
346            format!(
347                "Refusing to overwrite existing chart: {} (pass overwrite=true to replace it)",
348                path.display()
349            ),
350        ));
351    }
352
353    if let Some(parent) = path.parent() {
354        if !parent.as_os_str().is_empty() {
355            std::fs::create_dir_all(parent).map_err(|e| {
356                McpError::new(
357                    ErrorCode::InternalError,
358                    format!(
359                        "Failed to create parent directory for chart '{}': {e}",
360                        path.display()
361                    ),
362                )
363            })?;
364        }
365    }
366
367    std::fs::write(path, bytes).map_err(|e| {
368        McpError::new(
369            ErrorCode::InternalError,
370            format!("Failed to write chart to '{}': {e}", path.display()),
371        )
372    })?;
373
374    Ok(bytes.len() as u64)
375}
376
377/// User-facing chart configuration, parsed from MCP tool parameters.
378#[derive(Debug, Clone)]
379pub struct ChartOptions {
380    pub chart_type: ChartType,
381    pub x_column: Option<String>,
382    pub y_column: Option<String>,
383    pub series_column: Option<String>,
384    pub title: Option<String>,
385    pub format: ChartFormat,
386    pub width: u32,
387    pub height: u32,
388    pub bins: u32,
389    /// Override the chart-type-specific default for how the x column is
390    /// interpreted:
391    ///
392    /// - `None` (default): auto-detect from the first row's x value.
393    ///   - For `Bar`: always categorical.
394    ///   - For `Line` / `Scatter`: numeric x → numeric axis;
395    ///     DATE / TIMESTAMP / TIMESTAMPTZ string → **proportional time
396    ///     axis** (positions are real Unix epoch seconds, ticks formatted
397    ///     in the matching kind); TEXT → categorical fallback.
398    /// - `Some(true)`: force categorical layout (synthetic sequential
399    ///   x positions, original strings as tick labels). Useful when you
400    ///   want even spacing on temporal data — e.g. one bar per business
401    ///   day with no visual gap for weekends.
402    /// - `Some(false)`: force numeric x. Errors for non-numeric inputs
403    ///   on `Line` / `Scatter`. Rarely useful on `Bar`.
404    ///
405    /// When categorical mode is active the rendered x axis uses the
406    /// original string representation of each distinct x value as its
407    /// tick label, in the order x values are first seen. When time mode
408    /// is active, gaps between data points reflect real wall-clock time
409    /// rather than insertion order.
410    pub x_as_category: Option<bool>,
411    /// Fix the x-axis range as `[min, max]`. When set, auto-scaling is
412    /// skipped and all frames/charts share the same x extent. Useful for
413    /// side-by-side comparisons or animation where a consistent scale
414    /// matters. Ignored for bar charts (which use categorical positions).
415    pub x_range: Option<[f64; 2]>,
416    /// Fix the y-axis range as `[min, max]`. Same semantics as `x_range`.
417    pub y_range: Option<[f64; 2]>,
418    /// Map series names to hex colors (`"#rrggbb"`). Entries that match a
419    /// series name override the default palette; unmatched series still
420    /// cycle through palette colors. Only affects charts with a series
421    /// column; single-series charts use the first palette color as before.
422    pub color_map: std::collections::HashMap<String, RGBColor>,
423    /// When `true`, draw the series name as a text label next to each dot
424    /// on scatter (and each point on line) charts, and suppress the legend
425    /// entirely. Useful when each series has exactly one point (e.g. one
426    /// country per dot) and a legend would be redundant.
427    ///
428    /// Labels are drawn 6 pixels right and 4 pixels above the data point.
429    /// No collision avoidance is performed — for dense data the legend
430    /// (`label_points: false`, the default) is usually more readable.
431    pub label_points: bool,
432}
433
434impl Default for ChartOptions {
435    fn default() -> Self {
436        Self {
437            chart_type: ChartType::Bar,
438            x_column: None,
439            y_column: None,
440            series_column: None,
441            title: None,
442            format: ChartFormat::Png,
443            width: 800,
444            height: 480,
445            bins: 20,
446            x_as_category: None,
447            x_range: None,
448            y_range: None,
449            color_map: std::collections::HashMap::new(),
450            label_points: false,
451        }
452    }
453}
454
455/// Result of rendering a chart.
456#[derive(Debug)]
457pub struct ChartResult {
458    pub bytes: Vec<u8>,
459    pub mime_type: &'static str,
460    pub rows_plotted: usize,
461}
462
463/// Render a chart from a list of JSON row objects.
464///
465/// `rows` is expected to be the output of `execute_query_to_json`: each entry
466/// is a `Value::Object` with column name → value pairs. Non-object rows are
467/// skipped silently.
468///
469/// # Errors
470///
471/// - Returns [`ErrorCode::EmptyData`] if `rows` is empty.
472/// - Returns [`ErrorCode::SchemaMismatch`] if required columns named in
473///   `opts` are absent, if x or y columns cannot be interpreted as
474///   numeric for chart types that require numeric axes, or if a
475///   categorical axis produces zero distinct categories.
476/// - Returns [`ErrorCode::InternalError`] wrapping failures from the
477///   underlying `plotters` backend during rendering or PNG/SVG encoding.
478/// - Returns [`ErrorCode::InvalidArgument`] if the result set exceeds
479///   50,000 rows.
480pub fn render_chart(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
481    const MAX_CHART_ROWS: usize = 50_000;
482    if rows.is_empty() {
483        return Err(McpError::new(
484            ErrorCode::EmptyData,
485            "No rows returned from SQL query — nothing to chart",
486        ));
487    }
488    if rows.len() > MAX_CHART_ROWS {
489        return Err(McpError::new(
490            ErrorCode::InvalidArgument,
491            format!(
492                "Chart data has {} rows, exceeding the {MAX_CHART_ROWS}-row limit. \
493                 Add a LIMIT clause or aggregate your data to reduce row count.",
494                rows.len()
495            ),
496        )
497        .with_suggestion(format!(
498            "Add `LIMIT {MAX_CHART_ROWS}` to your query, or use GROUP BY to aggregate."
499        )));
500    }
501
502    match opts.format {
503        ChartFormat::Png => render_png(rows, opts),
504        ChartFormat::Svg => render_svg(rows, opts),
505    }
506}
507
508fn render_png(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
509    let tmp = tempfile::Builder::new()
510        .suffix(".png")
511        .tempfile()
512        .map_err(|e| {
513            McpError::new(
514                ErrorCode::InternalError,
515                format!("Cannot create temp PNG file: {e}"),
516            )
517        })?;
518    let path = tmp.path().to_path_buf();
519    let rows_plotted = {
520        let backend = BitMapBackend::new(&path, (opts.width, opts.height));
521        draw_on_backend(backend, rows, opts)?
522    };
523    let bytes = std::fs::read(&path).map_err(|e| {
524        McpError::new(
525            ErrorCode::InternalError,
526            format!("Cannot read rendered PNG: {e}"),
527        )
528    })?;
529    drop(tmp);
530    Ok(ChartResult {
531        bytes,
532        mime_type: ChartFormat::Png.mime_type(),
533        rows_plotted,
534    })
535}
536
537fn render_svg(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
538    let mut svg_string = String::new();
539    let rows_plotted = {
540        let backend = SVGBackend::with_string(&mut svg_string, (opts.width, opts.height));
541        draw_on_backend(backend, rows, opts)?
542    };
543    Ok(ChartResult {
544        bytes: svg_string.into_bytes(),
545        mime_type: ChartFormat::Svg.mime_type(),
546        rows_plotted,
547    })
548}
549
550/// Dispatch to the chart-type-specific drawing routine over an abstract backend.
551fn draw_on_backend<DB: DrawingBackend>(
552    backend: DB,
553    rows: &[Value],
554    opts: &ChartOptions,
555) -> Result<usize, McpError>
556where
557    <DB as DrawingBackend>::ErrorType: 'static,
558{
559    let root = backend.into_drawing_area();
560    root.fill(&WHITE).map_err(draw_err)?;
561
562    match opts.chart_type {
563        ChartType::Bar => draw_bar(&root, rows, opts),
564        ChartType::Line => draw_line(&root, rows, opts),
565        ChartType::Scatter => draw_scatter(&root, rows, opts),
566        ChartType::Histogram => draw_histogram(&root, rows, opts),
567    }
568}
569
570#[expect(
571    clippy::needless_pass_by_value,
572    reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
573)]
574fn draw_err<E: std::error::Error + Send + Sync + 'static>(e: DrawingAreaErrorKind<E>) -> McpError {
575    McpError::new(
576        ErrorCode::InternalError,
577        format!("Chart rendering error: {e}"),
578    )
579}
580
581#[expect(
582    clippy::ref_option,
583    reason = "matches callers that already hold `&Option<T>`; avoiding a `.as_ref()` dance at every call site"
584)]
585fn require_column<'a>(col: &'a Option<String>, role: &str) -> Result<&'a str, McpError> {
586    col.as_deref().ok_or_else(|| {
587        McpError::new(
588            ErrorCode::SchemaMismatch,
589            format!("The '{role}' column name is required for this chart type"),
590        )
591    })
592}
593
594fn as_number(v: &Value) -> Option<f64> {
595    match v {
596        Value::Number(n) => n.as_f64(),
597        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
598        _ => None,
599    }
600}
601
602fn as_string(v: &Value) -> String {
603    match v {
604        Value::String(s) => s.clone(),
605        Value::Null => String::new(),
606        other => other.to_string(),
607    }
608}
609
610/// Compact categorical tick labels by stripping a shared trailing
611/// timezone offset, when every label ends with the same one (typical
612/// for TIMESTAMPTZ data stored in UTC where every row reports `+00:00`).
613///
614/// Returns labels unchanged when there's no shared suffix or fewer than
615/// two labels. Tick *count* selection lives in [`auto_tick_count`]; this
616/// pass is purely about removing redundant characters from each label.
617fn strip_shared_tz_suffix(labels: &[String]) -> Vec<String> {
618    if labels.len() <= 1 {
619        return labels.to_vec();
620    }
621    let Some(suffix) = shared_tz_suffix(labels) else {
622        return labels.to_vec();
623    };
624    labels
625        .iter()
626        .map(|l| {
627            l.strip_suffix(suffix.as_str())
628                .unwrap_or(l)
629                .trim()
630                .to_string()
631        })
632        .collect()
633}
634
635/// Decide how many tick labels `plotters` should draw on a categorical
636/// x-axis given the labels we plan to render and the chart pixel width.
637///
638/// We pass the result to `.x_labels(N)` so `plotters` distributes tick
639/// positions across the categorical range. The formatter then renders
640/// the *real* label at each position — never blanks — so the user sees
641/// a usable, evenly-spaced subset rather than a sea of empty strings.
642///
643/// Heuristic: estimate per-label pixel width as
644/// `max_label_chars * 7px + 10px` (close to plotters' default mesh
645/// font), divide the chart width by that, then clamp to
646/// `[2, labels.len()]`. Returns `labels.len()` directly when there
647/// are 0 or 1 labels.
648///
649/// # Why not blank labels at non-step indices?
650///
651/// `plotters` picks its own tick *positions* on the float axis (e.g.
652/// `0.0, 4.7, 9.4, …` for a 0..89 categorical range). Rounding those
653/// back to integer indices rarely lands on the same indices a "keep
654/// every Nth, blank the rest" rule would preserve, so most ticks
655/// would render as empty strings. Telling `plotters` how many ticks
656/// to draw and always returning a real label is the only stable fix.
657///
658/// # Caveat: `plotters` rounds down to the next "nice" subdivision
659///
660/// `plotters::compute_f64_key_points` picks the smallest scale (most
661/// ticks) such that `npoints ≤ max_points`, drawing scales from a
662/// fixed band table `{1, 2, 5, 10, 20, 50, 100, …}`. So a wider chart
663/// requesting 9 ticks across a 0..89 range still ends up with 5 ticks
664/// (band 20), because the next denser band gives 10 ticks > 9. The
665/// fix for that is *not* to multiply the request — at 800 px width 10
666/// labels of 19 chars each (1430 px) would overlap. The proper fix
667/// for time-series charts is the proportional time-axis path, where
668/// `plotters` picks nice time intervals against real epoch positions
669/// and the band-rounding artifact disappears entirely.
670fn auto_tick_count(labels: &[String], chart_width: u32) -> usize {
671    if labels.len() <= 1 {
672        return labels.len();
673    }
674    let max_chars = labels.iter().map(|l| l.chars().count()).max().unwrap_or(1);
675    tick_count_for_label_width(max_chars, chart_width).min(labels.len())
676}
677
678/// Compute how many tick labels can fit horizontally, given a typical
679/// label character count and the chart pixel width. Pure width math —
680/// no clamping against a label count or label slice. Use this when
681/// the actual label list isn't available up front (e.g. the temporal
682/// branch generates labels lazily inside the formatter closure).
683///
684/// Returns at least 2 so the axis stays informative even when labels
685/// would technically overlap.
686fn tick_count_for_label_width(label_chars: usize, chart_width: u32) -> usize {
687    let per_label_px = u32::try_from(label_chars)
688        .unwrap_or(u32::MAX)
689        .saturating_mul(7)
690        .saturating_add(10);
691    let fits = chart_width.saturating_div(per_label_px.max(1)) as usize;
692    fits.max(2)
693}
694
695/// If all labels share a trailing timezone offset pattern like `+00:00`
696/// or `-05:30`, return that suffix. Returns `None` if labels differ or
697/// have no offset.
698fn shared_tz_suffix(labels: &[String]) -> Option<String> {
699    let first = labels.first()?;
700    // Match pattern: space or 'T' followed by time, then +/-HH:MM at the end
701    let offset_start = first.rfind('+').or_else(|| {
702        // Careful: don't match the '-' in "2026-05-01"
703        let last_minus = first.rfind('-')?;
704        // Only if it's after a ':' (i.e. part of time, not date)
705        if first[..last_minus].ends_with(|c: char| c.is_ascii_digit()) && last_minus > 10 {
706            Some(last_minus)
707        } else {
708            None
709        }
710    })?;
711    let suffix = &first[offset_start..];
712    // Must look like +HH:MM or -HH:MM (6 chars)
713    if suffix.len() != 6 {
714        return None;
715    }
716    // Verify all labels share this suffix
717    if labels.iter().all(|l| l.ends_with(suffix)) {
718        Some(suffix.to_string())
719    } else {
720        None
721    }
722}
723
724/// Collect distinct x values and their original string labels from a
725/// [`SeriesMap`], in ascending x-value order.
726///
727/// Used by [`draw_bar`] (always) and by [`draw_line_or_scatter`] when
728/// `x_as_category=true`. The returned (`x_val`, label) pairs drive the
729/// `x_label_formatter` that renders axis ticks as strings — essential
730/// for charts over `DATE` / enum / name-keyed data where `x_val` is a
731/// synthetic sequential index assigned by `group_series`'s category
732/// mode rather than a meaningful number.
733fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> {
734    // Dedup by bit pattern so NaN handling stays consistent with how
735    // `BTreeMap<f64>` would behave (we store as `u64` bits because
736    // `f64: !Ord`). The final sort is by numeric value.
737    let mut seen: BTreeMap<u64, String> = BTreeMap::new();
738    for pts in groups.values() {
739        for (x, _y, label) in pts {
740            seen.entry(x.to_bits()).or_insert_with(|| label.clone());
741        }
742    }
743    let mut entries: Vec<_> = seen.into_iter().collect();
744    entries.sort_by(|a, b| {
745        f64::from_bits(a.0)
746            .partial_cmp(&f64::from_bits(b.0))
747            .unwrap_or(std::cmp::Ordering::Equal)
748    });
749    entries
750        .into_iter()
751        .map(|(bits, label)| (f64::from_bits(bits), label))
752        .collect()
753}
754
755/// Discriminator for temporal x-axis input formats. Drives both the
756/// date parser ([`parse_temporal`]) and the time-axis label formatter,
757/// so a chart with `DATE` x values doesn't waste pixels on `00:00:00`
758/// suffixes and a `TIMESTAMPTZ` chart preserves its timezone offset on
759/// rendered tick labels.
760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
761enum TemporalKind {
762    /// `YYYY-MM-DD` — labels rendered as `%Y-%m-%d`, ticks land at
763    /// midnight UTC.
764    Date,
765    /// `YYYY-MM-DD HH:MM:SS` — labels rendered as `%Y-%m-%d %H:%M:%S`,
766    /// positioned at their face-value UTC equivalent (TIMESTAMP is
767    /// timezone-naive by definition).
768    DateTime,
769    /// `YYYY-MM-DD HH:MM:SS+HH:MM` — wrapped offset is the seconds
770    /// east of UTC parsed from the *first* row. Subsequent rows are
771    /// positioned in true UTC and re-rendered in this offset's local
772    /// time, so a chart over uniformly-`+00:00` data displays UTC
773    /// labels and a chart over `+05:30` data displays IST.
774    DateTimeTz(i32),
775}
776
777/// How to interpret the x column when extracting f64 axis positions.
778///
779/// Drives [`group_series`] and the corresponding rendering branch in
780/// [`line_or_scatter`] / [`draw_bar`]. `Temporal` is the new mode added
781/// for proportional time-axis rendering: x positions are real Unix
782/// epoch seconds (so 6 hours apart on the wire are 6 hours apart on
783/// the chart), and tick labels are formatted via chrono.
784#[derive(Debug, Clone, Copy)]
785enum XMode {
786    /// X values must be JSON numbers; positions pass through directly.
787    Numeric,
788    /// X values are stringified and assigned synthetic sequential
789    /// indices in first-seen order. All positions are integers, so
790    /// gaps in real-world spacing are flattened.
791    Categorical,
792    /// X values are parsed as temporal strings and positioned at their
793    /// Unix epoch seconds. Spacing is proportional to real time; tick
794    /// labels use a chrono format derived from the detected `kind`.
795    Temporal(TemporalKind),
796}
797
798/// Parse a SQL temporal string ([`Value`] of `String` shape) into
799/// `(kind, epoch_seconds_as_f64)`. Returns `None` when the value isn't
800/// a recognized DATE / TIMESTAMP / TIMESTAMPTZ form.
801///
802/// Recognized formats (most-specific first):
803/// - `YYYY-MM-DD HH:MM:SS+HH:MM` and `T` separator → [`TemporalKind::DateTimeTz`]
804/// - `YYYY-MM-DD HH:MM:SS+HHMM` (no colon in offset)
805/// - `YYYY-MM-DD HH:MM:SS` (and fractional seconds) → [`TemporalKind::DateTime`]
806/// - `YYYY-MM-DD HH:MM` (no seconds) → [`TemporalKind::DateTime`]
807/// - `YYYY-MM-DD` → [`TemporalKind::Date`]
808///
809/// `DateTime` strings are treated as UTC for positioning purposes —
810/// they're naive by definition, so we have no other choice. The label
811/// formatter will reproduce the input format faithfully.
812fn parse_temporal(s: &str) -> Option<(TemporalKind, f64)> {
813    const TZ_FORMATS: &[&str] = &[
814        "%Y-%m-%d %H:%M:%S%:z",
815        "%Y-%m-%dT%H:%M:%S%:z",
816        "%Y-%m-%d %H:%M:%S%z",
817        "%Y-%m-%dT%H:%M:%S%z",
818        "%Y-%m-%d %H:%M:%S%.f%:z",
819        "%Y-%m-%dT%H:%M:%S%.f%:z",
820    ];
821    for fmt in TZ_FORMATS {
822        if let Ok(dt) = DateTime::<FixedOffset>::parse_from_str(s, fmt) {
823            let offset = dt.offset().local_minus_utc();
824            return Some((TemporalKind::DateTimeTz(offset), dt.timestamp() as f64));
825        }
826    }
827
828    const DT_FORMATS: &[&str] = &[
829        "%Y-%m-%d %H:%M:%S",
830        "%Y-%m-%dT%H:%M:%S",
831        "%Y-%m-%d %H:%M:%S%.f",
832        "%Y-%m-%dT%H:%M:%S%.f",
833        "%Y-%m-%d %H:%M",
834        "%Y-%m-%dT%H:%M",
835    ];
836    for fmt in DT_FORMATS {
837        if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
838            return Some((
839                TemporalKind::DateTime,
840                Utc.from_utc_datetime(&dt).timestamp() as f64,
841            ));
842        }
843    }
844
845    if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
846        let dt = date.and_hms_opt(0, 0, 0)?;
847        return Some((
848            TemporalKind::Date,
849            Utc.from_utc_datetime(&dt).timestamp() as f64,
850        ));
851    }
852
853    None
854}
855
856/// Decide the x mode for a line/scatter chart from the first row's
857/// x value. Used when the caller didn't explicitly set `x_as_category`.
858///
859/// Priority:
860/// 1. Numeric (JSON number) → [`XMode::Numeric`].
861/// 2. String parsing as DATE/TIMESTAMP/TIMESTAMPTZ → [`XMode::Temporal`].
862/// 3. Anything else (TEXT, missing) → [`XMode::Categorical`] fallback.
863fn detect_line_x_mode(rows: &[Value], x_col: &str) -> XMode {
864    let Some(x_raw) = rows
865        .first()
866        .and_then(Value::as_object)
867        .and_then(|obj| obj.get(x_col))
868    else {
869        return XMode::Categorical;
870    };
871    if as_number(x_raw).is_some() {
872        return XMode::Numeric;
873    }
874    if let Some(s) = x_raw.as_str() {
875        if let Some((kind, _)) = parse_temporal(s) {
876            return XMode::Temporal(kind);
877        }
878    }
879    XMode::Categorical
880}
881
882/// Format a Unix epoch seconds tick value as a human-readable date
883/// string in a form matching the originally detected [`TemporalKind`].
884fn format_temporal_tick(seconds: f64, kind: TemporalKind) -> String {
885    if !seconds.is_finite() {
886        return String::new();
887    }
888    #[expect(
889        clippy::cast_possible_truncation,
890        reason = "tick positions for typical chart ranges (1970..2100) fit comfortably in i64; pre-flight is_finite() guards NaN/inf, and timestamp_opt() returns None on out-of-range values which we map to empty string"
891    )]
892    let secs_i64 = seconds.round() as i64;
893    match kind {
894        TemporalKind::Date => Utc
895            .timestamp_opt(secs_i64, 0)
896            .single()
897            .map(|dt| dt.format("%Y-%m-%d").to_string())
898            .unwrap_or_default(),
899        TemporalKind::DateTime => Utc
900            .timestamp_opt(secs_i64, 0)
901            .single()
902            .map(|dt| dt.naive_utc().format("%Y-%m-%d %H:%M:%S").to_string())
903            .unwrap_or_default(),
904        TemporalKind::DateTimeTz(tz_offset) => FixedOffset::east_opt(tz_offset)
905            .and_then(|off| off.timestamp_opt(secs_i64, 0).single())
906            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S%:z").to_string())
907            .unwrap_or_default(),
908    }
909}
910
911/// Group rows into (`series_name`, points) buckets, extracting x and y values.
912/// When `series_col` is None, all points land in a single unnamed series.
913fn group_series(
914    rows: &[Value],
915    x_col: &str,
916    y_col: &str,
917    series_col: Option<&str>,
918    x_mode: XMode,
919) -> Result<SeriesMap, McpError> {
920    let mut groups: SeriesMap = BTreeMap::new();
921    let mut category_index: BTreeMap<String, f64> = BTreeMap::new();
922
923    for row in rows {
924        let Some(obj) = row.as_object() else { continue };
925
926        let y_val = obj.get(y_col).and_then(as_number).ok_or_else(|| {
927            McpError::new(
928                ErrorCode::SchemaMismatch,
929                format!("Column '{y_col}' is missing or not numeric in at least one row"),
930            )
931        })?;
932
933        let x_raw = obj.get(x_col).cloned().unwrap_or(Value::Null);
934        let x_label = as_string(&x_raw);
935        let x_val = match x_mode {
936            XMode::Categorical => {
937                let next = category_index.len() as f64;
938                *category_index.entry(x_label.clone()).or_insert(next)
939            }
940            XMode::Numeric => as_number(&x_raw).ok_or_else(|| {
941                McpError::new(
942                    ErrorCode::SchemaMismatch,
943                    format!("Column '{x_col}' is missing or not numeric in at least one row"),
944                )
945            })?,
946            XMode::Temporal(_) => parse_temporal(&x_label)
947                .map(|(_, ts)| ts)
948                .ok_or_else(|| {
949                    McpError::new(
950                        ErrorCode::SchemaMismatch,
951                        format!(
952                            "Column '{x_col}' value '{x_label}' is not a recognized DATE / TIMESTAMP / TIMESTAMPTZ form"
953                        ),
954                    )
955                })?,
956        };
957
958        let series_key = match series_col {
959            Some(s) => obj.get(s).map(as_string).unwrap_or_default(),
960            None => String::new(),
961        };
962
963        groups
964            .entry(series_key)
965            .or_default()
966            .push((x_val, y_val, x_label));
967    }
968
969    if groups.values().all(std::vec::Vec::is_empty) {
970        return Err(McpError::new(
971            ErrorCode::EmptyData,
972            "No valid data points after filtering",
973        ));
974    }
975
976    Ok(groups)
977}
978
979/// Pick a color from the palette by index, cycling as needed.
980fn series_color(idx: usize) -> RGBColor {
981    // 8 distinct colors that work on white background; cycles for more series.
982    const PALETTE: [RGBColor; 8] = [
983        RGBColor(31, 119, 180),  // muted blue
984        RGBColor(255, 127, 14),  // safety orange
985        RGBColor(44, 160, 44),   // cooked asparagus
986        RGBColor(214, 39, 40),   // brick red
987        RGBColor(148, 103, 189), // muted purple
988        RGBColor(140, 86, 75),   // chestnut brown
989        RGBColor(227, 119, 194), // raspberry yogurt pink
990        RGBColor(127, 127, 127), // middle gray
991    ];
992    PALETTE[idx % PALETTE.len()]
993}
994
995/// Resolve the color for `series_name`: check `color_map` first, fall back
996/// to the palette-by-index default so unmapped series still get a color.
997fn series_color_for(series_name: &str, idx: usize, opts: &ChartOptions) -> RGBColor {
998    opts.color_map
999        .get(series_name)
1000        .copied()
1001        .unwrap_or_else(|| series_color(idx))
1002}
1003
1004/// Parse a `"#rrggbb"` hex string into an `RGBColor`. Returns `None` when
1005/// the string is not in the expected format so callers can log and skip
1006/// rather than hard-failing.
1007#[must_use]
1008pub fn parse_hex_color(s: &str) -> Option<RGBColor> {
1009    let s = s.strip_prefix('#').unwrap_or(s);
1010    if s.len() != 6 {
1011        return None;
1012    }
1013    let r = u8::from_str_radix(&s[0..2], 16).ok()?;
1014    let g = u8::from_str_radix(&s[2..4], 16).ok()?;
1015    let b = u8::from_str_radix(&s[4..6], 16).ok()?;
1016    Some(RGBColor(r, g, b))
1017}
1018
1019fn draw_bar<DB: DrawingBackend>(
1020    root: &DrawingArea<DB, plotters::coord::Shift>,
1021    rows: &[Value],
1022    opts: &ChartOptions,
1023) -> Result<usize, McpError>
1024where
1025    <DB as DrawingBackend>::ErrorType: 'static,
1026{
1027    let x_col = require_column(&opts.x_column, "x")?;
1028    let y_col = require_column(&opts.y_column, "y")?;
1029
1030    // Bar charts default to categorical x axis; `ChartOptions::x_as_category=Some(false)`
1031    // lets callers force numeric if they really want to. Bar charts never
1032    // use temporal mode — even time-series bar charts visually expect
1033    // discrete bars at evenly-spaced positions.
1034    let x_mode = if opts.x_as_category == Some(false) {
1035        XMode::Numeric
1036    } else {
1037        XMode::Categorical
1038    };
1039    let groups = group_series(rows, x_col, y_col, opts.series_column.as_deref(), x_mode)?;
1040
1041    let categories = collect_categories(&groups);
1042
1043    let x_min = -0.5_f64;
1044    let x_max = categories.len() as f64 - 0.5;
1045
1046    let y_min = groups
1047        .values()
1048        .flat_map(|pts| pts.iter().map(|(_, y, _)| *y))
1049        .fold(f64::INFINITY, f64::min)
1050        .min(0.0);
1051    let y_max = groups
1052        .values()
1053        .flat_map(|pts| pts.iter().map(|(_, y, _)| *y))
1054        .fold(f64::NEG_INFINITY, f64::max)
1055        .max(0.0);
1056    let y_pad = (y_max - y_min).abs() * 0.1 + 1.0;
1057
1058    let title = opts
1059        .title
1060        .clone()
1061        .unwrap_or_else(|| format!("{y_col} by {x_col}"));
1062
1063    let mut chart = ChartBuilder::on(root)
1064        .caption(&title, ("sans-serif", 22))
1065        .margin(10)
1066        .x_label_area_size(60)
1067        .y_label_area_size(70)
1068        .build_cartesian_2d(x_min..x_max, (y_min - y_pad)..(y_max + y_pad))
1069        .map_err(draw_err)?;
1070
1071    let raw_labels: Vec<String> = categories.iter().map(|(_, l)| l.clone()).collect();
1072    let labels = strip_shared_tz_suffix(&raw_labels);
1073    let tick_count = auto_tick_count(&labels, opts.width);
1074    chart
1075        .configure_mesh()
1076        .x_labels(tick_count)
1077        .x_label_formatter(&|v| {
1078            #[expect(
1079                clippy::cast_possible_truncation,
1080                reason = "axis tick value originated as an integer index into `labels`; the subsequent `usize::try_from` + length check make out-of-range ticks render as the empty-string branch"
1081            )]
1082            let idx = v.round() as isize;
1083            usize::try_from(idx)
1084                .ok()
1085                .and_then(|i| labels.get(i).cloned())
1086                .unwrap_or_default()
1087        })
1088        .y_desc(y_col)
1089        .x_desc(x_col)
1090        .draw()
1091        .map_err(draw_err)?;
1092
1093    let num_series = groups.len().max(1);
1094    let total_width = 0.8_f64;
1095    let bar_width = total_width / num_series as f64;
1096    let mut total_plotted = 0usize;
1097    for (idx, (series_key, pts)) in groups.iter().enumerate() {
1098        let color = series_color_for(series_key, idx, opts);
1099        let offset = -total_width / 2.0 + bar_width * (idx as f64 + 0.5);
1100        let name = if series_key.is_empty() {
1101            y_col.to_string()
1102        } else {
1103            series_key.clone()
1104        };
1105        chart
1106            .draw_series(pts.iter().map(|(x, y, _)| {
1107                let left = x + offset - bar_width / 2.0;
1108                let right = x + offset + bar_width / 2.0;
1109                Rectangle::new([(left, 0.0), (right, *y)], color.filled())
1110            }))
1111            .map_err(draw_err)?
1112            .label(name)
1113            .legend(move |(x, y)| Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled()));
1114        total_plotted += pts.len();
1115    }
1116
1117    chart
1118        .configure_series_labels()
1119        .background_style(colors::WHITE.mix(0.9))
1120        .border_style(colors::BLACK)
1121        .draw()
1122        .map_err(draw_err)?;
1123
1124    root.present().map_err(draw_err)?;
1125    Ok(total_plotted)
1126}
1127
1128fn draw_line<DB: DrawingBackend>(
1129    root: &DrawingArea<DB, plotters::coord::Shift>,
1130    rows: &[Value],
1131    opts: &ChartOptions,
1132) -> Result<usize, McpError>
1133where
1134    <DB as DrawingBackend>::ErrorType: 'static,
1135{
1136    line_or_scatter(root, rows, opts, true)
1137}
1138
1139fn draw_scatter<DB: DrawingBackend>(
1140    root: &DrawingArea<DB, plotters::coord::Shift>,
1141    rows: &[Value],
1142    opts: &ChartOptions,
1143) -> Result<usize, McpError>
1144where
1145    <DB as DrawingBackend>::ErrorType: 'static,
1146{
1147    line_or_scatter(root, rows, opts, false)
1148}
1149
1150#[expect(
1151    clippy::similar_names,
1152    reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones"
1153)]
1154/// Shared implementation for line and scatter charts. `connect_points` controls
1155/// whether successive points are joined with a line.
1156fn line_or_scatter<DB: DrawingBackend>(
1157    root: &DrawingArea<DB, plotters::coord::Shift>,
1158    rows: &[Value],
1159    opts: &ChartOptions,
1160    connect_points: bool,
1161) -> Result<usize, McpError>
1162where
1163    <DB as DrawingBackend>::ErrorType: 'static,
1164{
1165    let x_col = require_column(&opts.x_column, "x")?;
1166    let y_col = require_column(&opts.y_column, "y")?;
1167    // Decide the x mode:
1168    // - Explicit `x_as_category=Some(true)` → Categorical (force).
1169    // - Explicit `x_as_category=Some(false)` → Numeric (force).
1170    // - Default (None): peek at the first row's x value:
1171    //   - parses as DATE/TIMESTAMP/TIMESTAMPTZ → Temporal (proportional time axis).
1172    //   - non-numeric (TEXT) → Categorical fallback.
1173    //   - numeric → Numeric.
1174    let x_mode = match opts.x_as_category {
1175        Some(true) => XMode::Categorical,
1176        Some(false) => XMode::Numeric,
1177        None => detect_line_x_mode(rows, x_col),
1178    };
1179    let groups = group_series(rows, x_col, y_col, opts.series_column.as_deref(), x_mode)?;
1180
1181    let auto = bounds(&groups);
1182    let (rx_min, rx_max, ry_min, ry_max) = apply_ranges(auto, opts);
1183
1184    let default_title = if connect_points {
1185        "Line chart"
1186    } else {
1187        "Scatter plot"
1188    };
1189    let title = opts.title.clone().unwrap_or_else(|| default_title.into());
1190
1191    let mut chart = ChartBuilder::on(root)
1192        .caption(&title, ("sans-serif", 22))
1193        .margin(10)
1194        .x_label_area_size(match x_mode {
1195            XMode::Categorical | XMode::Temporal(_) => 60,
1196            XMode::Numeric => 50,
1197        })
1198        .y_label_area_size(70)
1199        .build_cartesian_2d(rx_min..rx_max, ry_min..ry_max)
1200        .map_err(draw_err)?;
1201
1202    // Configure the x-axis ticks per mode:
1203    // - Categorical: tick positions are synthetic indices; the formatter
1204    //   maps each back to the original string label.
1205    // - Temporal: tick positions are real Unix epoch seconds (proportional
1206    //   to wall-clock time); the formatter renders each via chrono in a
1207    //   format matching the input kind (DATE / TIMESTAMP / TIMESTAMPTZ).
1208    // - Numeric: pass-through; plotters' default float formatter is fine.
1209    match x_mode {
1210        XMode::Categorical => {
1211            let categories = collect_categories(&groups);
1212            let raw_labels: Vec<String> = categories.iter().map(|(_, l)| l.clone()).collect();
1213            let labels = strip_shared_tz_suffix(&raw_labels);
1214            let tick_count = auto_tick_count(&labels, opts.width);
1215            chart
1216                .configure_mesh()
1217                .x_desc(x_col)
1218                .y_desc(y_col)
1219                .x_labels(tick_count)
1220                .x_label_formatter(&|v| {
1221                    #[expect(
1222                        clippy::cast_possible_truncation,
1223                        reason = "axis tick value originated as an integer index into `labels`; the subsequent `usize::try_from` + length check make out-of-range ticks render as the empty-string branch"
1224                    )]
1225                    let idx = v.round() as isize;
1226                    usize::try_from(idx)
1227                        .ok()
1228                        .and_then(|i| labels.get(i).cloned())
1229                        .unwrap_or_default()
1230                })
1231                .draw()
1232                .map_err(draw_err)?;
1233        }
1234        XMode::Temporal(kind) => {
1235            // Sample one rendered tick label to size the per-tick budget.
1236            // DATE → 10 chars, TIMESTAMP → 19, TIMESTAMPTZ → 25 (with
1237            // `+HH:MM`). Floor at 10 so a degenerate sample still gets
1238            // a reasonable per-label budget.
1239            let sample = format_temporal_tick(rx_min, kind);
1240            let sample_chars = sample.chars().count().max(10);
1241            let tick_count = tick_count_for_label_width(sample_chars, opts.width);
1242            chart
1243                .configure_mesh()
1244                .x_desc(x_col)
1245                .y_desc(y_col)
1246                .x_labels(tick_count)
1247                .x_label_formatter(&|v| format_temporal_tick(*v, kind))
1248                .draw()
1249                .map_err(draw_err)?;
1250        }
1251        XMode::Numeric => {
1252            chart
1253                .configure_mesh()
1254                .x_desc(x_col)
1255                .y_desc(y_col)
1256                .draw()
1257                .map_err(draw_err)?;
1258        }
1259    }
1260
1261    let mut total_plotted = 0usize;
1262    for (idx, (series_key, pts)) in groups.iter().enumerate() {
1263        let color = series_color_for(series_key, idx, opts);
1264        let name = if series_key.is_empty() {
1265            y_col.to_string()
1266        } else {
1267            series_key.clone()
1268        };
1269        let mut sorted = pts.clone();
1270        if connect_points {
1271            sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1272        }
1273
1274        if opts.label_points {
1275            // Draw dots/lines without registering a legend entry, then
1276            // annotate each point with the series name as a text label.
1277            if connect_points {
1278                chart
1279                    .draw_series(LineSeries::new(
1280                        sorted.iter().map(|(x, y, _)| (*x, *y)),
1281                        color.stroke_width(2),
1282                    ))
1283                    .map_err(draw_err)?;
1284            } else {
1285                chart
1286                    .draw_series(
1287                        sorted
1288                            .iter()
1289                            .map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())),
1290                    )
1291                    .map_err(draw_err)?;
1292            }
1293            // Text label offset: right+above by default. When the dot is in
1294            // the right 25% of the x range, flip the label left so it stays
1295            // inside the chart area. When near the bottom 15% of y, flip up
1296            // so the label isn't below the axis line.
1297            let x_flip_threshold = rx_min + (rx_max - rx_min) * 0.75;
1298            let y_flip_threshold = ry_min + (ry_max - ry_min) * 0.15;
1299            let label_style = ("sans-serif", 11).into_font().color(&BLACK);
1300            chart
1301                .draw_series(sorted.iter().map(|(x, y, _)| {
1302                    let label = name.clone();
1303                    // Estimate pixel width: ~7px per Unicode character for 11pt font.
1304                    // This is still approximate but handles multi-byte UTF-8 correctly.
1305                    //
1306                    // Series label lengths in MCP outputs are bounded well under
1307                    // 10k characters; saturating at `i32::MAX` is the right
1308                    // behavior for a pixel offset anyway — anything larger
1309                    // would already be off-canvas.
1310                    let char_px = i32::try_from(label.chars().count())
1311                        .unwrap_or(i32::MAX)
1312                        .saturating_mul(7);
1313                    let x_off = if *x >= x_flip_threshold {
1314                        -(char_px + 6)
1315                    } else {
1316                        6
1317                    };
1318                    let y_off = if *y <= y_flip_threshold { -20 } else { -12 };
1319                    EmptyElement::at((*x, *y))
1320                        + Text::new(label, (x_off, y_off), label_style.clone())
1321                }))
1322                .map_err(draw_err)?;
1323        } else {
1324            // Default: dots/lines with legend entry.
1325            if connect_points {
1326                chart
1327                    .draw_series(LineSeries::new(
1328                        sorted.iter().map(|(x, y, _)| (*x, *y)),
1329                        color.stroke_width(2),
1330                    ))
1331                    .map_err(draw_err)?
1332                    .label(name)
1333                    .legend(move |(x, y)| {
1334                        PathElement::new(vec![(x, y), (x + 16, y)], color.stroke_width(2))
1335                    });
1336            } else {
1337                chart
1338                    .draw_series(
1339                        sorted
1340                            .iter()
1341                            .map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())),
1342                    )
1343                    .map_err(draw_err)?
1344                    .label(name)
1345                    .legend(move |(x, y)| Circle::new((x + 8, y), 4, color.filled()));
1346            }
1347        }
1348        total_plotted += pts.len();
1349    }
1350
1351    // Only draw the legend box when label_points is off — with labels
1352    // on the dots, the legend is redundant and takes up chart space.
1353    if !opts.label_points {
1354        chart
1355            .configure_series_labels()
1356            .background_style(colors::WHITE.mix(0.9))
1357            .border_style(colors::BLACK)
1358            .draw()
1359            .map_err(draw_err)?;
1360    }
1361
1362    root.present().map_err(draw_err)?;
1363    Ok(total_plotted)
1364}
1365
1366fn bounds(groups: &SeriesMap) -> (f64, f64, f64, f64) {
1367    let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
1368    let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
1369    for pts in groups.values() {
1370        for (x, y, _) in pts {
1371            if *x < x_min {
1372                x_min = *x;
1373            }
1374            if *x > x_max {
1375                x_max = *x;
1376            }
1377            if *y < y_min {
1378                y_min = *y;
1379            }
1380            if *y > y_max {
1381                y_max = *y;
1382            }
1383        }
1384    }
1385    if !x_min.is_finite() {
1386        x_min = 0.0;
1387    }
1388    if !x_max.is_finite() {
1389        x_max = 1.0;
1390    }
1391    if !y_min.is_finite() {
1392        y_min = 0.0;
1393    }
1394    if !y_max.is_finite() {
1395        y_max = 1.0;
1396    }
1397    if (x_max - x_min).abs() < 1e-12 {
1398        x_max = x_min + 1.0;
1399    }
1400    if (y_max - y_min).abs() < 1e-12 {
1401        y_max = y_min + 1.0;
1402    }
1403    (x_min, x_max, y_min, y_max)
1404}
1405
1406#[expect(
1407    clippy::similar_names,
1408    reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones"
1409)]
1410/// Apply optional fixed-range overrides from `ChartOptions`, returning the
1411/// final `(x_min, x_max, y_min, y_max)` to pass to `build_cartesian_2d`.
1412///
1413/// When a range is provided the auto-computed bound is replaced entirely —
1414/// no padding is added on the overridden axes. Auto-computed axes still
1415/// receive their normal 5% padding so they don't clip the outermost point.
1416fn apply_ranges(auto: (f64, f64, f64, f64), opts: &ChartOptions) -> (f64, f64, f64, f64) {
1417    let (x_min, x_max, y_min, y_max) = auto;
1418    let x_pad = (x_max - x_min).abs() * 0.05 + 1e-9;
1419    let y_pad = (y_max - y_min).abs() * 0.05 + 1e-9;
1420    let (final_x_min, final_x_max) = match opts.x_range {
1421        Some([lo, hi]) => (lo, hi),
1422        None => (x_min - x_pad, x_max + x_pad),
1423    };
1424    let (final_y_min, final_y_max) = match opts.y_range {
1425        Some([lo, hi]) => (lo, hi),
1426        None => (y_min - y_pad, y_max + y_pad),
1427    };
1428    (final_x_min, final_x_max, final_y_min, final_y_max)
1429}
1430
1431fn draw_histogram<DB: DrawingBackend>(
1432    root: &DrawingArea<DB, plotters::coord::Shift>,
1433    rows: &[Value],
1434    opts: &ChartOptions,
1435) -> Result<usize, McpError>
1436where
1437    <DB as DrawingBackend>::ErrorType: 'static,
1438{
1439    // Histograms use a single numeric column. Prefer x_column, fall back to y_column.
1440    let col = opts
1441        .x_column
1442        .as_deref()
1443        .or(opts.y_column.as_deref())
1444        .ok_or_else(|| {
1445            McpError::new(
1446                ErrorCode::SchemaMismatch,
1447                "Histogram requires an 'x' or 'y' column name",
1448            )
1449        })?;
1450
1451    let values: Vec<f64> = rows
1452        .iter()
1453        .filter_map(|r| r.as_object().and_then(|o| o.get(col)).and_then(as_number))
1454        .collect();
1455    if values.is_empty() {
1456        return Err(McpError::new(
1457            ErrorCode::SchemaMismatch,
1458            format!("Column '{col}' has no numeric values to histogram"),
1459        ));
1460    }
1461
1462    let bin_count = opts.bins.max(1) as usize;
1463    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
1464    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1465    let span = if (max - min).abs() < 1e-12 {
1466        1.0
1467    } else {
1468        max - min
1469    };
1470    let bin_width = span / bin_count as f64;
1471
1472    let mut bins = vec![0u64; bin_count];
1473    for v in &values {
1474        // Histogram bin index: `floor((v - min) / bin_width)` is finite and
1475        // lies in `[0, bin_count)` for well-formed inputs; we still clamp
1476        // with `.max(0).min(bin_count - 1)` to defend against NaN/rounding.
1477        // The narrowing to `isize` / `usize` is therefore a reinterpret of a
1478        // value we have just bounded to a small non-negative integer.
1479        #[expect(
1480            clippy::cast_possible_truncation,
1481            clippy::cast_sign_loss,
1482            reason = "bin index is clamped into `[0, bin_count)` on the surrounding lines, so the narrowing f64→isize→usize is a reinterpret of an already-bounded small integer"
1483        )]
1484        let idx = (((*v - min) / bin_width).floor() as isize).max(0) as usize;
1485        let idx = idx.min(bin_count - 1);
1486        bins[idx] += 1;
1487    }
1488
1489    let y_max = *bins.iter().max().unwrap_or(&1) as f64;
1490    let title = opts
1491        .title
1492        .clone()
1493        .unwrap_or_else(|| format!("Distribution of {col}"));
1494
1495    let mut chart = ChartBuilder::on(root)
1496        .caption(&title, ("sans-serif", 22))
1497        .margin(10)
1498        .x_label_area_size(50)
1499        .y_label_area_size(60)
1500        .build_cartesian_2d(min..(max + bin_width * 0.01), 0.0..(y_max * 1.1 + 1.0))
1501        .map_err(draw_err)?;
1502
1503    chart
1504        .configure_mesh()
1505        .x_desc(col)
1506        .y_desc("count")
1507        .draw()
1508        .map_err(draw_err)?;
1509
1510    let color = series_color(0);
1511    chart
1512        .draw_series(bins.iter().enumerate().map(|(i, count)| {
1513            let left = min + bin_width * i as f64;
1514            let right = left + bin_width;
1515            Rectangle::new([(left, 0.0), (right, *count as f64)], color.filled())
1516        }))
1517        .map_err(draw_err)?;
1518
1519    root.present().map_err(draw_err)?;
1520    Ok(values.len())
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525    use super::*;
1526
1527    fn s(strs: &[&str]) -> Vec<String> {
1528        strs.iter().map(|s| (*s).to_string()).collect()
1529    }
1530
1531    #[test]
1532    fn strip_shared_tz_suffix_drops_uniform_offset() {
1533        let labels = s(&[
1534            "2026-05-01 08:00:00+00:00",
1535            "2026-05-02 06:15:00+00:00",
1536            "2026-05-03 18:30:00+00:00",
1537        ]);
1538        let stripped = strip_shared_tz_suffix(&labels);
1539        assert_eq!(
1540            stripped,
1541            s(&[
1542                "2026-05-01 08:00:00",
1543                "2026-05-02 06:15:00",
1544                "2026-05-03 18:30:00",
1545            ])
1546        );
1547    }
1548
1549    #[test]
1550    fn strip_shared_tz_suffix_handles_non_utc_offset() {
1551        let labels = s(&["2026-05-01 08:00:00+05:30", "2026-05-02 06:15:00+05:30"]);
1552        let stripped = strip_shared_tz_suffix(&labels);
1553        assert_eq!(
1554            stripped,
1555            s(&["2026-05-01 08:00:00", "2026-05-02 06:15:00",])
1556        );
1557    }
1558
1559    #[test]
1560    fn strip_shared_tz_suffix_preserves_when_offsets_differ() {
1561        let labels = s(&["2026-05-01 08:00:00+00:00", "2026-05-02 06:15:00+05:30"]);
1562        let stripped = strip_shared_tz_suffix(&labels);
1563        assert_eq!(stripped, labels, "differing offsets must not be stripped");
1564    }
1565
1566    #[test]
1567    fn strip_shared_tz_suffix_preserves_plain_dates() {
1568        let labels = s(&["2026-05-01", "2026-05-02", "2026-05-03"]);
1569        let stripped = strip_shared_tz_suffix(&labels);
1570        assert_eq!(stripped, labels, "DATE strings have no suffix to strip");
1571    }
1572
1573    #[test]
1574    fn strip_shared_tz_suffix_passes_through_one_or_zero() {
1575        assert_eq!(strip_shared_tz_suffix(&[]), Vec::<String>::new());
1576        let one = s(&["2026-05-01 08:00:00+00:00"]);
1577        assert_eq!(strip_shared_tz_suffix(&one), one);
1578    }
1579
1580    #[test]
1581    fn auto_tick_count_returns_all_when_labels_fit() {
1582        // 5 short labels at width 800 — all fit comfortably.
1583        let labels = s(&["A", "B", "C", "D", "E"]);
1584        assert_eq!(auto_tick_count(&labels, 800), 5);
1585    }
1586
1587    #[test]
1588    fn auto_tick_count_thins_long_timestamp_series() {
1589        // 90 points like "2026-01-01 13:00:00" (19 chars).
1590        // per_label_px = 19*7 + 10 = 143; fits = 800/143 = 5.
1591        // The fix's contract: the count plotters is told MUST be ≥ 2
1592        // (so the axis stays informative) and ≤ labels.len(); for a
1593        // 19-char label at 800px the heuristic should land in the
1594        // 4..=8 band — comfortably small enough that no two adjacent
1595        // ticks overlap.
1596        let labels: Vec<String> = (0..90)
1597            .map(|i| format!("2026-01-{:02} {:02}:00:00", (i / 24) + 1, i % 24))
1598            .collect();
1599        let count = auto_tick_count(&labels, 800);
1600        assert!(
1601            (4..=8).contains(&count),
1602            "expected 4..=8 ticks for 90 long labels at 800px, got {count}"
1603        );
1604        assert!(count >= 2, "must always show at least 2 ticks");
1605        assert!(count <= labels.len(), "must never exceed label count");
1606    }
1607
1608    #[test]
1609    fn auto_tick_count_clamps_to_at_least_two() {
1610        // Hypothetical: extremely wide labels at narrow chart width.
1611        let labels = s(&[
1612            "x".repeat(200).as_str(),
1613            "y".repeat(200).as_str(),
1614            "z".repeat(200).as_str(),
1615        ]);
1616        assert!(auto_tick_count(&labels, 100) >= 2);
1617    }
1618
1619    #[test]
1620    fn auto_tick_count_handles_one_or_zero_labels() {
1621        assert_eq!(auto_tick_count(&[], 800), 0);
1622        let one = s(&["only"]);
1623        assert_eq!(auto_tick_count(&one, 800), 1);
1624    }
1625
1626    #[test]
1627    fn auto_tick_count_caps_at_label_count() {
1628        // Tiny labels at huge width — heuristic would say "many", but
1629        // we should never exceed the actual label count.
1630        let labels = s(&["A", "B", "C"]);
1631        assert_eq!(auto_tick_count(&labels, 10_000), 3);
1632    }
1633
1634    #[test]
1635    fn tick_count_for_label_width_does_not_clamp_to_label_count() {
1636        // The width-only helper has no label-count input, so a 19-char
1637        // estimate at 800px must compute fits=5 directly. Regression
1638        // guard against the bug where an over-eager `min(labels.len())`
1639        // collapsed the temporal-mode tick budget to 2.
1640        // 19 chars * 7 + 10 = 143px → 800/143 = 5, 1400/143 = 9.
1641        assert_eq!(tick_count_for_label_width(19, 800), 5);
1642        assert_eq!(tick_count_for_label_width(19, 1400), 9);
1643        // 10 chars * 7 + 10 = 80px → 800/80 = 10. DATE-only fits more.
1644        assert_eq!(tick_count_for_label_width(10, 800), 10);
1645    }
1646
1647    #[test]
1648    fn tick_count_for_label_width_clamps_to_at_least_two() {
1649        assert_eq!(tick_count_for_label_width(200, 100), 2);
1650    }
1651
1652    #[test]
1653    fn parse_temporal_recognizes_date() {
1654        let (kind, secs) = parse_temporal("2026-05-01").expect("DATE should parse");
1655        assert_eq!(kind, TemporalKind::Date);
1656        // Sanity: well after the epoch.
1657        assert!(secs > 1.7e9);
1658    }
1659
1660    #[test]
1661    fn parse_temporal_recognizes_timestamp() {
1662        let (kind, secs1) = parse_temporal("2026-05-01 08:00:00").expect("TIMESTAMP should parse");
1663        assert_eq!(kind, TemporalKind::DateTime);
1664        let (_, secs2) = parse_temporal("2026-05-01 12:30:00").expect("TIMESTAMP should parse");
1665        // Same date, 4.5 hours apart.
1666        let delta = secs2 - secs1;
1667        assert!(
1668            (delta - 16_200.0).abs() < 1.0,
1669            "expected 16200s gap, got {delta}"
1670        );
1671    }
1672
1673    #[test]
1674    fn parse_temporal_recognizes_timestamptz_and_captures_offset() {
1675        let (kind, _) =
1676            parse_temporal("2026-05-01 08:00:00+05:30").expect("TIMESTAMPTZ should parse");
1677        match kind {
1678            TemporalKind::DateTimeTz(off) => assert_eq!(off, 5 * 3600 + 30 * 60),
1679            other => panic!("expected DateTimeTz, got {other:?}"),
1680        }
1681    }
1682
1683    #[test]
1684    fn parse_temporal_recognizes_t_separator() {
1685        let (kind, _) =
1686            parse_temporal("2026-05-01T08:00:00+00:00").expect("ISO T-form should parse");
1687        assert!(matches!(kind, TemporalKind::DateTimeTz(0)));
1688    }
1689
1690    #[test]
1691    fn parse_temporal_rejects_non_temporal_strings() {
1692        assert!(parse_temporal("alpha").is_none());
1693        assert!(parse_temporal("").is_none());
1694        assert!(parse_temporal("2026").is_none());
1695        // Numeric strings are NOT temporal — caller should treat as numeric.
1696        assert!(parse_temporal("42").is_none());
1697    }
1698
1699    #[test]
1700    fn format_temporal_tick_round_trips_date() {
1701        let (_, secs) = parse_temporal("2026-05-01").unwrap();
1702        assert_eq!(format_temporal_tick(secs, TemporalKind::Date), "2026-05-01");
1703    }
1704
1705    #[test]
1706    fn format_temporal_tick_round_trips_timestamp() {
1707        let (_, secs) = parse_temporal("2026-05-01 08:30:00").unwrap();
1708        assert_eq!(
1709            format_temporal_tick(secs, TemporalKind::DateTime),
1710            "2026-05-01 08:30:00"
1711        );
1712    }
1713
1714    #[test]
1715    fn format_temporal_tick_preserves_offset_for_timestamptz() {
1716        let (kind, secs) = parse_temporal("2026-05-01 08:30:00+05:30").unwrap();
1717        assert_eq!(
1718            format_temporal_tick(secs, kind),
1719            "2026-05-01 08:30:00+05:30"
1720        );
1721    }
1722
1723    #[test]
1724    fn format_temporal_tick_handles_nan() {
1725        // Plotters can theoretically pass NaN/infinity for axis ticks
1726        // when the range is degenerate. We must not panic.
1727        assert_eq!(format_temporal_tick(f64::NAN, TemporalKind::Date), "");
1728        assert_eq!(
1729            format_temporal_tick(f64::INFINITY, TemporalKind::DateTime),
1730            ""
1731        );
1732    }
1733
1734    #[test]
1735    fn detect_line_x_mode_picks_temporal_for_dates() {
1736        let rows = vec![serde_json::json!({"ts": "2026-05-01"})];
1737        let mode = detect_line_x_mode(&rows, "ts");
1738        assert!(matches!(mode, XMode::Temporal(TemporalKind::Date)));
1739    }
1740
1741    #[test]
1742    fn detect_line_x_mode_picks_temporal_for_timestamps() {
1743        let rows = vec![serde_json::json!({"ts": "2026-05-01 08:00:00"})];
1744        let mode = detect_line_x_mode(&rows, "ts");
1745        assert!(matches!(mode, XMode::Temporal(TemporalKind::DateTime)));
1746    }
1747
1748    #[test]
1749    fn detect_line_x_mode_picks_temporal_for_timestamptz() {
1750        let rows = vec![serde_json::json!({"ts": "2026-05-01 08:00:00+00:00"})];
1751        let mode = detect_line_x_mode(&rows, "ts");
1752        assert!(matches!(mode, XMode::Temporal(TemporalKind::DateTimeTz(0))));
1753    }
1754
1755    #[test]
1756    fn detect_line_x_mode_falls_back_to_categorical_for_text() {
1757        let rows = vec![serde_json::json!({"x": "alpha"})];
1758        let mode = detect_line_x_mode(&rows, "x");
1759        assert!(matches!(mode, XMode::Categorical));
1760    }
1761
1762    #[test]
1763    fn detect_line_x_mode_picks_numeric_for_numbers() {
1764        let rows = vec![serde_json::json!({"x": 42.0})];
1765        let mode = detect_line_x_mode(&rows, "x");
1766        assert!(matches!(mode, XMode::Numeric));
1767    }
1768}