Lines
100 %
Functions
75 %
Branches
//! Chart intermediate representation.
//!
//! This is the one contract that adapters, renderers (SVG, canvas,
//! ratatui), and the JSON wire format all share. Serialises cleanly so
//! the WASM canvas renderer can consume the same bytes the server
//! produces.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChartKind {
Bar,
StackedBar,
Line,
}
/// One x/y datum. `y` is carried as a `num`/`denom` pair so the exact
/// Rational64 crosses the wire without float drift; each renderer
/// converts to `f64` for display.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeriesPoint {
pub x: String,
pub y_num: i64,
pub y_denom: i64,
impl SeriesPoint {
/// Best-effort conversion to an `f64` for the renderer. A zero
/// denominator falls back to 0.0 rather than panicking — an
/// invariant violation in the IR shouldn't take down the renderer.
#[must_use]
pub fn y_f64(&self) -> f64 {
if self.y_denom == 0 {
return 0.0;
self.y_num as f64 / self.y_denom as f64
pub struct Series {
pub label: String,
/// Commodity symbol (e.g. "USD"). Empty when the series is unitless.
pub commodity_symbol: String,
pub points: Vec<SeriesPoint>,
/// A chart the server has already prepared. Renderers read this
/// directly; none of them compute totals, pick top-N, or negate
/// signs. All of that lives in the adapters.
pub struct ChartSpec {
pub title: String,
pub kind: ChartKind,
pub x_label: String,
pub y_label: String,
pub series: Vec<Series>,
/// Display-time notes (e.g. "Showing USD; 2 other commodities
/// hidden"). Rendered as an info line under the chart.
pub notes: Vec<String>,
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ChartSpec {
ChartSpec {
title: "Income vs Expense".to_string(),
kind: ChartKind::StackedBar,
x_label: "Month".to_string(),
y_label: "Amount".to_string(),
series: vec![
Series {
label: "Income".to_string(),
commodity_symbol: "USD".to_string(),
points: vec![
SeriesPoint {
x: "2026-01".to_string(),
y_num: 3200,
y_denom: 1,
},
x: "2026-02".to_string(),
y_num: 2800,
],
label: "Expense".to_string(),
y_num: 2100,
y_num: 1900,
notes: vec!["Showing USD only; 1 other commodity hidden".to_string()],
#[test]
fn spec_round_trips_through_json() {
let original = sample();
let encoded = serde_json::to_string(&original).expect("serialise");
let decoded: ChartSpec = serde_json::from_str(&encoded).expect("deserialise");
assert_eq!(decoded, original, "wire format must be lossless");
fn chart_kind_serialises_as_lowercase() {
let s = serde_json::to_string(&ChartKind::StackedBar).expect("serialise");
assert_eq!(s, "\"stackedbar\"");
fn series_point_converts_to_f64() {
let p = SeriesPoint {
x: "x".to_string(),
y_num: 3,
y_denom: 2,
};
assert!((p.y_f64() - 1.5).abs() < f64::EPSILON);
fn series_point_zero_denominator_is_safe() {
y_num: 42,
y_denom: 0,
assert_eq!(p.y_f64(), 0.0);