Skip to main content

benday_core/
spec.rs

1//! The chart specification: a strict subset of Vega-Lite's JSON grammar.
2//!
3//! Unknown fields are rejected at parse time (`deny_unknown_fields`) so that
4//! callers emitting full Vega-Lite get a correctable error instead of a
5//! silently wrong chart.
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct Spec {
12    /// Optional: rows may instead arrive on stdin. `ingest::resolve` enforces
13    /// that data is present in exactly one place.
14    #[serde(default)]
15    pub data: Option<Data>,
16    pub mark: Mark,
17    pub encoding: Encoding,
18    #[serde(default)]
19    pub title: Option<String>,
20    /// Plot area width in terminal cells (not total output width).
21    #[serde(default)]
22    pub width: Option<usize>,
23    /// Plot area height in terminal cells.
24    #[serde(default)]
25    pub height: Option<usize>,
26}
27
28/// Inline data: tidy row objects, OR columnar `columns` + `rows`. Exactly one
29/// form — `ingest::resolve` enforces it (serde can't express either/or here
30/// without wrecking error paths).
31#[derive(Debug, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct Data {
34    /// Inline tidy data: one JSON object per row.
35    #[serde(default)]
36    pub values: Option<Vec<serde_json::Map<String, serde_json::Value>>>,
37    #[serde(default)]
38    pub columns: Option<Vec<Column>>,
39    #[serde(default)]
40    pub rows: Option<Vec<Vec<serde_json::Value>>>,
41}
42
43/// Strict twin of `ingest::EnvColumn`: the spec is agent-authored, so unknown
44/// keys are rejected here.
45#[derive(Debug, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct Column {
48    pub name: String,
49    #[serde(default, rename = "type")]
50    pub ty: Option<String>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Mark {
56    Bar,
57    Line,
58    Point,
59    Area,
60}
61
62#[derive(Debug, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct Encoding {
65    pub x: Channel,
66    pub y: Channel,
67    #[serde(default)]
68    pub color: Option<Channel>,
69    /// Accepted by the grammar only so validation can reject it with a helpful
70    /// redirect (grouping is expressed with `color`). Typed as a raw `Value`,
71    /// not a `Channel`: Vega-Lite emits several xOffset shapes (`{"field": …}`,
72    /// `{"value": …}`, band configs) and a strict `Channel` would bounce them
73    /// into serde's generic unknown-field error before validation could help.
74    #[serde(default, rename = "xOffset")]
75    pub x_offset: Option<serde_json::Value>,
76}
77
78#[derive(Debug, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct Channel {
81    pub field: String,
82    /// Inferred from the data when omitted.
83    #[serde(default, rename = "type")]
84    pub ty: Option<FieldType>,
85    #[serde(default)]
86    pub aggregate: Option<Aggregate>,
87    /// Calendar-truncation bucketing for a temporal x on a `bar` mark: each
88    /// value is floored to the unit's boundary, keeping the calendar prefix
89    /// (`"month"` maps `2026-06-14` to `2026-06`, NOT Vega-Lite's cyclic "all
90    /// Junes"). Bar-only, x-only this cycle — every other placement is a
91    /// teaching error (see `compile`). Inferred nothing when omitted.
92    #[serde(default, rename = "timeUnit")]
93    pub time_unit: Option<TimeUnit>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
97#[serde(rename_all = "lowercase")]
98pub enum FieldType {
99    Quantitative,
100    Nominal,
101    Ordinal,
102    /// Continuous time: positioned at true instants on a calendar scale (see
103    /// docs/plans/2026-07-05-temporal-family-design.md). An explicit
104    /// `"ordinal"` restores evenly-spaced categorical behavior.
105    Temporal,
106}
107
108/// A calendar-truncation bucket unit for `timeUnit` (temporal bars). Ordered
109/// coarse-relevant but semantics are per-variant: truncate a timestamp to this
110/// unit's boundary, KEEPING the year (design §spec semantics). Week anchors to
111/// Monday.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
113#[serde(rename_all = "lowercase")]
114pub enum TimeUnit {
115    Year,
116    Quarter,
117    Month,
118    Week,
119    Day,
120    Hour,
121    Minute,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
125#[serde(rename_all = "lowercase")]
126pub enum Aggregate {
127    Sum,
128    Mean,
129    Median,
130    Min,
131    Max,
132    Count,
133}