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** — numeric x-axis by default; set `x_as_category = true` for DATE/string x.
14//! - **Scatter** — numeric x-axis by default; same `x_as_category` option 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 plotters::prelude::*;
42use plotters::style::colors;
43use serde_json::Value;
44use std::collections::BTreeMap;
45
46/// A single chart series' data points.
47///
48/// Each entry is `(x, y, x_label)` where the numeric `x` drives
49/// positioning on the axis and `x_label` preserves the original
50/// string form of the x value so categorical axes can render
51/// human-readable tick labels (the `group_series` function maps
52/// category strings through a `BTreeMap<String, f64>` to assign
53/// stable, deterministic x positions).
54type SeriesPoints = Vec<(f64, f64, String)>;
55
56/// Series name → its points. Uses `BTreeMap` (not `HashMap`) so
57/// multi-series charts render in deterministic order, which makes
58/// the resulting image bytes reproducible across runs.
59type SeriesMap = BTreeMap<String, SeriesPoints>;
60
61/// Supported chart types.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ChartType {
64    Bar,
65    Line,
66    Scatter,
67    Histogram,
68}
69
70impl ChartType {
71    /// Parse a string into a [`ChartType`].
72    ///
73    /// # Errors
74    ///
75    /// Returns [`ErrorCode::SchemaMismatch`] if `s` (case-insensitive) is
76    /// not one of `bar`, `line`, `scatter`, `histogram`, or `hist`.
77    pub fn parse(s: &str) -> Result<Self, McpError> {
78        match s.to_lowercase().as_str() {
79            "bar" => Ok(ChartType::Bar),
80            "line" => Ok(ChartType::Line),
81            "scatter" => Ok(ChartType::Scatter),
82            "histogram" | "hist" => Ok(ChartType::Histogram),
83            other => Err(McpError::new(
84                ErrorCode::SchemaMismatch,
85                format!(
86                    "Unknown chart type '{other}'. Expected one of: bar, line, scatter, histogram"
87                ),
88            )),
89        }
90    }
91}
92
93/// Output format for the rendered chart.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ChartFormat {
96    Png,
97    Svg,
98}
99
100impl ChartFormat {
101    /// Parse a string into a [`ChartFormat`].
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ErrorCode::UnsupportedFormat`] if `s` (case-insensitive)
106    /// is not `png` or `svg`.
107    pub fn parse(s: &str) -> Result<Self, McpError> {
108        match s.to_lowercase().as_str() {
109            "png" => Ok(ChartFormat::Png),
110            "svg" => Ok(ChartFormat::Svg),
111            other => Err(McpError::new(
112                ErrorCode::UnsupportedFormat,
113                format!("Unknown chart format '{other}'. Expected 'png' or 'svg'"),
114            )),
115        }
116    }
117
118    #[must_use]
119    pub fn mime_type(&self) -> &'static str {
120        match self {
121            ChartFormat::Png => "image/png",
122            ChartFormat::Svg => "image/svg+xml",
123        }
124    }
125
126    /// File extension without leading dot (`"png"` / `"svg"`). Used when
127    /// synthesizing default filenames under the system temp dir.
128    #[must_use]
129    pub fn extension(&self) -> &'static str {
130        match self {
131            ChartFormat::Png => "png",
132            ChartFormat::Svg => "svg",
133        }
134    }
135}
136
137/// Resolve the effective output format from an explicit `format` parameter
138/// and/or an `output_path`'s extension.
139///
140/// Rules:
141/// - Both set: they must agree. Conflict returns `InvalidArgument` naming
142///   both values so the caller can fix one.
143/// - Only `format` set: parse it via [`ChartFormat::parse`].
144/// - Only `output_path` set: derive from its extension (`.png` / `.svg`).
145///   Unknown extensions return `InvalidArgument`.
146/// - Neither set: default to PNG (matches the pre-change behavior).
147///
148/// The path is only inspected for its extension — the file need not exist.
149///
150/// # Errors
151///
152/// - Returns [`ErrorCode::InvalidArgument`] if both `explicit_format` and
153///   `output_path` are set and they disagree on the format.
154/// - Propagates [`ErrorCode::UnsupportedFormat`] from [`ChartFormat::parse`]
155///   for unknown format strings.
156/// - Returns [`ErrorCode::InvalidArgument`] (via `format_from_extension`)
157///   when `output_path` has an extension other than `.png` or `.svg`.
158pub fn resolve_chart_format(
159    explicit_format: Option<&str>,
160    output_path: Option<&str>,
161) -> Result<ChartFormat, McpError> {
162    let ext_from_path = output_path.and_then(extract_extension);
163
164    match (explicit_format, ext_from_path.as_deref()) {
165        (Some(f), Some(ext)) => {
166            let from_format = ChartFormat::parse(f)?;
167            let from_ext = format_from_extension(ext)?;
168            if from_format != from_ext {
169                return Err(McpError::new(
170                    ErrorCode::InvalidArgument,
171                    format!(
172                        "chart: format=\"{f}\" conflicts with output_path extension \".{ext}\" — \
173                         remove one or make them agree"
174                    ),
175                ));
176            }
177            Ok(from_format)
178        }
179        (Some(f), None) => ChartFormat::parse(f),
180        (None, Some(ext)) => format_from_extension(ext),
181        (None, None) => Ok(ChartFormat::Png),
182    }
183}
184
185/// Lowercase extension of `path` with the leading dot stripped, or `None`
186/// if the path has no extension or a non-UTF-8 extension.
187fn extract_extension(path: &str) -> Option<String> {
188    std::path::Path::new(path)
189        .extension()
190        .and_then(|e| e.to_str())
191        .map(str::to_ascii_lowercase)
192}
193
194/// Map a file extension (no leading dot, lowercased) to a `ChartFormat`.
195/// Unknown extensions return `InvalidArgument` with a list of what's allowed.
196fn format_from_extension(ext: &str) -> Result<ChartFormat, McpError> {
197    match ext {
198        "png" => Ok(ChartFormat::Png),
199        "svg" => Ok(ChartFormat::Svg),
200        other => Err(McpError::new(
201            ErrorCode::InvalidArgument,
202            format!(
203                "chart: unsupported output_path extension \".{other}\" (use .png or .svg, \
204                 or omit output_path to auto-generate one)"
205            ),
206        )),
207    }
208}
209
210/// How the `chart` tool should deliver the rendered image: write it to
211/// disk, return it inline in the MCP tool result, or both. This is a
212/// pure decision based on the caller's `inline` / `output_path` flags —
213/// no I/O happens here; `write_chart_to_disk` does the actual write.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum ChartDisposition {
216    /// Write to `path`, don't return inline. Path is either caller-supplied
217    /// or auto-generated under the system temp dir.
218    WriteOnly { path: std::path::PathBuf },
219    /// Return inline, don't write to disk.
220    InlineOnly,
221    /// Write to `path` and also return inline.
222    WriteAndInline { path: std::path::PathBuf },
223}
224
225impl ChartDisposition {
226    /// The target path, if any. `InlineOnly` has no path.
227    #[must_use]
228    pub fn path(&self) -> Option<&std::path::Path> {
229        match self {
230            ChartDisposition::WriteOnly { path } | ChartDisposition::WriteAndInline { path } => {
231                Some(path)
232            }
233            ChartDisposition::InlineOnly => None,
234        }
235    }
236
237    /// Whether to include `Content::image(...)` in the tool result.
238    #[must_use]
239    pub fn wants_inline(&self) -> bool {
240        matches!(
241            self,
242            ChartDisposition::InlineOnly | ChartDisposition::WriteAndInline { .. }
243        )
244    }
245}
246
247/// Decide what the chart tool should do with the rendered bytes based on
248/// the caller's `inline` and `output_path` flags plus the already-resolved
249/// `format`.
250///
251/// Semantics (see the `chart` tool docs):
252/// - `inline=true` + no path → `InlineOnly` (skip disk)
253/// - `inline=true` + path    → `WriteAndInline` (both)
254/// - `inline=false`/absent + path → `WriteOnly`
255/// - `inline=false`/absent + no path → `WriteOnly` with auto-generated path
256///   under `std::env::temp_dir()/hyperdb-charts/`
257///
258/// This is the default path most callers take: keeps the MCP transcript small
259/// by writing the PNG/SVG to disk and letting the caller `Read(path)` when
260/// they want to display it.
261#[must_use]
262pub fn resolve_chart_disposition(
263    inline: bool,
264    output_path: Option<&str>,
265    format: ChartFormat,
266) -> ChartDisposition {
267    match (inline, output_path) {
268        (true, None) => ChartDisposition::InlineOnly,
269        (true, Some(p)) => ChartDisposition::WriteAndInline {
270            path: std::path::PathBuf::from(p),
271        },
272        (false, Some(p)) => ChartDisposition::WriteOnly {
273            path: std::path::PathBuf::from(p),
274        },
275        (false, None) => ChartDisposition::WriteOnly {
276            path: auto_generated_chart_path(format),
277        },
278    }
279}
280
281/// Synthesize a unique path under `std::env::temp_dir()/hyperdb-charts/` for
282/// a default-disposition chart write. The filename encodes a monotonic
283/// counter + PID + unix-nanos so two calls in the same nanosecond (or on two
284/// hosts with sync'd clocks) don't collide.
285///
286/// The parent directory is *not* created here — the caller does that right
287/// before writing, to keep this function pure and cheap for testing.
288pub fn auto_generated_chart_path(format: ChartFormat) -> std::path::PathBuf {
289    use std::sync::atomic::{AtomicU64, Ordering};
290    static COUNTER: AtomicU64 = AtomicU64::new(0);
291
292    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
293    let pid = std::process::id();
294    let nanos = std::time::SystemTime::now()
295        .duration_since(std::time::UNIX_EPOCH)
296        .map_or(0, |d| d.as_nanos());
297
298    std::env::temp_dir().join("hyperdb-charts").join(format!(
299        "chart-{nanos}-{pid}-{counter}.{ext}",
300        ext = format.extension()
301    ))
302}
303
304/// Write chart bytes to `path`, creating the parent directory if needed and
305/// honoring the `overwrite` flag.
306///
307/// Errors:
308/// - `PermissionDenied` if `path` exists and `overwrite=false` (matches
309///   `export`'s pre-flight check).
310/// - `InternalError` wrapping the underlying `std::io::Error` for mkdir or
311///   write failures.
312///
313/// Returns the number of bytes written.
314///
315/// # Errors
316///
317/// - Returns [`ErrorCode::PermissionDenied`] if `path` exists and
318///   `overwrite` is `false`.
319/// - Returns [`ErrorCode::InternalError`] wrapping the underlying
320///   [`std::io::Error`] for `create_dir_all` or `write` failures.
321pub fn write_chart_to_disk(
322    path: &std::path::Path,
323    bytes: &[u8],
324    overwrite: bool,
325) -> Result<u64, McpError> {
326    // Reject `..` components to prevent traversal attacks via LLM-generated paths.
327    // (We can't canonicalize a non-existent path, but rejecting `..` covers the
328    // most common attack pattern.)
329    if path
330        .components()
331        .any(|c| matches!(c, std::path::Component::ParentDir))
332    {
333        return Err(McpError::new(
334            ErrorCode::InvalidArgument,
335            format!(
336                "Chart output path '{}' may not contain '..' components",
337                path.display()
338            ),
339        ));
340    }
341
342    if !overwrite && path.exists() {
343        return Err(McpError::new(
344            ErrorCode::PermissionDenied,
345            format!(
346                "Refusing to overwrite existing chart: {} (pass overwrite=true to replace it)",
347                path.display()
348            ),
349        ));
350    }
351
352    if let Some(parent) = path.parent() {
353        if !parent.as_os_str().is_empty() {
354            std::fs::create_dir_all(parent).map_err(|e| {
355                McpError::new(
356                    ErrorCode::InternalError,
357                    format!(
358                        "Failed to create parent directory for chart '{}': {e}",
359                        path.display()
360                    ),
361                )
362            })?;
363        }
364    }
365
366    std::fs::write(path, bytes).map_err(|e| {
367        McpError::new(
368            ErrorCode::InternalError,
369            format!("Failed to write chart to '{}': {e}", path.display()),
370        )
371    })?;
372
373    Ok(bytes.len() as u64)
374}
375
376/// User-facing chart configuration, parsed from MCP tool parameters.
377#[derive(Debug, Clone)]
378pub struct ChartOptions {
379    pub chart_type: ChartType,
380    pub x_column: Option<String>,
381    pub y_column: Option<String>,
382    pub series_column: Option<String>,
383    pub title: Option<String>,
384    pub format: ChartFormat,
385    pub width: u32,
386    pub height: u32,
387    pub bins: u32,
388    /// Override the chart-type-specific default for how the x column is
389    /// interpreted:
390    ///
391    /// - `None` (default): use the chart type's natural behavior — `Bar`
392    ///   treats x as categorical, `Line` / `Scatter` require numeric x.
393    /// - `Some(true)`: force categorical even on `Line` / `Scatter`.
394    ///   Essential for plotting values whose natural axis is a string /
395    ///   date / enum (e.g. `SELECT day, happiness_score` where `day` is a
396    ///   `DATE`).
397    /// - `Some(false)`: force numeric even on `Bar` (rarely useful — bar
398    ///   charts are almost always categorical).
399    ///
400    /// When categorical mode is active the rendered x axis uses the
401    /// original string representation of each distinct x value as its
402    /// tick label, in the order x values are first seen.
403    pub x_as_category: Option<bool>,
404    /// Fix the x-axis range as `[min, max]`. When set, auto-scaling is
405    /// skipped and all frames/charts share the same x extent. Useful for
406    /// side-by-side comparisons or animation where a consistent scale
407    /// matters. Ignored for bar charts (which use categorical positions).
408    pub x_range: Option<[f64; 2]>,
409    /// Fix the y-axis range as `[min, max]`. Same semantics as `x_range`.
410    pub y_range: Option<[f64; 2]>,
411    /// Map series names to hex colors (`"#rrggbb"`). Entries that match a
412    /// series name override the default palette; unmatched series still
413    /// cycle through palette colors. Only affects charts with a series
414    /// column; single-series charts use the first palette color as before.
415    pub color_map: std::collections::HashMap<String, RGBColor>,
416    /// When `true`, draw the series name as a text label next to each dot
417    /// on scatter (and each point on line) charts, and suppress the legend
418    /// entirely. Useful when each series has exactly one point (e.g. one
419    /// country per dot) and a legend would be redundant.
420    ///
421    /// Labels are drawn 6 pixels right and 4 pixels above the data point.
422    /// No collision avoidance is performed — for dense data the legend
423    /// (`label_points: false`, the default) is usually more readable.
424    pub label_points: bool,
425}
426
427impl Default for ChartOptions {
428    fn default() -> Self {
429        Self {
430            chart_type: ChartType::Bar,
431            x_column: None,
432            y_column: None,
433            series_column: None,
434            title: None,
435            format: ChartFormat::Png,
436            width: 800,
437            height: 480,
438            bins: 20,
439            x_as_category: None,
440            x_range: None,
441            y_range: None,
442            color_map: std::collections::HashMap::new(),
443            label_points: false,
444        }
445    }
446}
447
448/// Result of rendering a chart.
449#[derive(Debug)]
450pub struct ChartResult {
451    pub bytes: Vec<u8>,
452    pub mime_type: &'static str,
453    pub rows_plotted: usize,
454}
455
456/// Render a chart from a list of JSON row objects.
457///
458/// `rows` is expected to be the output of `execute_query_to_json`: each entry
459/// is a `Value::Object` with column name → value pairs. Non-object rows are
460/// skipped silently.
461///
462/// # Errors
463///
464/// - Returns [`ErrorCode::EmptyData`] if `rows` is empty.
465/// - Returns [`ErrorCode::SchemaMismatch`] if required columns named in
466///   `opts` are absent, if x or y columns cannot be interpreted as
467///   numeric for chart types that require numeric axes, or if a
468///   categorical axis produces zero distinct categories.
469/// - Returns [`ErrorCode::InternalError`] wrapping failures from the
470///   underlying `plotters` backend during rendering or PNG/SVG encoding.
471/// - Returns [`ErrorCode::InvalidArgument`] if the result set exceeds
472///   50,000 rows.
473pub fn render_chart(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
474    const MAX_CHART_ROWS: usize = 50_000;
475    if rows.is_empty() {
476        return Err(McpError::new(
477            ErrorCode::EmptyData,
478            "No rows returned from SQL query — nothing to chart",
479        ));
480    }
481    if rows.len() > MAX_CHART_ROWS {
482        return Err(McpError::new(
483            ErrorCode::InvalidArgument,
484            format!(
485                "Chart data has {} rows, exceeding the {MAX_CHART_ROWS}-row limit. \
486                 Add a LIMIT clause or aggregate your data to reduce row count.",
487                rows.len()
488            ),
489        )
490        .with_suggestion(format!(
491            "Add `LIMIT {MAX_CHART_ROWS}` to your query, or use GROUP BY to aggregate."
492        )));
493    }
494
495    match opts.format {
496        ChartFormat::Png => render_png(rows, opts),
497        ChartFormat::Svg => render_svg(rows, opts),
498    }
499}
500
501fn render_png(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
502    let tmp = tempfile::Builder::new()
503        .suffix(".png")
504        .tempfile()
505        .map_err(|e| {
506            McpError::new(
507                ErrorCode::InternalError,
508                format!("Cannot create temp PNG file: {e}"),
509            )
510        })?;
511    let path = tmp.path().to_path_buf();
512    let rows_plotted = {
513        let backend = BitMapBackend::new(&path, (opts.width, opts.height));
514        draw_on_backend(backend, rows, opts)?
515    };
516    let bytes = std::fs::read(&path).map_err(|e| {
517        McpError::new(
518            ErrorCode::InternalError,
519            format!("Cannot read rendered PNG: {e}"),
520        )
521    })?;
522    drop(tmp);
523    Ok(ChartResult {
524        bytes,
525        mime_type: ChartFormat::Png.mime_type(),
526        rows_plotted,
527    })
528}
529
530fn render_svg(rows: &[Value], opts: &ChartOptions) -> Result<ChartResult, McpError> {
531    let mut svg_string = String::new();
532    let rows_plotted = {
533        let backend = SVGBackend::with_string(&mut svg_string, (opts.width, opts.height));
534        draw_on_backend(backend, rows, opts)?
535    };
536    Ok(ChartResult {
537        bytes: svg_string.into_bytes(),
538        mime_type: ChartFormat::Svg.mime_type(),
539        rows_plotted,
540    })
541}
542
543/// Dispatch to the chart-type-specific drawing routine over an abstract backend.
544fn draw_on_backend<DB: DrawingBackend>(
545    backend: DB,
546    rows: &[Value],
547    opts: &ChartOptions,
548) -> Result<usize, McpError>
549where
550    <DB as DrawingBackend>::ErrorType: 'static,
551{
552    let root = backend.into_drawing_area();
553    root.fill(&WHITE).map_err(draw_err)?;
554
555    match opts.chart_type {
556        ChartType::Bar => draw_bar(&root, rows, opts),
557        ChartType::Line => draw_line(&root, rows, opts),
558        ChartType::Scatter => draw_scatter(&root, rows, opts),
559        ChartType::Histogram => draw_histogram(&root, rows, opts),
560    }
561}
562
563#[expect(
564    clippy::needless_pass_by_value,
565    reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
566)]
567fn draw_err<E: std::error::Error + Send + Sync + 'static>(e: DrawingAreaErrorKind<E>) -> McpError {
568    McpError::new(
569        ErrorCode::InternalError,
570        format!("Chart rendering error: {e}"),
571    )
572}
573
574#[expect(
575    clippy::ref_option,
576    reason = "matches callers that already hold `&Option<T>`; avoiding a `.as_ref()` dance at every call site"
577)]
578fn require_column<'a>(col: &'a Option<String>, role: &str) -> Result<&'a str, McpError> {
579    col.as_deref().ok_or_else(|| {
580        McpError::new(
581            ErrorCode::SchemaMismatch,
582            format!("The '{role}' column name is required for this chart type"),
583        )
584    })
585}
586
587fn as_number(v: &Value) -> Option<f64> {
588    match v {
589        Value::Number(n) => n.as_f64(),
590        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
591        _ => None,
592    }
593}
594
595fn as_string(v: &Value) -> String {
596    match v {
597        Value::String(s) => s.clone(),
598        Value::Null => String::new(),
599        other => other.to_string(),
600    }
601}
602
603/// Collect distinct x values and their original string labels from a
604/// [`SeriesMap`], in ascending x-value order.
605///
606/// Used by [`draw_bar`] (always) and by [`draw_line_or_scatter`] when
607/// `x_as_category=true`. The returned (`x_val`, label) pairs drive the
608/// `x_label_formatter` that renders axis ticks as strings — essential
609/// for charts over `DATE` / enum / name-keyed data where `x_val` is a
610/// synthetic sequential index assigned by `group_series`'s category
611/// mode rather than a meaningful number.
612fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> {
613    // Dedup by bit pattern so NaN handling stays consistent with how
614    // `BTreeMap<f64>` would behave (we store as `u64` bits because
615    // `f64: !Ord`). The final sort is by numeric value.
616    let mut seen: BTreeMap<u64, String> = BTreeMap::new();
617    for pts in groups.values() {
618        for (x, _y, label) in pts {
619            seen.entry(x.to_bits()).or_insert_with(|| label.clone());
620        }
621    }
622    let mut entries: Vec<_> = seen.into_iter().collect();
623    entries.sort_by(|a, b| {
624        f64::from_bits(a.0)
625            .partial_cmp(&f64::from_bits(b.0))
626            .unwrap_or(std::cmp::Ordering::Equal)
627    });
628    entries
629        .into_iter()
630        .map(|(bits, label)| (f64::from_bits(bits), label))
631        .collect()
632}
633
634/// Group rows into (`series_name`, points) buckets, extracting x and y values.
635/// When `series_col` is None, all points land in a single unnamed series.
636fn group_series(
637    rows: &[Value],
638    x_col: &str,
639    y_col: &str,
640    series_col: Option<&str>,
641    x_as_category: bool,
642) -> Result<SeriesMap, McpError> {
643    let mut groups: SeriesMap = BTreeMap::new();
644    let mut category_index: BTreeMap<String, f64> = BTreeMap::new();
645
646    for row in rows {
647        let Some(obj) = row.as_object() else { continue };
648
649        let y_val = obj.get(y_col).and_then(as_number).ok_or_else(|| {
650            McpError::new(
651                ErrorCode::SchemaMismatch,
652                format!("Column '{y_col}' is missing or not numeric in at least one row"),
653            )
654        })?;
655
656        let x_raw = obj.get(x_col).cloned().unwrap_or(Value::Null);
657        let x_label = as_string(&x_raw);
658        let x_val = if x_as_category {
659            let next = category_index.len() as f64;
660            *category_index.entry(x_label.clone()).or_insert(next)
661        } else {
662            as_number(&x_raw).ok_or_else(|| {
663                McpError::new(
664                    ErrorCode::SchemaMismatch,
665                    format!("Column '{x_col}' is missing or not numeric in at least one row"),
666                )
667            })?
668        };
669
670        let series_key = match series_col {
671            Some(s) => obj.get(s).map(as_string).unwrap_or_default(),
672            None => String::new(),
673        };
674
675        groups
676            .entry(series_key)
677            .or_default()
678            .push((x_val, y_val, x_label));
679    }
680
681    if groups.values().all(std::vec::Vec::is_empty) {
682        return Err(McpError::new(
683            ErrorCode::EmptyData,
684            "No valid data points after filtering",
685        ));
686    }
687
688    Ok(groups)
689}
690
691/// Pick a color from the palette by index, cycling as needed.
692fn series_color(idx: usize) -> RGBColor {
693    // 8 distinct colors that work on white background; cycles for more series.
694    const PALETTE: [RGBColor; 8] = [
695        RGBColor(31, 119, 180),  // muted blue
696        RGBColor(255, 127, 14),  // safety orange
697        RGBColor(44, 160, 44),   // cooked asparagus
698        RGBColor(214, 39, 40),   // brick red
699        RGBColor(148, 103, 189), // muted purple
700        RGBColor(140, 86, 75),   // chestnut brown
701        RGBColor(227, 119, 194), // raspberry yogurt pink
702        RGBColor(127, 127, 127), // middle gray
703    ];
704    PALETTE[idx % PALETTE.len()]
705}
706
707/// Resolve the color for `series_name`: check `color_map` first, fall back
708/// to the palette-by-index default so unmapped series still get a color.
709fn series_color_for(series_name: &str, idx: usize, opts: &ChartOptions) -> RGBColor {
710    opts.color_map
711        .get(series_name)
712        .copied()
713        .unwrap_or_else(|| series_color(idx))
714}
715
716/// Parse a `"#rrggbb"` hex string into an `RGBColor`. Returns `None` when
717/// the string is not in the expected format so callers can log and skip
718/// rather than hard-failing.
719#[must_use]
720pub fn parse_hex_color(s: &str) -> Option<RGBColor> {
721    let s = s.strip_prefix('#').unwrap_or(s);
722    if s.len() != 6 {
723        return None;
724    }
725    let r = u8::from_str_radix(&s[0..2], 16).ok()?;
726    let g = u8::from_str_radix(&s[2..4], 16).ok()?;
727    let b = u8::from_str_radix(&s[4..6], 16).ok()?;
728    Some(RGBColor(r, g, b))
729}
730
731fn draw_bar<DB: DrawingBackend>(
732    root: &DrawingArea<DB, plotters::coord::Shift>,
733    rows: &[Value],
734    opts: &ChartOptions,
735) -> Result<usize, McpError>
736where
737    <DB as DrawingBackend>::ErrorType: 'static,
738{
739    let x_col = require_column(&opts.x_column, "x")?;
740    let y_col = require_column(&opts.y_column, "y")?;
741
742    // Bar charts default to categorical x axis; `ChartOptions::x_as_category`
743    // lets callers force numeric if they really want to.
744    let x_as_category = opts.x_as_category.unwrap_or(true);
745    let groups = group_series(
746        rows,
747        x_col,
748        y_col,
749        opts.series_column.as_deref(),
750        x_as_category,
751    )?;
752
753    let categories = collect_categories(&groups);
754
755    let x_min = -0.5_f64;
756    let x_max = categories.len() as f64 - 0.5;
757
758    let y_min = groups
759        .values()
760        .flat_map(|pts| pts.iter().map(|(_, y, _)| *y))
761        .fold(f64::INFINITY, f64::min)
762        .min(0.0);
763    let y_max = groups
764        .values()
765        .flat_map(|pts| pts.iter().map(|(_, y, _)| *y))
766        .fold(f64::NEG_INFINITY, f64::max)
767        .max(0.0);
768    let y_pad = (y_max - y_min).abs() * 0.1 + 1.0;
769
770    let title = opts
771        .title
772        .clone()
773        .unwrap_or_else(|| format!("{y_col} by {x_col}"));
774
775    let mut chart = ChartBuilder::on(root)
776        .caption(&title, ("sans-serif", 22))
777        .margin(10)
778        .x_label_area_size(60)
779        .y_label_area_size(70)
780        .build_cartesian_2d(x_min..x_max, (y_min - y_pad)..(y_max + y_pad))
781        .map_err(draw_err)?;
782
783    let labels: Vec<String> = categories.iter().map(|(_, l)| l.clone()).collect();
784    chart
785        .configure_mesh()
786        .x_labels(categories.len().min(20))
787        .x_label_formatter(&|v| {
788            // Plotters passes us category-index floats that `collect_categories`
789            // generated from `0..labels.len()`; the round-trip stays within
790            // `isize` range. Clamp negative values to the out-of-range branch
791            // and rely on `usize::try_from` to surface any stray negative tick
792            // as an empty label rather than wrapping.
793            #[expect(
794                clippy::cast_possible_truncation,
795                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"
796            )]
797            let idx = v.round() as isize;
798            usize::try_from(idx)
799                .ok()
800                .and_then(|i| labels.get(i).cloned())
801                .unwrap_or_default()
802        })
803        .y_desc(y_col)
804        .x_desc(x_col)
805        .draw()
806        .map_err(draw_err)?;
807
808    let num_series = groups.len().max(1);
809    let total_width = 0.8_f64;
810    let bar_width = total_width / num_series as f64;
811    let mut total_plotted = 0usize;
812    for (idx, (series_key, pts)) in groups.iter().enumerate() {
813        let color = series_color_for(series_key, idx, opts);
814        let offset = -total_width / 2.0 + bar_width * (idx as f64 + 0.5);
815        let name = if series_key.is_empty() {
816            y_col.to_string()
817        } else {
818            series_key.clone()
819        };
820        chart
821            .draw_series(pts.iter().map(|(x, y, _)| {
822                let left = x + offset - bar_width / 2.0;
823                let right = x + offset + bar_width / 2.0;
824                Rectangle::new([(left, 0.0), (right, *y)], color.filled())
825            }))
826            .map_err(draw_err)?
827            .label(name)
828            .legend(move |(x, y)| Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled()));
829        total_plotted += pts.len();
830    }
831
832    chart
833        .configure_series_labels()
834        .background_style(colors::WHITE.mix(0.9))
835        .border_style(colors::BLACK)
836        .draw()
837        .map_err(draw_err)?;
838
839    root.present().map_err(draw_err)?;
840    Ok(total_plotted)
841}
842
843fn draw_line<DB: DrawingBackend>(
844    root: &DrawingArea<DB, plotters::coord::Shift>,
845    rows: &[Value],
846    opts: &ChartOptions,
847) -> Result<usize, McpError>
848where
849    <DB as DrawingBackend>::ErrorType: 'static,
850{
851    line_or_scatter(root, rows, opts, true)
852}
853
854fn draw_scatter<DB: DrawingBackend>(
855    root: &DrawingArea<DB, plotters::coord::Shift>,
856    rows: &[Value],
857    opts: &ChartOptions,
858) -> Result<usize, McpError>
859where
860    <DB as DrawingBackend>::ErrorType: 'static,
861{
862    line_or_scatter(root, rows, opts, false)
863}
864
865#[expect(
866    clippy::similar_names,
867    reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones"
868)]
869/// Shared implementation for line and scatter charts. `connect_points` controls
870/// whether successive points are joined with a line.
871fn line_or_scatter<DB: DrawingBackend>(
872    root: &DrawingArea<DB, plotters::coord::Shift>,
873    rows: &[Value],
874    opts: &ChartOptions,
875    connect_points: bool,
876) -> Result<usize, McpError>
877where
878    <DB as DrawingBackend>::ErrorType: 'static,
879{
880    let x_col = require_column(&opts.x_column, "x")?;
881    let y_col = require_column(&opts.y_column, "y")?;
882    // Line and scatter default to numeric x; callers with non-numeric x
883    // (dates, labels, enums) opt in via `ChartOptions::x_as_category`.
884    let x_as_category = opts.x_as_category.unwrap_or(false);
885    let groups = group_series(
886        rows,
887        x_col,
888        y_col,
889        opts.series_column.as_deref(),
890        x_as_category,
891    )?;
892
893    let auto = bounds(&groups);
894    let (rx_min, rx_max, ry_min, ry_max) = apply_ranges(auto, opts);
895
896    let default_title = if connect_points {
897        "Line chart"
898    } else {
899        "Scatter plot"
900    };
901    let title = opts.title.clone().unwrap_or_else(|| default_title.into());
902
903    let mut chart = ChartBuilder::on(root)
904        .caption(&title, ("sans-serif", 22))
905        .margin(10)
906        .x_label_area_size(if x_as_category { 60 } else { 50 })
907        .y_label_area_size(70)
908        .build_cartesian_2d(rx_min..rx_max, ry_min..ry_max)
909        .map_err(draw_err)?;
910
911    // In categorical mode the x values are synthetic sequential indices
912    // assigned by `group_series` — the axis ticks need a formatter that
913    // translates the index back to the original string label, otherwise
914    // the rendered chart would show 0, 1, 2, ... where a reader expects
915    // dates or names.
916    if x_as_category {
917        let categories = collect_categories(&groups);
918        let labels: Vec<String> = categories.iter().map(|(_, l)| l.clone()).collect();
919        chart
920            .configure_mesh()
921            .x_desc(x_col)
922            .y_desc(y_col)
923            .x_labels(categories.len().min(20))
924            .x_label_formatter(&|v| {
925                #[expect(
926                    clippy::cast_possible_truncation,
927                    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"
928                )]
929                let idx = v.round() as isize;
930                usize::try_from(idx)
931                    .ok()
932                    .and_then(|i| labels.get(i).cloned())
933                    .unwrap_or_default()
934            })
935            .draw()
936            .map_err(draw_err)?;
937    } else {
938        chart
939            .configure_mesh()
940            .x_desc(x_col)
941            .y_desc(y_col)
942            .draw()
943            .map_err(draw_err)?;
944    }
945
946    let mut total_plotted = 0usize;
947    for (idx, (series_key, pts)) in groups.iter().enumerate() {
948        let color = series_color_for(series_key, idx, opts);
949        let name = if series_key.is_empty() {
950            y_col.to_string()
951        } else {
952            series_key.clone()
953        };
954        let mut sorted = pts.clone();
955        if connect_points {
956            sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
957        }
958
959        if opts.label_points {
960            // Draw dots/lines without registering a legend entry, then
961            // annotate each point with the series name as a text label.
962            if connect_points {
963                chart
964                    .draw_series(LineSeries::new(
965                        sorted.iter().map(|(x, y, _)| (*x, *y)),
966                        color.stroke_width(2),
967                    ))
968                    .map_err(draw_err)?;
969            } else {
970                chart
971                    .draw_series(
972                        sorted
973                            .iter()
974                            .map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())),
975                    )
976                    .map_err(draw_err)?;
977            }
978            // Text label offset: right+above by default. When the dot is in
979            // the right 25% of the x range, flip the label left so it stays
980            // inside the chart area. When near the bottom 15% of y, flip up
981            // so the label isn't below the axis line.
982            let x_flip_threshold = rx_min + (rx_max - rx_min) * 0.75;
983            let y_flip_threshold = ry_min + (ry_max - ry_min) * 0.15;
984            let label_style = ("sans-serif", 11).into_font().color(&BLACK);
985            chart
986                .draw_series(sorted.iter().map(|(x, y, _)| {
987                    let label = name.clone();
988                    // Estimate pixel width: ~7px per Unicode character for 11pt font.
989                    // This is still approximate but handles multi-byte UTF-8 correctly.
990                    //
991                    // Series label lengths in MCP outputs are bounded well under
992                    // 10k characters; saturating at `i32::MAX` is the right
993                    // behavior for a pixel offset anyway — anything larger
994                    // would already be off-canvas.
995                    let char_px = i32::try_from(label.chars().count())
996                        .unwrap_or(i32::MAX)
997                        .saturating_mul(7);
998                    let x_off = if *x >= x_flip_threshold {
999                        -(char_px + 6)
1000                    } else {
1001                        6
1002                    };
1003                    let y_off = if *y <= y_flip_threshold { -20 } else { -12 };
1004                    EmptyElement::at((*x, *y))
1005                        + Text::new(label, (x_off, y_off), label_style.clone())
1006                }))
1007                .map_err(draw_err)?;
1008        } else {
1009            // Default: dots/lines with legend entry.
1010            if connect_points {
1011                chart
1012                    .draw_series(LineSeries::new(
1013                        sorted.iter().map(|(x, y, _)| (*x, *y)),
1014                        color.stroke_width(2),
1015                    ))
1016                    .map_err(draw_err)?
1017                    .label(name)
1018                    .legend(move |(x, y)| {
1019                        PathElement::new(vec![(x, y), (x + 16, y)], color.stroke_width(2))
1020                    });
1021            } else {
1022                chart
1023                    .draw_series(
1024                        sorted
1025                            .iter()
1026                            .map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())),
1027                    )
1028                    .map_err(draw_err)?
1029                    .label(name)
1030                    .legend(move |(x, y)| Circle::new((x + 8, y), 4, color.filled()));
1031            }
1032        }
1033        total_plotted += pts.len();
1034    }
1035
1036    // Only draw the legend box when label_points is off — with labels
1037    // on the dots, the legend is redundant and takes up chart space.
1038    if !opts.label_points {
1039        chart
1040            .configure_series_labels()
1041            .background_style(colors::WHITE.mix(0.9))
1042            .border_style(colors::BLACK)
1043            .draw()
1044            .map_err(draw_err)?;
1045    }
1046
1047    root.present().map_err(draw_err)?;
1048    Ok(total_plotted)
1049}
1050
1051fn bounds(groups: &SeriesMap) -> (f64, f64, f64, f64) {
1052    let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY);
1053    let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY);
1054    for pts in groups.values() {
1055        for (x, y, _) in pts {
1056            if *x < x_min {
1057                x_min = *x;
1058            }
1059            if *x > x_max {
1060                x_max = *x;
1061            }
1062            if *y < y_min {
1063                y_min = *y;
1064            }
1065            if *y > y_max {
1066                y_max = *y;
1067            }
1068        }
1069    }
1070    if !x_min.is_finite() {
1071        x_min = 0.0;
1072    }
1073    if !x_max.is_finite() {
1074        x_max = 1.0;
1075    }
1076    if !y_min.is_finite() {
1077        y_min = 0.0;
1078    }
1079    if !y_max.is_finite() {
1080        y_max = 1.0;
1081    }
1082    if (x_max - x_min).abs() < 1e-12 {
1083        x_max = x_min + 1.0;
1084    }
1085    if (y_max - y_min).abs() < 1e-12 {
1086        y_max = y_min + 1.0;
1087    }
1088    (x_min, x_max, y_min, y_max)
1089}
1090
1091#[expect(
1092    clippy::similar_names,
1093    reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones"
1094)]
1095/// Apply optional fixed-range overrides from `ChartOptions`, returning the
1096/// final `(x_min, x_max, y_min, y_max)` to pass to `build_cartesian_2d`.
1097///
1098/// When a range is provided the auto-computed bound is replaced entirely —
1099/// no padding is added on the overridden axes. Auto-computed axes still
1100/// receive their normal 5% padding so they don't clip the outermost point.
1101fn apply_ranges(auto: (f64, f64, f64, f64), opts: &ChartOptions) -> (f64, f64, f64, f64) {
1102    let (x_min, x_max, y_min, y_max) = auto;
1103    let x_pad = (x_max - x_min).abs() * 0.05 + 1e-9;
1104    let y_pad = (y_max - y_min).abs() * 0.05 + 1e-9;
1105    let (final_x_min, final_x_max) = match opts.x_range {
1106        Some([lo, hi]) => (lo, hi),
1107        None => (x_min - x_pad, x_max + x_pad),
1108    };
1109    let (final_y_min, final_y_max) = match opts.y_range {
1110        Some([lo, hi]) => (lo, hi),
1111        None => (y_min - y_pad, y_max + y_pad),
1112    };
1113    (final_x_min, final_x_max, final_y_min, final_y_max)
1114}
1115
1116fn draw_histogram<DB: DrawingBackend>(
1117    root: &DrawingArea<DB, plotters::coord::Shift>,
1118    rows: &[Value],
1119    opts: &ChartOptions,
1120) -> Result<usize, McpError>
1121where
1122    <DB as DrawingBackend>::ErrorType: 'static,
1123{
1124    // Histograms use a single numeric column. Prefer x_column, fall back to y_column.
1125    let col = opts
1126        .x_column
1127        .as_deref()
1128        .or(opts.y_column.as_deref())
1129        .ok_or_else(|| {
1130            McpError::new(
1131                ErrorCode::SchemaMismatch,
1132                "Histogram requires an 'x' or 'y' column name",
1133            )
1134        })?;
1135
1136    let values: Vec<f64> = rows
1137        .iter()
1138        .filter_map(|r| r.as_object().and_then(|o| o.get(col)).and_then(as_number))
1139        .collect();
1140    if values.is_empty() {
1141        return Err(McpError::new(
1142            ErrorCode::SchemaMismatch,
1143            format!("Column '{col}' has no numeric values to histogram"),
1144        ));
1145    }
1146
1147    let bin_count = opts.bins.max(1) as usize;
1148    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
1149    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1150    let span = if (max - min).abs() < 1e-12 {
1151        1.0
1152    } else {
1153        max - min
1154    };
1155    let bin_width = span / bin_count as f64;
1156
1157    let mut bins = vec![0u64; bin_count];
1158    for v in &values {
1159        // Histogram bin index: `floor((v - min) / bin_width)` is finite and
1160        // lies in `[0, bin_count)` for well-formed inputs; we still clamp
1161        // with `.max(0).min(bin_count - 1)` to defend against NaN/rounding.
1162        // The narrowing to `isize` / `usize` is therefore a reinterpret of a
1163        // value we have just bounded to a small non-negative integer.
1164        #[expect(
1165            clippy::cast_possible_truncation,
1166            clippy::cast_sign_loss,
1167            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"
1168        )]
1169        let idx = (((*v - min) / bin_width).floor() as isize).max(0) as usize;
1170        let idx = idx.min(bin_count - 1);
1171        bins[idx] += 1;
1172    }
1173
1174    let y_max = *bins.iter().max().unwrap_or(&1) as f64;
1175    let title = opts
1176        .title
1177        .clone()
1178        .unwrap_or_else(|| format!("Distribution of {col}"));
1179
1180    let mut chart = ChartBuilder::on(root)
1181        .caption(&title, ("sans-serif", 22))
1182        .margin(10)
1183        .x_label_area_size(50)
1184        .y_label_area_size(60)
1185        .build_cartesian_2d(min..(max + bin_width * 0.01), 0.0..(y_max * 1.1 + 1.0))
1186        .map_err(draw_err)?;
1187
1188    chart
1189        .configure_mesh()
1190        .x_desc(col)
1191        .y_desc("count")
1192        .draw()
1193        .map_err(draw_err)?;
1194
1195    let color = series_color(0);
1196    chart
1197        .draw_series(bins.iter().enumerate().map(|(i, count)| {
1198            let left = min + bin_width * i as f64;
1199            let right = left + bin_width;
1200            Rectangle::new([(left, 0.0), (right, *count as f64)], color.filled())
1201        }))
1202        .map_err(draw_err)?;
1203
1204    root.present().map_err(draw_err)?;
1205    Ok(values.len())
1206}