From ce92e46ad6784ac38d91316d3d758780b102b08b Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Mon, 25 May 2026 23:44:03 -0700 Subject: [PATCH 1/7] fix(mcp): disable ANSI escape codes on stderr tracing layer MCP servers communicate over stdio; their stderr goes to a client log pane (e.g. Claude Code output panel) that doesn't render ANSI codes. On Windows this produced raw escape sequences like [2m...[0m. Force plain text on both the daemon and MCP stderr layers. Also adds a pre-1.0 stability note to the README. --- hyperdb-mcp/README.md | 2 ++ hyperdb-mcp/src/main.rs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/hyperdb-mcp/README.md b/hyperdb-mcp/README.md index d10c489..f73e505 100644 --- a/hyperdb-mcp/README.md +++ b/hyperdb-mcp/README.md @@ -1,5 +1,7 @@ # hyperdb-mcp +> **Note:** This crate was vibe-engineered with heavy use of AI coding assistants. The 0.1.x line may still undergo large breaking changes; the public API won't settle until the 1.0.0 release. + An MCP (Model Context Protocol) server that turns the Hyper columnar database into an instant SQL analytics engine. Data flows in from other MCP plugins or files, lands in Hyper automatically, and becomes queryable with SQL — no setup, no schema files, no database management. Built on the pure-Rust [`hyperdb-api`](../hyperdb-api/) crate for maximum performance: 22M+ rows/sec inserts, 18M+ rows/sec queries, constant memory for billion-row results. diff --git a/hyperdb-mcp/src/main.rs b/hyperdb-mcp/src/main.rs index a617117..a6d9925 100644 --- a/hyperdb-mcp/src/main.rs +++ b/hyperdb-mcp/src/main.rs @@ -173,7 +173,7 @@ async fn run_daemon_mode( tracing_subscriber::registry() .with(filter) - .with(fmt::layer().with_writer(std::io::stderr)) + .with(fmt::layer().with_writer(std::io::stderr).with_ansi(false)) .with(fmt::layer().with_writer(file_writer).with_ansi(false)) .init(); @@ -210,7 +210,7 @@ async fn run_mcp_mode(cli: Cli) -> Result<(), Box> { tracing_subscriber::registry() .with(filter) - .with(fmt::layer().with_writer(std::io::stderr)) + .with(fmt::layer().with_writer(std::io::stderr).with_ansi(false)) .with(fmt::layer().with_writer(file_writer).with_ansi(false)) .init(); From 90eab029fa8b48191cff5962b2ce0b5915cf43b1 Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Mon, 25 May 2026 23:52:36 -0700 Subject: [PATCH 2/7] fix(mcp): auto-create parent dirs for chart/export output paths validate_output_path previously rejected paths whose parent directory didn't exist. Now it creates the directory tree (create_dir_all) before canonicalizing, so users don't need to pre-create output directories. Also improves chart tool description to guide LLMs on: - Long-format data requirement (one numeric y, use series for grouping) - x_as_category=true requirement for DATE/TIMESTAMP x-axes - UNION ALL reshaping for wide-format data --- hyperdb-mcp/src/attach.rs | 29 +++++++++++++++++++++-------- hyperdb-mcp/src/readme.rs | 4 +++- hyperdb-mcp/src/server.rs | 2 +- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/hyperdb-mcp/src/attach.rs b/hyperdb-mcp/src/attach.rs index 70d6645..915ed4f 100644 --- a/hyperdb-mcp/src/attach.rs +++ b/hyperdb-mcp/src/attach.rs @@ -727,6 +727,14 @@ pub fn validate_output_path(path: &str, kind: &str) -> Result format!("{kind} path '{path}' has no file-name component"), ) })?; + if !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| { + McpError::new( + ErrorCode::InternalError, + format!("Failed to create parent directory for {kind} path '{path}': {e}"), + ) + })?; + } let canonical_parent = std::fs::canonicalize(parent).map_err(|e| { McpError::new( ErrorCode::FileNotFound, @@ -924,14 +932,19 @@ mod tests { } #[test] - fn validate_output_path_rejects_missing_parent() { - // Build a platform-portable absolute path with a missing parent. - // Hardcoded "/definitely/..." paths are not absolute on Windows. - let missing_parent = std::env::temp_dir() - .join("hyper_mcp_validate_output_missing_parent_99999") - .join("out.csv"); - let err = validate_output_path(missing_parent.to_str().unwrap(), "export").unwrap_err(); - assert_eq!(err.code, ErrorCode::FileNotFound); + fn validate_output_path_creates_missing_parent() { + let parent = std::env::temp_dir().join("hyper_mcp_validate_output_missing_parent_99999"); + // Clean up from any prior run. + let _ = std::fs::remove_dir_all(&parent); + assert!(!parent.exists()); + + let target = parent.join("out.csv"); + let canonical = + validate_output_path(target.to_str().unwrap(), "export").expect("should create parent"); + assert!(canonical.is_absolute()); + assert!(parent.exists(), "parent directory should have been created"); + // Clean up. + let _ = std::fs::remove_dir_all(&parent); } #[test] diff --git a/hyperdb-mcp/src/readme.rs b/hyperdb-mcp/src/readme.rs index 481ea21..7b2f716 100644 --- a/hyperdb-mcp/src/readme.rs +++ b/hyperdb-mcp/src/readme.rs @@ -131,7 +131,9 @@ watcher targets the alias; call `unwatch_directory` first. - `export` — write a table or query result to a file (Parquet, Iceberg, Arrow IPC, CSV, .hyper). - `chart` — render a bar / line / scatter / histogram PNG from a SQL - query. + query. Data must be long-format (one numeric y column; use a `series` + column for grouping). For DATE/TIMESTAMP x-axis on line/scatter, set + `x_as_category=true`. Wide-format data must be reshaped with UNION ALL. - `copy_query` — run a SELECT across local + attached databases and insert the result into a target table (`mode`: `create`, `append`, `replace`). Cross-database analytics in one tool call. diff --git a/hyperdb-mcp/src/server.rs b/hyperdb-mcp/src/server.rs index 04ef74d..e612150 100644 --- a/hyperdb-mcp/src/server.rs +++ b/hyperdb-mcp/src/server.rs @@ -2299,7 +2299,7 @@ impl HyperMcpServer { /// Render a chart (PNG or SVG) from a SQL query. #[tool( - description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n- `output_path`: explicit destination file path. Parent directory is created automatically. If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." + description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts require a numeric x by default. When x is a DATE, TIMESTAMP, or any non-numeric type, you MUST set `x_as_category=true` or the chart will reject the data.\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." )] fn chart( &self, From fb88ebebdc83fce1bae35b3ab5467793f85700fd Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 26 May 2026 00:27:06 -0700 Subject: [PATCH 3/7] fix(chart): auto-detect categorical x columns for line/scatter charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line and scatter charts now peek at the first row's x value — if it's not numeric (DATE, TIMESTAMP, TEXT, etc.), categorical mode activates automatically. This eliminates the need to manually set x_as_category=true for date-based time series. The explicit x_as_category parameter still works as an override. --- hyperdb-mcp/src/chart.rs | 16 +++++++++++----- hyperdb-mcp/src/readme.rs | 5 +++-- hyperdb-mcp/src/server.rs | 12 ++++++------ hyperdb-mcp/tests/chart_tests.rs | 7 ++++--- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/hyperdb-mcp/src/chart.rs b/hyperdb-mcp/src/chart.rs index aa254fb..f60bf01 100644 --- a/hyperdb-mcp/src/chart.rs +++ b/hyperdb-mcp/src/chart.rs @@ -10,8 +10,8 @@ //! # Supported Chart Types //! //! - **Bar** — categorical x-axis by default; multi-series supported via `series` column. -//! - **Line** — numeric x-axis by default; set `x_as_category = true` for DATE/string x. -//! - **Scatter** — numeric x-axis by default; same `x_as_category` option as line. +//! - **Line** — auto-detects categorical x (DATE/TIMESTAMP/TEXT); override with `x_as_category`. +//! - **Scatter** — same auto-detection as line. //! - **Histogram** — single numeric column binned into N buckets (default 20). //! //! # Rendering Pipeline @@ -879,9 +879,15 @@ where { let x_col = require_column(&opts.x_column, "x")?; let y_col = require_column(&opts.y_column, "y")?; - // Line and scatter default to numeric x; callers with non-numeric x - // (dates, labels, enums) opt in via `ChartOptions::x_as_category`. - let x_as_category = opts.x_as_category.unwrap_or(false); + // Auto-detect categorical x: if the caller didn't explicitly set + // x_as_category and the first row's x value isn't numeric (e.g. + // DATE, TIMESTAMP, TEXT), flip to categorical mode automatically. + let x_as_category = opts.x_as_category.unwrap_or_else(|| { + rows.first() + .and_then(Value::as_object) + .and_then(|obj| obj.get(x_col)) + .is_some_and(|v| as_number(v).is_none()) + }); let groups = group_series( rows, x_col, diff --git a/hyperdb-mcp/src/readme.rs b/hyperdb-mcp/src/readme.rs index 7b2f716..1eaac21 100644 --- a/hyperdb-mcp/src/readme.rs +++ b/hyperdb-mcp/src/readme.rs @@ -132,8 +132,9 @@ watcher targets the alias; call `unwatch_directory` first. Arrow IPC, CSV, .hyper). - `chart` — render a bar / line / scatter / histogram PNG from a SQL query. Data must be long-format (one numeric y column; use a `series` - column for grouping). For DATE/TIMESTAMP x-axis on line/scatter, set - `x_as_category=true`. Wide-format data must be reshaped with UNION ALL. + column for grouping). DATE/TIMESTAMP/TEXT x columns are auto-detected + as categorical for line/scatter. Wide-format data must be reshaped + with UNION ALL. - `copy_query` — run a SELECT across local + attached databases and insert the result into a target table (`mode`: `create`, `append`, `replace`). Cross-database analytics in one tool call. diff --git a/hyperdb-mcp/src/server.rs b/hyperdb-mcp/src/server.rs index e612150..19f9ab7 100644 --- a/hyperdb-mcp/src/server.rs +++ b/hyperdb-mcp/src/server.rs @@ -441,11 +441,11 @@ pub struct ChartParams { pub height: Option, /// Number of bins for histograms (default 20) pub bins: Option, - /// Treat the x column as categorical rather than numeric. Set to `true` - /// when plotting a `line` or `scatter` against a `DATE`, `TIMESTAMP`, - /// enum, or any other non-numeric x column; otherwise the chart will - /// reject the query with "column is missing or not numeric". Bar - /// charts are always categorical regardless of this flag. + /// Treat the x column as categorical rather than numeric. Auto-detected + /// from the first row's x value for line/scatter charts: DATE, TIMESTAMP, + /// TEXT, and other non-numeric types flip to categorical automatically. + /// Set explicitly to override auto-detection. Bar charts are always + /// categorical regardless of this flag. pub x_as_category: Option, /// Fix the x-axis range as [min, max]. Omit to auto-scale. Useful when /// comparing multiple charts at a consistent scale (e.g. [0, 1500] for @@ -2299,7 +2299,7 @@ impl HyperMcpServer { /// Render a chart (PNG or SVG) from a SQL query. #[tool( - description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts require a numeric x by default. When x is a DATE, TIMESTAMP, or any non-numeric type, you MUST set `x_as_category=true` or the chart will reject the data.\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." + description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns (DATE, TIMESTAMP, TEXT) and switch to categorical mode automatically. You can still set `x_as_category` explicitly to override.\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." )] fn chart( &self, diff --git a/hyperdb-mcp/tests/chart_tests.rs b/hyperdb-mcp/tests/chart_tests.rs index 003c442..e442aa3 100644 --- a/hyperdb-mcp/tests/chart_tests.rs +++ b/hyperdb-mcp/tests/chart_tests.rs @@ -107,13 +107,14 @@ fn line_chart_categorical_x() { let result = render_chart(&rows, &opts).unwrap(); assert_eq!(result.rows_plotted, 5); assert_eq!(result.mime_type, "image/png"); - // Without x_as_category this would fail the numeric-parse check. + // Without explicit x_as_category, auto-detection kicks in and + // recognizes the string x column as categorical. let without_category = ChartOptions { x_as_category: None, ..opts }; - let err = render_chart(&rows, &without_category).unwrap_err(); - assert!(err.message.contains("not numeric")); + let result2 = render_chart(&rows, &without_category).unwrap(); + assert_eq!(result2.rows_plotted, 5); } /// Scatter chart with no series column renders every row as one series. From dfa1bf3d8091912dc851af00334cb5cf5bbbf84d Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 26 May 2026 00:37:54 -0700 Subject: [PATCH 4/7] fix(chart): auto-shorten TIMESTAMPTZ labels and thin dense tick marks Categorical axis labels now go through a shortening pass: 1. Strip shared timezone offset (+00:00) when all labels end with it 2. Auto-thin: blank intermediate labels when they'd overlap based on estimated character width vs chart pixel width This prevents label collision on charts with 12+ TIMESTAMPTZ ticks at 800px width. First and last labels are always preserved. --- hyperdb-mcp/src/chart.rs | 94 +++++++++++++++++++++++++++++--- hyperdb-mcp/tests/chart_tests.rs | 44 +++++++++++++++ 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/hyperdb-mcp/src/chart.rs b/hyperdb-mcp/src/chart.rs index f60bf01..be80a02 100644 --- a/hyperdb-mcp/src/chart.rs +++ b/hyperdb-mcp/src/chart.rs @@ -600,6 +600,89 @@ fn as_string(v: &Value) -> String { } } +/// Shorten categorical tick labels for display. Two passes: +/// +/// 1. **Strip shared timezone offset** — if every label ends with the +/// same `+HH:MM` or `+00:00` (common for TIMESTAMPTZ), drop that +/// suffix since it adds no information. +/// 2. **Auto-thin** — if there are more labels than can fit without +/// overlap (estimated at `chart_width / (avg_char_count * 7px)`), +/// keep only every Nth label and blank the rest. +fn shorten_labels(labels: &[String], chart_width: u32) -> Vec { + if labels.is_empty() { + return Vec::new(); + } + // Pass 1: strip shared timezone suffix (e.g. "+00:00", "+05:30") + let stripped: Vec = if labels.len() > 1 { + let suffix = shared_tz_suffix(labels); + if let Some(ref sfx) = suffix { + labels + .iter() + .map(|l| l.strip_suffix(sfx.as_str()).unwrap_or(l).trim().to_string()) + .collect() + } else { + labels.to_vec() + } + } else { + labels.to_vec() + }; + + // Pass 2: auto-thin if labels would overlap + let max_len = stripped.iter().map(String::len).max().unwrap_or(1); + let char_px = 7_u32; + #[expect( + clippy::cast_possible_truncation, + reason = "label length is bounded by real-world string sizes (< 200 chars)" + )] + let label_px = (max_len as u32) * char_px + 10; + let fits = (chart_width / label_px.max(1)) as usize; + if fits >= stripped.len() || stripped.len() <= 2 { + return stripped; + } + // Show every Nth label, always including first and last + let step = (stripped.len() + fits - 1) / fits.max(1); + stripped + .iter() + .enumerate() + .map(|(i, l)| { + if i == 0 || i == stripped.len() - 1 || i % step == 0 { + l.clone() + } else { + String::new() + } + }) + .collect() +} + +/// If all labels share a trailing timezone offset pattern like `+00:00` +/// or `-05:30`, return that suffix. Returns `None` if labels differ or +/// have no offset. +fn shared_tz_suffix(labels: &[String]) -> Option { + let first = labels.first()?; + // Match pattern: space or 'T' followed by time, then +/-HH:MM at the end + let offset_start = first.rfind('+').or_else(|| { + // Careful: don't match the '-' in "2026-05-01" + let last_minus = first.rfind('-')?; + // Only if it's after a ':' (i.e. part of time, not date) + if first[..last_minus].ends_with(|c: char| c.is_ascii_digit()) && last_minus > 10 { + Some(last_minus) + } else { + None + } + })?; + let suffix = &first[offset_start..]; + // Must look like +HH:MM or -HH:MM (6 chars) + if suffix.len() != 6 { + return None; + } + // Verify all labels share this suffix + if labels.iter().all(|l| l.ends_with(suffix)) { + Some(suffix.to_string()) + } else { + None + } +} + /// Collect distinct x values and their original string labels from a /// [`SeriesMap`], in ascending x-value order. /// @@ -780,16 +863,12 @@ where .build_cartesian_2d(x_min..x_max, (y_min - y_pad)..(y_max + y_pad)) .map_err(draw_err)?; - let labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); + let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); + let labels = shorten_labels(&raw_labels, opts.width); chart .configure_mesh() .x_labels(categories.len().min(20)) .x_label_formatter(&|v| { - // Plotters passes us category-index floats that `collect_categories` - // generated from `0..labels.len()`; the round-trip stays within - // `isize` range. Clamp negative values to the out-of-range branch - // and rely on `usize::try_from` to surface any stray negative tick - // as an empty label rather than wrapping. #[expect( clippy::cast_possible_truncation, 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" @@ -921,7 +1000,8 @@ where // dates or names. if x_as_category { let categories = collect_categories(&groups); - let labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); + let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); + let labels = shorten_labels(&raw_labels, opts.width); chart .configure_mesh() .x_desc(x_col) diff --git a/hyperdb-mcp/tests/chart_tests.rs b/hyperdb-mcp/tests/chart_tests.rs index e442aa3..4ad5097 100644 --- a/hyperdb-mcp/tests/chart_tests.rs +++ b/hyperdb-mcp/tests/chart_tests.rs @@ -540,3 +540,47 @@ fn render_and_write_produces_valid_png_on_disk() { let on_disk = std::fs::read(&path).unwrap(); assert!(on_disk.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])); } + +/// Line chart with TIMESTAMPTZ-style labels auto-detects categorical mode +/// and renders without error. Labels with shared +00:00 suffix get shortened. +#[test] +fn line_chart_timestamptz_auto_categorical_and_label_shortening() { + let rows = vec![ + json!({"ts": "2026-05-01 08:00:00+00:00", "value": 100}), + json!({"ts": "2026-05-01 12:30:00+00:00", "value": 150}), + json!({"ts": "2026-05-02 06:15:00+00:00", "value": 200}), + json!({"ts": "2026-05-02 22:45:00+00:00", "value": 180}), + json!({"ts": "2026-05-03 10:00:00+00:00", "value": 220}), + json!({"ts": "2026-05-03 18:30:00+00:00", "value": 190}), + ]; + let opts = ChartOptions { + chart_type: ChartType::Line, + x_column: Some("ts".into()), + y_column: Some("value".into()), + // x_as_category deliberately left as None to test auto-detection + ..ChartOptions::default() + }; + let result = render_chart(&rows, &opts).unwrap(); + assert_eq!(result.rows_plotted, 6); +} + +/// Many TIMESTAMPTZ labels auto-thin to avoid overlap. +#[test] +fn line_chart_many_timestamps_auto_thins() { + let rows: Vec<_> = (0..30) + .map(|i| { + json!({ + "ts": format!("2026-05-{:02} 12:00:00+00:00", (i % 28) + 1), + "value": i * 10 + }) + }) + .collect(); + let opts = ChartOptions { + chart_type: ChartType::Line, + x_column: Some("ts".into()), + y_column: Some("value".into()), + ..ChartOptions::default() + }; + let result = render_chart(&rows, &opts).unwrap(); + assert_eq!(result.rows_plotted, 30); +} From cb784f5389b3ef6069e61b4d7f1cef4473b40b03 Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 26 May 2026 02:24:57 -0700 Subject: [PATCH 5/7] feat(chart): proportional time axis for DATE/TIMESTAMP/TIMESTAMPTZ + fix tick thinning Line and scatter charts now auto-detect DATE / TIMESTAMP / TIMESTAMPTZ x columns and render with a proportional time axis: real wall-clock gaps between data points are reflected on the chart's x-axis instead of being flattened to even spacing. Tick labels are formatted via chrono to match the input kind (%Y-%m-%d, %Y-%m-%d %H:%M:%S, or %Y-%m-%d %H:%M:%S%:z), with the originating offset captured from the first row preserved for TIMESTAMPTZ. Pass x_as_category: true to opt out and force the previous categorical layout; TEXT x columns continue to render categorically. Also fixes a tick-label thinning regression in the categorical path. The old shorten_labels blanked individual labels at non-step indices, but plotters picks its own tick positions on the float axis and rounds them to integer indices, so almost none of the chosen ticks landed on a kept index - a 90-point hourly TIMESTAMP series at any chart width rendered with only ONE visible x-axis label. The chart layer now computes a target tick count from chart width and label size, then passes it to plotters via .x_labels(N); every drawn tick carries a real label. Refactors: - shorten_labels split into strip_shared_tz_suffix (cosmetic +HH:MM compaction) and auto_tick_count (no-overlap budget for plotters). - group_series x_as_category: bool -> x_mode: XMode enum (Numeric / Categorical / Temporal(TemporalKind)). - New parse_temporal, format_temporal_tick, detect_line_x_mode, tick_count_for_label_width helpers. Tests: 16 new unit tests for the helpers (parse, format, detect, tick math, NaN safety, offset capture); 1 new integration test (line_chart_long_timestamp_series_renders_multiple_visible_labels) asserts >=3 visible date labels in the rendered SVG, locking in the regression fix. Total: 26 unit, 37 integration, all green; clippy --all-targets -D warnings clean. Docs: chart tool description in server.rs and LLM-facing readme.rs updated to document the new auto-detect behavior; CHANGELOG.md [Unreleased] gains two ### Fixed entries. --- hyperdb-mcp/CHANGELOG.md | 29 ++ hyperdb-mcp/src/chart.rs | 728 +++++++++++++++++++++++++------ hyperdb-mcp/src/readme.rs | 7 +- hyperdb-mcp/src/server.rs | 2 +- hyperdb-mcp/tests/chart_tests.rs | 48 ++ 5 files changed, 684 insertions(+), 130 deletions(-) diff --git a/hyperdb-mcp/CHANGELOG.md b/hyperdb-mcp/CHANGELOG.md index 4217a95..1198788 100644 --- a/hyperdb-mcp/CHANGELOG.md +++ b/hyperdb-mcp/CHANGELOG.md @@ -143,6 +143,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- **Chart x-axis tick label thinning.** Long categorical line/scatter + charts (e.g. a 90-point hourly TIMESTAMP series) used to render with + only ONE visible x-axis label. The old logic blanked individual + labels at non-step indices, but `plotters` picks its own tick + *positions* on the float axis and rounds them to integer indices — + so almost none of the chosen ticks landed on a kept index, and the + formatter returned empty strings for the rest. The chart layer now + computes a target tick count from chart width and label sizes and + passes that count to `plotters` via `.x_labels(N)`; every drawn + tick carries its real label. Same `+00:00` suffix stripping for + shared TIMESTAMPTZ offsets is preserved (now isolated in + `strip_shared_tz_suffix`). +- **Line / scatter charts over `DATE`, `TIMESTAMP`, and `TIMESTAMPTZ` + columns now use a proportional time axis** instead of the previous + categorical-with-evenly-spaced-ticks behavior. Real-world time gaps + between data points are now reflected in the chart's x-axis: a + series at `2026-05-01 08:00`, `2026-05-01 12:30`, `2026-05-02 06:15` + shows the 4.5-hour and 17.75-hour gaps proportionally instead of + flattening every interval to the same width. Tick labels are + formatted via `chrono` in a form that matches the input kind: + `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, and + `%Y-%m-%d %H:%M:%S%:z` for TIMESTAMPTZ (the offset captured from the + first row, so a uniformly-`+05:30` series reports IST throughout). + Set `x_as_category: true` to opt out and force the previous + categorical layout (e.g. for charts where evenly-spaced bins are + more readable than proportional gaps). TEXT x columns continue to + render categorically as before. Bar charts are unaffected — they + remain categorical regardless of x type, which matches reader + expectations for grouped data. - **Watcher recovery after hyperd restart.** The watcher's connection pool now auto-rebuilds when a per-file ingest hits a connection-lost error (typically after the daemon restarts hyperd). Each ingest gets diff --git a/hyperdb-mcp/src/chart.rs b/hyperdb-mcp/src/chart.rs index be80a02..51e1055 100644 --- a/hyperdb-mcp/src/chart.rs +++ b/hyperdb-mcp/src/chart.rs @@ -38,6 +38,7 @@ )] use crate::error::{ErrorCode, McpError}; +use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc}; use plotters::prelude::*; use plotters::style::colors; use serde_json::Value; @@ -388,18 +389,24 @@ pub struct ChartOptions { /// Override the chart-type-specific default for how the x column is /// interpreted: /// - /// - `None` (default): use the chart type's natural behavior — `Bar` - /// treats x as categorical, `Line` / `Scatter` require numeric x. - /// - `Some(true)`: force categorical even on `Line` / `Scatter`. - /// Essential for plotting values whose natural axis is a string / - /// date / enum (e.g. `SELECT day, happiness_score` where `day` is a - /// `DATE`). - /// - `Some(false)`: force numeric even on `Bar` (rarely useful — bar - /// charts are almost always categorical). + /// - `None` (default): auto-detect from the first row's x value. + /// - For `Bar`: always categorical. + /// - For `Line` / `Scatter`: numeric x → numeric axis; + /// DATE / TIMESTAMP / TIMESTAMPTZ string → **proportional time + /// axis** (positions are real Unix epoch seconds, ticks formatted + /// in the matching kind); TEXT → categorical fallback. + /// - `Some(true)`: force categorical layout (synthetic sequential + /// x positions, original strings as tick labels). Useful when you + /// want even spacing on temporal data — e.g. one bar per business + /// day with no visual gap for weekends. + /// - `Some(false)`: force numeric x. Errors for non-numeric inputs + /// on `Line` / `Scatter`. Rarely useful on `Bar`. /// /// When categorical mode is active the rendered x axis uses the /// original string representation of each distinct x value as its - /// tick label, in the order x values are first seen. + /// tick label, in the order x values are first seen. When time mode + /// is active, gaps between data points reflect real wall-clock time + /// rather than insertion order. pub x_as_category: Option, /// Fix the x-axis range as `[min, max]`. When set, auto-scaling is /// skipped and all frames/charts share the same x extent. Useful for @@ -600,60 +607,90 @@ fn as_string(v: &Value) -> String { } } -/// Shorten categorical tick labels for display. Two passes: +/// Compact categorical tick labels by stripping a shared trailing +/// timezone offset, when every label ends with the same one (typical +/// for TIMESTAMPTZ data stored in UTC where every row reports `+00:00`). /// -/// 1. **Strip shared timezone offset** — if every label ends with the -/// same `+HH:MM` or `+00:00` (common for TIMESTAMPTZ), drop that -/// suffix since it adds no information. -/// 2. **Auto-thin** — if there are more labels than can fit without -/// overlap (estimated at `chart_width / (avg_char_count * 7px)`), -/// keep only every Nth label and blank the rest. -fn shorten_labels(labels: &[String], chart_width: u32) -> Vec { - if labels.is_empty() { - return Vec::new(); - } - // Pass 1: strip shared timezone suffix (e.g. "+00:00", "+05:30") - let stripped: Vec = if labels.len() > 1 { - let suffix = shared_tz_suffix(labels); - if let Some(ref sfx) = suffix { - labels - .iter() - .map(|l| l.strip_suffix(sfx.as_str()).unwrap_or(l).trim().to_string()) - .collect() - } else { - labels.to_vec() - } - } else { - labels.to_vec() +/// Returns labels unchanged when there's no shared suffix or fewer than +/// two labels. Tick *count* selection lives in [`auto_tick_count`]; this +/// pass is purely about removing redundant characters from each label. +fn strip_shared_tz_suffix(labels: &[String]) -> Vec { + if labels.len() <= 1 { + return labels.to_vec(); + } + let Some(suffix) = shared_tz_suffix(labels) else { + return labels.to_vec(); }; - - // Pass 2: auto-thin if labels would overlap - let max_len = stripped.iter().map(String::len).max().unwrap_or(1); - let char_px = 7_u32; - #[expect( - clippy::cast_possible_truncation, - reason = "label length is bounded by real-world string sizes (< 200 chars)" - )] - let label_px = (max_len as u32) * char_px + 10; - let fits = (chart_width / label_px.max(1)) as usize; - if fits >= stripped.len() || stripped.len() <= 2 { - return stripped; - } - // Show every Nth label, always including first and last - let step = (stripped.len() + fits - 1) / fits.max(1); - stripped + labels .iter() - .enumerate() - .map(|(i, l)| { - if i == 0 || i == stripped.len() - 1 || i % step == 0 { - l.clone() - } else { - String::new() - } - }) + .map(|l| l.strip_suffix(suffix.as_str()).unwrap_or(l).trim().to_string()) .collect() } +/// Decide how many tick labels `plotters` should draw on a categorical +/// x-axis given the labels we plan to render and the chart pixel width. +/// +/// We pass the result to `.x_labels(N)` so `plotters` distributes tick +/// positions across the categorical range. The formatter then renders +/// the *real* label at each position — never blanks — so the user sees +/// a usable, evenly-spaced subset rather than a sea of empty strings. +/// +/// Heuristic: estimate per-label pixel width as +/// `max_label_chars * 7px + 10px` (close to plotters' default mesh +/// font), divide the chart width by that, then clamp to +/// `[2, labels.len()]`. Returns `labels.len()` directly when there +/// are 0 or 1 labels. +/// +/// # Why not blank labels at non-step indices? +/// +/// `plotters` picks its own tick *positions* on the float axis (e.g. +/// `0.0, 4.7, 9.4, …` for a 0..89 categorical range). Rounding those +/// back to integer indices rarely lands on the same indices a "keep +/// every Nth, blank the rest" rule would preserve, so most ticks +/// would render as empty strings. Telling `plotters` how many ticks +/// to draw and always returning a real label is the only stable fix. +/// +/// # Caveat: `plotters` rounds down to the next "nice" subdivision +/// +/// `plotters::compute_f64_key_points` picks the smallest scale (most +/// ticks) such that `npoints ≤ max_points`, drawing scales from a +/// fixed band table `{1, 2, 5, 10, 20, 50, 100, …}`. So a wider chart +/// requesting 9 ticks across a 0..89 range still ends up with 5 ticks +/// (band 20), because the next denser band gives 10 ticks > 9. The +/// fix for that is *not* to multiply the request — at 800 px width 10 +/// labels of 19 chars each (1430 px) would overlap. The proper fix +/// for time-series charts is the proportional time-axis path, where +/// `plotters` picks nice time intervals against real epoch positions +/// and the band-rounding artifact disappears entirely. +fn auto_tick_count(labels: &[String], chart_width: u32) -> usize { + if labels.len() <= 1 { + return labels.len(); + } + let max_chars = labels + .iter() + .map(|l| l.chars().count()) + .max() + .unwrap_or(1); + tick_count_for_label_width(max_chars, chart_width).min(labels.len()) +} + +/// Compute how many tick labels can fit horizontally, given a typical +/// label character count and the chart pixel width. Pure width math — +/// no clamping against a label count or label slice. Use this when +/// the actual label list isn't available up front (e.g. the temporal +/// branch generates labels lazily inside the formatter closure). +/// +/// Returns at least 2 so the axis stays informative even when labels +/// would technically overlap. +fn tick_count_for_label_width(label_chars: usize, chart_width: u32) -> usize { + let per_label_px = u32::try_from(label_chars) + .unwrap_or(u32::MAX) + .saturating_mul(7) + .saturating_add(10); + let fits = chart_width.saturating_div(per_label_px.max(1)) as usize; + fits.max(2) +} + /// If all labels share a trailing timezone offset pattern like `+00:00` /// or `-05:30`, return that suffix. Returns `None` if labels differ or /// have no offset. @@ -714,6 +751,156 @@ fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> { .collect() } +/// Discriminator for temporal x-axis input formats. Drives both the +/// date parser ([`parse_temporal`]) and the time-axis label formatter, +/// so a chart with `DATE` x values doesn't waste pixels on `00:00:00` +/// suffixes and a `TIMESTAMPTZ` chart preserves its timezone offset on +/// rendered tick labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TemporalKind { + /// `YYYY-MM-DD` — labels rendered as `%Y-%m-%d`, ticks land at + /// midnight UTC. + Date, + /// `YYYY-MM-DD HH:MM:SS` — labels rendered as `%Y-%m-%d %H:%M:%S`, + /// positioned at their face-value UTC equivalent (TIMESTAMP is + /// timezone-naive by definition). + DateTime, + /// `YYYY-MM-DD HH:MM:SS+HH:MM` — wrapped offset is the seconds + /// east of UTC parsed from the *first* row. Subsequent rows are + /// positioned in true UTC and re-rendered in this offset's local + /// time, so a chart over uniformly-`+00:00` data displays UTC + /// labels and a chart over `+05:30` data displays IST. + DateTimeTz(i32), +} + +/// How to interpret the x column when extracting f64 axis positions. +/// +/// Drives [`group_series`] and the corresponding rendering branch in +/// [`line_or_scatter`] / [`draw_bar`]. `Temporal` is the new mode added +/// for proportional time-axis rendering: x positions are real Unix +/// epoch seconds (so 6 hours apart on the wire are 6 hours apart on +/// the chart), and tick labels are formatted via chrono. +#[derive(Debug, Clone, Copy)] +enum XMode { + /// X values must be JSON numbers; positions pass through directly. + Numeric, + /// X values are stringified and assigned synthetic sequential + /// indices in first-seen order. All positions are integers, so + /// gaps in real-world spacing are flattened. + Categorical, + /// X values are parsed as temporal strings and positioned at their + /// Unix epoch seconds. Spacing is proportional to real time; tick + /// labels use a chrono format derived from the detected `kind`. + Temporal(TemporalKind), +} + +/// Parse a SQL temporal string ([`Value`] of `String` shape) into +/// `(kind, epoch_seconds_as_f64)`. Returns `None` when the value isn't +/// a recognized DATE / TIMESTAMP / TIMESTAMPTZ form. +/// +/// Recognized formats (most-specific first): +/// - `YYYY-MM-DD HH:MM:SS+HH:MM` and `T` separator → [`TemporalKind::DateTimeTz`] +/// - `YYYY-MM-DD HH:MM:SS+HHMM` (no colon in offset) +/// - `YYYY-MM-DD HH:MM:SS` (and fractional seconds) → [`TemporalKind::DateTime`] +/// - `YYYY-MM-DD HH:MM` (no seconds) → [`TemporalKind::DateTime`] +/// - `YYYY-MM-DD` → [`TemporalKind::Date`] +/// +/// `DateTime` strings are treated as UTC for positioning purposes — +/// they're naive by definition, so we have no other choice. The label +/// formatter will reproduce the input format faithfully. +fn parse_temporal(s: &str) -> Option<(TemporalKind, f64)> { + const TZ_FORMATS: &[&str] = &[ + "%Y-%m-%d %H:%M:%S%:z", + "%Y-%m-%dT%H:%M:%S%:z", + "%Y-%m-%d %H:%M:%S%z", + "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%d %H:%M:%S%.f%:z", + "%Y-%m-%dT%H:%M:%S%.f%:z", + ]; + for fmt in TZ_FORMATS { + if let Ok(dt) = DateTime::::parse_from_str(s, fmt) { + let offset = dt.offset().local_minus_utc(); + return Some((TemporalKind::DateTimeTz(offset), dt.timestamp() as f64)); + } + } + + const DT_FORMATS: &[&str] = &[ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%d %H:%M", + "%Y-%m-%dT%H:%M", + ]; + for fmt in DT_FORMATS { + if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) { + return Some((TemporalKind::DateTime, Utc.from_utc_datetime(&dt).timestamp() as f64)); + } + } + + if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let dt = date.and_hms_opt(0, 0, 0)?; + return Some((TemporalKind::Date, Utc.from_utc_datetime(&dt).timestamp() as f64)); + } + + None +} + +/// Decide the x mode for a line/scatter chart from the first row's +/// x value. Used when the caller didn't explicitly set `x_as_category`. +/// +/// Priority: +/// 1. Numeric (JSON number) → [`XMode::Numeric`]. +/// 2. String parsing as DATE/TIMESTAMP/TIMESTAMPTZ → [`XMode::Temporal`]. +/// 3. Anything else (TEXT, missing) → [`XMode::Categorical`] fallback. +fn detect_line_x_mode(rows: &[Value], x_col: &str) -> XMode { + let Some(x_raw) = rows + .first() + .and_then(Value::as_object) + .and_then(|obj| obj.get(x_col)) + else { + return XMode::Categorical; + }; + if as_number(x_raw).is_some() { + return XMode::Numeric; + } + if let Some(s) = x_raw.as_str() { + if let Some((kind, _)) = parse_temporal(s) { + return XMode::Temporal(kind); + } + } + XMode::Categorical +} + +/// Format a Unix epoch seconds tick value as a human-readable date +/// string in a form matching the originally detected [`TemporalKind`]. +fn format_temporal_tick(seconds: f64, kind: TemporalKind) -> String { + if !seconds.is_finite() { + return String::new(); + } + #[expect( + clippy::cast_possible_truncation, + 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" + )] + let secs_i64 = seconds.round() as i64; + match kind { + TemporalKind::Date => Utc + .timestamp_opt(secs_i64, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_default(), + TemporalKind::DateTime => Utc + .timestamp_opt(secs_i64, 0) + .single() + .map(|dt| dt.naive_utc().format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_default(), + TemporalKind::DateTimeTz(tz_offset) => FixedOffset::east_opt(tz_offset) + .and_then(|off| off.timestamp_opt(secs_i64, 0).single()) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S%:z").to_string()) + .unwrap_or_default(), + } +} + /// Group rows into (`series_name`, points) buckets, extracting x and y values. /// When `series_col` is None, all points land in a single unnamed series. fn group_series( @@ -721,7 +908,7 @@ fn group_series( x_col: &str, y_col: &str, series_col: Option<&str>, - x_as_category: bool, + x_mode: XMode, ) -> Result { let mut groups: SeriesMap = BTreeMap::new(); let mut category_index: BTreeMap = BTreeMap::new(); @@ -738,16 +925,27 @@ fn group_series( let x_raw = obj.get(x_col).cloned().unwrap_or(Value::Null); let x_label = as_string(&x_raw); - let x_val = if x_as_category { - let next = category_index.len() as f64; - *category_index.entry(x_label.clone()).or_insert(next) - } else { - as_number(&x_raw).ok_or_else(|| { + let x_val = match x_mode { + XMode::Categorical => { + let next = category_index.len() as f64; + *category_index.entry(x_label.clone()).or_insert(next) + } + XMode::Numeric => as_number(&x_raw).ok_or_else(|| { McpError::new( ErrorCode::SchemaMismatch, format!("Column '{x_col}' is missing or not numeric in at least one row"), ) - })? + })?, + XMode::Temporal(_) => parse_temporal(&x_label) + .map(|(_, ts)| ts) + .ok_or_else(|| { + McpError::new( + ErrorCode::SchemaMismatch, + format!( + "Column '{x_col}' value '{x_label}' is not a recognized DATE / TIMESTAMP / TIMESTAMPTZ form" + ), + ) + })?, }; let series_key = match series_col { @@ -822,16 +1020,16 @@ where let x_col = require_column(&opts.x_column, "x")?; let y_col = require_column(&opts.y_column, "y")?; - // Bar charts default to categorical x axis; `ChartOptions::x_as_category` - // lets callers force numeric if they really want to. - let x_as_category = opts.x_as_category.unwrap_or(true); - let groups = group_series( - rows, - x_col, - y_col, - opts.series_column.as_deref(), - x_as_category, - )?; + // Bar charts default to categorical x axis; `ChartOptions::x_as_category=Some(false)` + // lets callers force numeric if they really want to. Bar charts never + // use temporal mode — even time-series bar charts visually expect + // discrete bars at evenly-spaced positions. + let x_mode = if opts.x_as_category == Some(false) { + XMode::Numeric + } else { + XMode::Categorical + }; + let groups = group_series(rows, x_col, y_col, opts.series_column.as_deref(), x_mode)?; let categories = collect_categories(&groups); @@ -864,10 +1062,11 @@ where .map_err(draw_err)?; let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); - let labels = shorten_labels(&raw_labels, opts.width); + let labels = strip_shared_tz_suffix(&raw_labels); + let tick_count = auto_tick_count(&labels, opts.width); chart .configure_mesh() - .x_labels(categories.len().min(20)) + .x_labels(tick_count) .x_label_formatter(&|v| { #[expect( clippy::cast_possible_truncation, @@ -958,22 +1157,19 @@ where { let x_col = require_column(&opts.x_column, "x")?; let y_col = require_column(&opts.y_column, "y")?; - // Auto-detect categorical x: if the caller didn't explicitly set - // x_as_category and the first row's x value isn't numeric (e.g. - // DATE, TIMESTAMP, TEXT), flip to categorical mode automatically. - let x_as_category = opts.x_as_category.unwrap_or_else(|| { - rows.first() - .and_then(Value::as_object) - .and_then(|obj| obj.get(x_col)) - .is_some_and(|v| as_number(v).is_none()) - }); - let groups = group_series( - rows, - x_col, - y_col, - opts.series_column.as_deref(), - x_as_category, - )?; + // Decide the x mode: + // - Explicit `x_as_category=Some(true)` → Categorical (force). + // - Explicit `x_as_category=Some(false)` → Numeric (force). + // - Default (None): peek at the first row's x value: + // - parses as DATE/TIMESTAMP/TIMESTAMPTZ → Temporal (proportional time axis). + // - non-numeric (TEXT) → Categorical fallback. + // - numeric → Numeric. + let x_mode = match opts.x_as_category { + Some(true) => XMode::Categorical, + Some(false) => XMode::Numeric, + None => detect_line_x_mode(rows, x_col), + }; + let groups = group_series(rows, x_col, y_col, opts.series_column.as_deref(), x_mode)?; let auto = bounds(&groups); let (rx_min, rx_max, ry_min, ry_max) = apply_ranges(auto, opts); @@ -988,45 +1184,71 @@ where let mut chart = ChartBuilder::on(root) .caption(&title, ("sans-serif", 22)) .margin(10) - .x_label_area_size(if x_as_category { 60 } else { 50 }) + .x_label_area_size(match x_mode { + XMode::Categorical | XMode::Temporal(_) => 60, + XMode::Numeric => 50, + }) .y_label_area_size(70) .build_cartesian_2d(rx_min..rx_max, ry_min..ry_max) .map_err(draw_err)?; - // In categorical mode the x values are synthetic sequential indices - // assigned by `group_series` — the axis ticks need a formatter that - // translates the index back to the original string label, otherwise - // the rendered chart would show 0, 1, 2, ... where a reader expects - // dates or names. - if x_as_category { - let categories = collect_categories(&groups); - let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); - let labels = shorten_labels(&raw_labels, opts.width); - chart - .configure_mesh() - .x_desc(x_col) - .y_desc(y_col) - .x_labels(categories.len().min(20)) - .x_label_formatter(&|v| { - #[expect( - clippy::cast_possible_truncation, - 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" - )] - let idx = v.round() as isize; - usize::try_from(idx) - .ok() - .and_then(|i| labels.get(i).cloned()) - .unwrap_or_default() - }) - .draw() - .map_err(draw_err)?; - } else { - chart - .configure_mesh() - .x_desc(x_col) - .y_desc(y_col) - .draw() - .map_err(draw_err)?; + // Configure the x-axis ticks per mode: + // - Categorical: tick positions are synthetic indices; the formatter + // maps each back to the original string label. + // - Temporal: tick positions are real Unix epoch seconds (proportional + // to wall-clock time); the formatter renders each via chrono in a + // format matching the input kind (DATE / TIMESTAMP / TIMESTAMPTZ). + // - Numeric: pass-through; plotters' default float formatter is fine. + match x_mode { + XMode::Categorical => { + let categories = collect_categories(&groups); + let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); + let labels = strip_shared_tz_suffix(&raw_labels); + let tick_count = auto_tick_count(&labels, opts.width); + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .x_labels(tick_count) + .x_label_formatter(&|v| { + #[expect( + clippy::cast_possible_truncation, + 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" + )] + let idx = v.round() as isize; + usize::try_from(idx) + .ok() + .and_then(|i| labels.get(i).cloned()) + .unwrap_or_default() + }) + .draw() + .map_err(draw_err)?; + } + XMode::Temporal(kind) => { + // Sample one rendered tick label to size the per-tick budget. + // DATE → 10 chars, TIMESTAMP → 19, TIMESTAMPTZ → 25 (with + // `+HH:MM`). Floor at 10 so a degenerate sample still gets + // a reasonable per-label budget. + let sample = format_temporal_tick(rx_min, kind); + let sample_chars = sample.chars().count().max(10); + let tick_count = tick_count_for_label_width(sample_chars, opts.width); + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .x_labels(tick_count) + .x_label_formatter(&|v| format_temporal_tick(*v, kind)) + .draw() + .map_err(draw_err)?; + } + XMode::Numeric => { + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .draw() + .map_err(draw_err)?; + } } let mut total_plotted = 0usize; @@ -1290,3 +1512,255 @@ where root.present().map_err(draw_err)?; Ok(values.len()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn s(strs: &[&str]) -> Vec { + strs.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn strip_shared_tz_suffix_drops_uniform_offset() { + let labels = s(&[ + "2026-05-01 08:00:00+00:00", + "2026-05-02 06:15:00+00:00", + "2026-05-03 18:30:00+00:00", + ]); + let stripped = strip_shared_tz_suffix(&labels); + assert_eq!(stripped, s(&[ + "2026-05-01 08:00:00", + "2026-05-02 06:15:00", + "2026-05-03 18:30:00", + ])); + } + + #[test] + fn strip_shared_tz_suffix_handles_non_utc_offset() { + let labels = s(&[ + "2026-05-01 08:00:00+05:30", + "2026-05-02 06:15:00+05:30", + ]); + let stripped = strip_shared_tz_suffix(&labels); + assert_eq!(stripped, s(&[ + "2026-05-01 08:00:00", + "2026-05-02 06:15:00", + ])); + } + + #[test] + fn strip_shared_tz_suffix_preserves_when_offsets_differ() { + let labels = s(&[ + "2026-05-01 08:00:00+00:00", + "2026-05-02 06:15:00+05:30", + ]); + let stripped = strip_shared_tz_suffix(&labels); + assert_eq!(stripped, labels, "differing offsets must not be stripped"); + } + + #[test] + fn strip_shared_tz_suffix_preserves_plain_dates() { + let labels = s(&["2026-05-01", "2026-05-02", "2026-05-03"]); + let stripped = strip_shared_tz_suffix(&labels); + assert_eq!(stripped, labels, "DATE strings have no suffix to strip"); + } + + #[test] + fn strip_shared_tz_suffix_passes_through_one_or_zero() { + assert_eq!(strip_shared_tz_suffix(&[]), Vec::::new()); + let one = s(&["2026-05-01 08:00:00+00:00"]); + assert_eq!(strip_shared_tz_suffix(&one), one); + } + + #[test] + fn auto_tick_count_returns_all_when_labels_fit() { + // 5 short labels at width 800 — all fit comfortably. + let labels = s(&["A", "B", "C", "D", "E"]); + assert_eq!(auto_tick_count(&labels, 800), 5); + } + + #[test] + fn auto_tick_count_thins_long_timestamp_series() { + // 90 points like "2026-01-01 13:00:00" (19 chars). + // per_label_px = 19*7 + 10 = 143; fits = 800/143 = 5. + // The fix's contract: the count plotters is told MUST be ≥ 2 + // (so the axis stays informative) and ≤ labels.len(); for a + // 19-char label at 800px the heuristic should land in the + // 4..=8 band — comfortably small enough that no two adjacent + // ticks overlap. + let labels: Vec = (0..90) + .map(|i| format!("2026-01-{:02} {:02}:00:00", (i / 24) + 1, i % 24)) + .collect(); + let count = auto_tick_count(&labels, 800); + assert!( + (4..=8).contains(&count), + "expected 4..=8 ticks for 90 long labels at 800px, got {count}" + ); + assert!(count >= 2, "must always show at least 2 ticks"); + assert!(count <= labels.len(), "must never exceed label count"); + } + + #[test] + fn auto_tick_count_clamps_to_at_least_two() { + // Hypothetical: extremely wide labels at narrow chart width. + let labels = s(&[ + "x".repeat(200).as_str(), + "y".repeat(200).as_str(), + "z".repeat(200).as_str(), + ]); + assert!(auto_tick_count(&labels, 100) >= 2); + } + + #[test] + fn auto_tick_count_handles_one_or_zero_labels() { + assert_eq!(auto_tick_count(&[], 800), 0); + let one = s(&["only"]); + assert_eq!(auto_tick_count(&one, 800), 1); + } + + #[test] + fn auto_tick_count_caps_at_label_count() { + // Tiny labels at huge width — heuristic would say "many", but + // we should never exceed the actual label count. + let labels = s(&["A", "B", "C"]); + assert_eq!(auto_tick_count(&labels, 10_000), 3); + } + + #[test] + fn tick_count_for_label_width_does_not_clamp_to_label_count() { + // The width-only helper has no label-count input, so a 19-char + // estimate at 800px must compute fits=5 directly. Regression + // guard against the bug where an over-eager `min(labels.len())` + // collapsed the temporal-mode tick budget to 2. + // 19 chars * 7 + 10 = 143px → 800/143 = 5, 1400/143 = 9. + assert_eq!(tick_count_for_label_width(19, 800), 5); + assert_eq!(tick_count_for_label_width(19, 1400), 9); + // 10 chars * 7 + 10 = 80px → 800/80 = 10. DATE-only fits more. + assert_eq!(tick_count_for_label_width(10, 800), 10); + } + + #[test] + fn tick_count_for_label_width_clamps_to_at_least_two() { + assert_eq!(tick_count_for_label_width(200, 100), 2); + } + + #[test] + fn parse_temporal_recognizes_date() { + let (kind, secs) = parse_temporal("2026-05-01").expect("DATE should parse"); + assert_eq!(kind, TemporalKind::Date); + // Sanity: well after the epoch. + assert!(secs > 1.7e9); + } + + #[test] + fn parse_temporal_recognizes_timestamp() { + let (kind, secs1) = + parse_temporal("2026-05-01 08:00:00").expect("TIMESTAMP should parse"); + assert_eq!(kind, TemporalKind::DateTime); + let (_, secs2) = + parse_temporal("2026-05-01 12:30:00").expect("TIMESTAMP should parse"); + // Same date, 4.5 hours apart. + let delta = secs2 - secs1; + assert!( + (delta - 16_200.0).abs() < 1.0, + "expected 16200s gap, got {delta}" + ); + } + + #[test] + fn parse_temporal_recognizes_timestamptz_and_captures_offset() { + let (kind, _) = + parse_temporal("2026-05-01 08:00:00+05:30").expect("TIMESTAMPTZ should parse"); + match kind { + TemporalKind::DateTimeTz(off) => assert_eq!(off, 5 * 3600 + 30 * 60), + other => panic!("expected DateTimeTz, got {other:?}"), + } + } + + #[test] + fn parse_temporal_recognizes_t_separator() { + let (kind, _) = + parse_temporal("2026-05-01T08:00:00+00:00").expect("ISO T-form should parse"); + assert!(matches!(kind, TemporalKind::DateTimeTz(0))); + } + + #[test] + fn parse_temporal_rejects_non_temporal_strings() { + assert!(parse_temporal("alpha").is_none()); + assert!(parse_temporal("").is_none()); + assert!(parse_temporal("2026").is_none()); + // Numeric strings are NOT temporal — caller should treat as numeric. + assert!(parse_temporal("42").is_none()); + } + + #[test] + fn format_temporal_tick_round_trips_date() { + let (_, secs) = parse_temporal("2026-05-01").unwrap(); + assert_eq!(format_temporal_tick(secs, TemporalKind::Date), "2026-05-01"); + } + + #[test] + fn format_temporal_tick_round_trips_timestamp() { + let (_, secs) = parse_temporal("2026-05-01 08:30:00").unwrap(); + assert_eq!( + format_temporal_tick(secs, TemporalKind::DateTime), + "2026-05-01 08:30:00" + ); + } + + #[test] + fn format_temporal_tick_preserves_offset_for_timestamptz() { + let (kind, secs) = parse_temporal("2026-05-01 08:30:00+05:30").unwrap(); + assert_eq!( + format_temporal_tick(secs, kind), + "2026-05-01 08:30:00+05:30" + ); + } + + #[test] + fn format_temporal_tick_handles_nan() { + // Plotters can theoretically pass NaN/infinity for axis ticks + // when the range is degenerate. We must not panic. + assert_eq!(format_temporal_tick(f64::NAN, TemporalKind::Date), ""); + assert_eq!( + format_temporal_tick(f64::INFINITY, TemporalKind::DateTime), + "" + ); + } + + #[test] + fn detect_line_x_mode_picks_temporal_for_dates() { + let rows = vec![serde_json::json!({"ts": "2026-05-01"})]; + let mode = detect_line_x_mode(&rows, "ts"); + assert!(matches!(mode, XMode::Temporal(TemporalKind::Date))); + } + + #[test] + fn detect_line_x_mode_picks_temporal_for_timestamps() { + let rows = vec![serde_json::json!({"ts": "2026-05-01 08:00:00"})]; + let mode = detect_line_x_mode(&rows, "ts"); + assert!(matches!(mode, XMode::Temporal(TemporalKind::DateTime))); + } + + #[test] + fn detect_line_x_mode_picks_temporal_for_timestamptz() { + let rows = vec![serde_json::json!({"ts": "2026-05-01 08:00:00+00:00"})]; + let mode = detect_line_x_mode(&rows, "ts"); + assert!(matches!(mode, XMode::Temporal(TemporalKind::DateTimeTz(0)))); + } + + #[test] + fn detect_line_x_mode_falls_back_to_categorical_for_text() { + let rows = vec![serde_json::json!({"x": "alpha"})]; + let mode = detect_line_x_mode(&rows, "x"); + assert!(matches!(mode, XMode::Categorical)); + } + + #[test] + fn detect_line_x_mode_picks_numeric_for_numbers() { + let rows = vec![serde_json::json!({"x": 42.0})]; + let mode = detect_line_x_mode(&rows, "x"); + assert!(matches!(mode, XMode::Numeric)); + } +} diff --git a/hyperdb-mcp/src/readme.rs b/hyperdb-mcp/src/readme.rs index 1eaac21..70c6e7c 100644 --- a/hyperdb-mcp/src/readme.rs +++ b/hyperdb-mcp/src/readme.rs @@ -132,8 +132,11 @@ watcher targets the alias; call `unwatch_directory` first. Arrow IPC, CSV, .hyper). - `chart` — render a bar / line / scatter / histogram PNG from a SQL query. Data must be long-format (one numeric y column; use a `series` - column for grouping). DATE/TIMESTAMP/TEXT x columns are auto-detected - as categorical for line/scatter. Wide-format data must be reshaped + column for grouping). On line/scatter charts, DATE / TIMESTAMP / + TIMESTAMPTZ x columns auto-detect to a **proportional time axis** + (real-world gaps reflected in spacing); TEXT x falls back to evenly + spaced categorical mode. Pass `x_as_category: true` to force + categorical even on temporal data. Wide-format data must be reshaped with UNION ALL. - `copy_query` — run a SELECT across local + attached databases and insert the result into a target table (`mode`: `create`, `append`, diff --git a/hyperdb-mcp/src/server.rs b/hyperdb-mcp/src/server.rs index 19f9ab7..d709fbf 100644 --- a/hyperdb-mcp/src/server.rs +++ b/hyperdb-mcp/src/server.rs @@ -2299,7 +2299,7 @@ impl HyperMcpServer { /// Render a chart (PNG or SVG) from a SQL query. #[tool( - description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns (DATE, TIMESTAMP, TEXT) and switch to categorical mode automatically. You can still set `x_as_category` explicitly to override.\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." + description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." )] fn chart( &self, diff --git a/hyperdb-mcp/tests/chart_tests.rs b/hyperdb-mcp/tests/chart_tests.rs index 4ad5097..ee06ca4 100644 --- a/hyperdb-mcp/tests/chart_tests.rs +++ b/hyperdb-mcp/tests/chart_tests.rs @@ -584,3 +584,51 @@ fn line_chart_many_timestamps_auto_thins() { let result = render_chart(&rows, &opts).unwrap(); assert_eq!(result.rows_plotted, 30); } + +/// Regression: a 90-point hourly TIMESTAMP series used to render with +/// only ONE visible x-axis label because the old `shorten_labels` +/// blanked non-step indices and `plotters` picked tick positions that +/// rarely landed on a kept index. The fix tells `plotters` how many +/// ticks to draw up front, so every tick position carries a real label. +/// +/// Uses SVG mode so we can inspect the rendered text content directly. +#[test] +fn line_chart_long_timestamp_series_renders_multiple_visible_labels() { + let rows: Vec<_> = (0..90) + .map(|i| { + json!({ + "ts": format!("2026-01-{:02} {:02}:00:00", (i / 24) + 1, i % 24), + "value": i + }) + }) + .collect(); + let opts = ChartOptions { + chart_type: ChartType::Line, + format: ChartFormat::Svg, + x_column: Some("ts".into()), + y_column: Some("value".into()), + width: 800, + height: 480, + ..ChartOptions::default() + }; + let result = render_chart(&rows, &opts).unwrap(); + assert_eq!(result.rows_plotted, 90); + let svg = String::from_utf8(result.bytes).expect("SVG must be UTF-8"); + // Each visible x-axis tick label is rendered as an SVG + // element containing the literal label string. Every label in this + // series starts with "2026-01-", so counting that prefix gives the + // number of *visible* x-axis labels (the y-axis labels are numeric + // and won't match). + let visible_labels = svg.matches("2026-01-").count(); + assert!( + visible_labels >= 3, + "expected >= 3 visible x-axis labels for a 90-point series, got {visible_labels} \ + (regression: pre-fix value was 1)" + ); + // Also bound the upper end — too many would mean labels overlap; + // 800 / (19chars * 7px + 10px) ≈ 5 is the heuristic target. + assert!( + visible_labels <= 12, + "expected <= 12 visible labels (no overlap on 800px wide chart), got {visible_labels}" + ); +} From 5aa421131779f30eb7fb3ccd2ce340d5fd7e501d Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 26 May 2026 02:48:06 -0700 Subject: [PATCH 6/7] chore: re-enable release-please to release v0.2.0 --- .github/workflows/release-please.yml | 4 +--- README.md | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d4a45e6..277c4b7 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -28,9 +28,7 @@ permissions: jobs: release-please: - # Temporarily disabled until post-daemon/two-db-model work is complete. - # Re-enable by removing the `false &&` prefix below. - if: false && github.repository == 'tableau/hyper-api-rust' + if: github.repository == 'tableau/hyper-api-rust' runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@v5 diff --git a/README.md b/README.md index a23a471..ff2f4e8 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ A **pure-Rust** implementation of the Hyper database API, using the PostgreSQL wire protocol with Hyper-specific extensions. Create, read, and manipulate Hyper database files (`.hyper`) without any C library dependencies. -> **Project Status — 0.1.x, AI-Engineered** +> **Project Status — 0.2.x, AI-Engineered** > > This crate was vibe-engineered with heavy use of AI coding assistants. The -> **0.1.x** line may still undergo large breaking changes; the public API -> won't settle until the 0.2.0 release. +> **0.2.x** line may still undergo large breaking changes; the public API +> won't settle until the 1.0.0 release. > > Contributors and reviewers should, at a minimum, run an **AI code reviewer** > over any changes, following the conventions, layering rules, and patterns From 1f34f61c0f4d027da2bdde742c11e427420f892f Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 26 May 2026 02:51:42 -0700 Subject: [PATCH 7/7] chore: fix formatting --- hyperdb-mcp/src/chart.rs | 60 +++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/hyperdb-mcp/src/chart.rs b/hyperdb-mcp/src/chart.rs index 51e1055..0f1772d 100644 --- a/hyperdb-mcp/src/chart.rs +++ b/hyperdb-mcp/src/chart.rs @@ -623,7 +623,12 @@ fn strip_shared_tz_suffix(labels: &[String]) -> Vec { }; labels .iter() - .map(|l| l.strip_suffix(suffix.as_str()).unwrap_or(l).trim().to_string()) + .map(|l| { + l.strip_suffix(suffix.as_str()) + .unwrap_or(l) + .trim() + .to_string() + }) .collect() } @@ -666,11 +671,7 @@ fn auto_tick_count(labels: &[String], chart_width: u32) -> usize { if labels.len() <= 1 { return labels.len(); } - let max_chars = labels - .iter() - .map(|l| l.chars().count()) - .max() - .unwrap_or(1); + let max_chars = labels.iter().map(|l| l.chars().count()).max().unwrap_or(1); tick_count_for_label_width(max_chars, chart_width).min(labels.len()) } @@ -834,13 +835,19 @@ fn parse_temporal(s: &str) -> Option<(TemporalKind, f64)> { ]; for fmt in DT_FORMATS { if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) { - return Some((TemporalKind::DateTime, Utc.from_utc_datetime(&dt).timestamp() as f64)); + return Some(( + TemporalKind::DateTime, + Utc.from_utc_datetime(&dt).timestamp() as f64, + )); } } if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { let dt = date.and_hms_opt(0, 0, 0)?; - return Some((TemporalKind::Date, Utc.from_utc_datetime(&dt).timestamp() as f64)); + return Some(( + TemporalKind::Date, + Utc.from_utc_datetime(&dt).timestamp() as f64, + )); } None @@ -1529,32 +1536,29 @@ mod tests { "2026-05-03 18:30:00+00:00", ]); let stripped = strip_shared_tz_suffix(&labels); - assert_eq!(stripped, s(&[ - "2026-05-01 08:00:00", - "2026-05-02 06:15:00", - "2026-05-03 18:30:00", - ])); + assert_eq!( + stripped, + s(&[ + "2026-05-01 08:00:00", + "2026-05-02 06:15:00", + "2026-05-03 18:30:00", + ]) + ); } #[test] fn strip_shared_tz_suffix_handles_non_utc_offset() { - let labels = s(&[ - "2026-05-01 08:00:00+05:30", - "2026-05-02 06:15:00+05:30", - ]); + let labels = s(&["2026-05-01 08:00:00+05:30", "2026-05-02 06:15:00+05:30"]); let stripped = strip_shared_tz_suffix(&labels); - assert_eq!(stripped, s(&[ - "2026-05-01 08:00:00", - "2026-05-02 06:15:00", - ])); + assert_eq!( + stripped, + s(&["2026-05-01 08:00:00", "2026-05-02 06:15:00",]) + ); } #[test] fn strip_shared_tz_suffix_preserves_when_offsets_differ() { - let labels = s(&[ - "2026-05-01 08:00:00+00:00", - "2026-05-02 06:15:00+05:30", - ]); + let labels = s(&["2026-05-01 08:00:00+00:00", "2026-05-02 06:15:00+05:30"]); let stripped = strip_shared_tz_suffix(&labels); assert_eq!(stripped, labels, "differing offsets must not be stripped"); } @@ -1655,11 +1659,9 @@ mod tests { #[test] fn parse_temporal_recognizes_timestamp() { - let (kind, secs1) = - parse_temporal("2026-05-01 08:00:00").expect("TIMESTAMP should parse"); + let (kind, secs1) = parse_temporal("2026-05-01 08:00:00").expect("TIMESTAMP should parse"); assert_eq!(kind, TemporalKind::DateTime); - let (_, secs2) = - parse_temporal("2026-05-01 12:30:00").expect("TIMESTAMP should parse"); + let (_, secs2) = parse_temporal("2026-05-01 12:30:00").expect("TIMESTAMP should parse"); // Same date, 4.5 hours apart. let delta = secs2 - secs1; assert!(