1#![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
47type SeriesPoints = Vec<(f64, f64, String)>;
56
57type SeriesMap = BTreeMap<String, SeriesPoints>;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ChartType {
65 Bar,
66 Line,
67 Scatter,
68 Histogram,
69}
70
71impl ChartType {
72 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ChartFormat {
97 Png,
98 Svg,
99}
100
101impl ChartFormat {
102 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 #[must_use]
130 pub fn extension(&self) -> &'static str {
131 match self {
132 ChartFormat::Png => "png",
133 ChartFormat::Svg => "svg",
134 }
135 }
136}
137
138pub 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
186fn 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
195fn 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#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum ChartDisposition {
217 WriteOnly { path: std::path::PathBuf },
220 InlineOnly,
222 WriteAndInline { path: std::path::PathBuf },
224}
225
226impl ChartDisposition {
227 #[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 #[must_use]
240 pub fn wants_inline(&self) -> bool {
241 matches!(
242 self,
243 ChartDisposition::InlineOnly | ChartDisposition::WriteAndInline { .. }
244 )
245 }
246}
247
248#[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
282pub 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
305pub fn write_chart_to_disk(
323 path: &std::path::Path,
324 bytes: &[u8],
325 overwrite: bool,
326) -> Result<u64, McpError> {
327 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#[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 pub x_as_category: Option<bool>,
411 pub x_range: Option<[f64; 2]>,
416 pub y_range: Option<[f64; 2]>,
418 pub color_map: std::collections::HashMap<String, RGBColor>,
423 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#[derive(Debug)]
457pub struct ChartResult {
458 pub bytes: Vec<u8>,
459 pub mime_type: &'static str,
460 pub rows_plotted: usize,
461}
462
463pub 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
550fn 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
610fn 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
635fn 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
678fn 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
695fn shared_tz_suffix(labels: &[String]) -> Option<String> {
699 let first = labels.first()?;
700 let offset_start = first.rfind('+').or_else(|| {
702 let last_minus = first.rfind('-')?;
704 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 if suffix.len() != 6 {
714 return None;
715 }
716 if labels.iter().all(|l| l.ends_with(suffix)) {
718 Some(suffix.to_string())
719 } else {
720 None
721 }
722}
723
724fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> {
734 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
761enum TemporalKind {
762 Date,
765 DateTime,
769 DateTimeTz(i32),
775}
776
777#[derive(Debug, Clone, Copy)]
785enum XMode {
786 Numeric,
788 Categorical,
792 Temporal(TemporalKind),
796}
797
798fn 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
856fn 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
882fn 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
911fn 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
979fn series_color(idx: usize) -> RGBColor {
981 const PALETTE: [RGBColor; 8] = [
983 RGBColor(31, 119, 180), RGBColor(255, 127, 14), RGBColor(44, 160, 44), RGBColor(214, 39, 40), RGBColor(148, 103, 189), RGBColor(140, 86, 75), RGBColor(227, 119, 194), RGBColor(127, 127, 127), ];
992 PALETTE[idx % PALETTE.len()]
993}
994
995fn 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#[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 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)]
1154fn 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 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 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 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 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 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 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 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 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)]
1410fn 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 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 #[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 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 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 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 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 assert_eq!(tick_count_for_label_width(19, 800), 5);
1642 assert_eq!(tick_count_for_label_width(19, 1400), 9);
1643 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 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 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 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 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}