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};
8use std::fmt;
9
10#[derive(Debug, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct Spec {
13    /// Optional: rows may instead arrive on stdin. `ingest::resolve` enforces
14    /// that data is present in exactly one place.
15    #[serde(default)]
16    pub data: Option<Data>,
17    pub mark: Mark,
18    pub encoding: Encoding,
19    #[serde(default)]
20    pub title: Option<String>,
21    /// Plot area width in terminal cells (not total output width).
22    #[serde(default)]
23    pub width: Option<usize>,
24    /// Plot area height in terminal cells.
25    #[serde(default)]
26    pub height: Option<usize>,
27}
28
29/// Inline data: tidy row objects, OR columnar `columns` + `rows`. Exactly one
30/// form — `ingest::resolve` enforces it (serde can't express either/or here
31/// without wrecking error paths).
32#[derive(Debug, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Data {
35    /// Inline tidy data: one JSON object per row.
36    #[serde(default)]
37    pub values: Option<Vec<serde_json::Map<String, serde_json::Value>>>,
38    #[serde(default)]
39    pub columns: Option<Vec<Column>>,
40    #[serde(default)]
41    pub rows: Option<Vec<Vec<serde_json::Value>>>,
42}
43
44/// Strict twin of `ingest::EnvColumn`: the spec is agent-authored, so unknown
45/// keys are rejected here.
46#[derive(Debug, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct Column {
49    pub name: String,
50    #[serde(default, rename = "type")]
51    pub ty: Option<String>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
55#[serde(rename_all = "lowercase")]
56pub enum Mark {
57    Bar,
58    Line,
59    Point,
60    Area,
61}
62
63#[derive(Debug, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct Encoding {
66    pub x: Channel,
67    pub y: Channel,
68    #[serde(default)]
69    pub color: Option<Channel>,
70    /// Accepted by the grammar only so validation can reject it with a helpful
71    /// redirect (grouping is expressed with `color`). Typed as a raw `Value`,
72    /// not a `Channel`: Vega-Lite emits several xOffset shapes (`{"field": …}`,
73    /// `{"value": …}`, band configs) and a strict `Channel` would bounce them
74    /// into serde's generic unknown-field error before validation could help.
75    #[serde(default, rename = "xOffset")]
76    pub x_offset: Option<serde_json::Value>,
77}
78
79#[derive(Debug, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct Channel {
82    pub field: String,
83    /// Inferred from the data when omitted.
84    #[serde(default, rename = "type")]
85    pub ty: Option<FieldType>,
86    #[serde(default)]
87    pub aggregate: Option<Aggregate>,
88    /// Calendar-truncation bucketing for a temporal x on a `bar` mark: each
89    /// value is floored to the unit's boundary, keeping the calendar prefix
90    /// (`"month"` maps `2026-06-14` to `2026-06`, NOT Vega-Lite's cyclic "all
91    /// Junes"). Bar-only, x-only this cycle — every other placement is a
92    /// teaching error (see `compile`). Inferred nothing when omitted.
93    #[serde(default, rename = "timeUnit")]
94    pub time_unit: Option<TimeUnit>,
95    /// Histogram binning for a quantitative x on a `bar` mark: `true` (automatic
96    /// nice bins), `{"maxbins": N}`, or `{"step": w}`. `false` is accepted and
97    /// means absent. Bar-only, x-only this cycle — every other placement is a
98    /// teaching error (see `compile`). Value ranges are validated in `preflight`
99    /// (not serde) so an out-of-range knob teaches instead of bouncing generically.
100    #[serde(default)]
101    pub bin: Option<BinValue>,
102}
103
104/// `"bin": true` | `{"maxbins": N}` | `{"step": w}`. `false` is accepted and
105/// means absent (Vega-Lite emits it; rejecting would be noise). Numbers stay
106/// permissive (`f64`) so `preflight` can TEACH — serde must not bounce
107/// `maxbins: 0` with a generic type error before validation can explain it.
108#[derive(Debug, Clone, Copy)]
109pub enum BinValue {
110    Flag(bool),
111    Config(BinConfig),
112}
113
114/// A hand-written `Deserialize` instead of `#[serde(untagged)]`: untagged
115/// buffers the value and, on failure, collapses to a generic "data did not
116/// match any variant" — swallowing `BinConfig`'s precise unknown-field message.
117/// Delegating the map form to `BinConfig` preserves "unknown field `extent`,
118/// expected `maxbins` or `step`" (the codebase-wide `deny_unknown_fields`
119/// contract) and rejects a bare number/string with a clear `expecting` line —
120/// so `{"bin": {"extent": [0,1]}}` and `{"bin": 3}` both fail AT the bin value,
121/// never silently matching `Flag`.
122impl<'de> Deserialize<'de> for BinValue {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: serde::Deserializer<'de>,
126    {
127        struct BinVisitor;
128        impl<'de> serde::de::Visitor<'de> for BinVisitor {
129            type Value = BinValue;
130            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
131                f.write_str("`true`/`false`, or a bin object with `maxbins` or `step`")
132            }
133            fn visit_bool<E>(self, v: bool) -> Result<BinValue, E> {
134                Ok(BinValue::Flag(v))
135            }
136            fn visit_map<A>(self, map: A) -> Result<BinValue, A::Error>
137            where
138                A: serde::de::MapAccess<'de>,
139            {
140                BinConfig::deserialize(serde::de::value::MapAccessDeserializer::new(map))
141                    .map(BinValue::Config)
142            }
143        }
144        // JSON is self-describing, so `deserialize_any` dispatches on the actual
145        // token (bool → Flag, object → Config; anything else → `expecting`).
146        deserializer.deserialize_any(BinVisitor)
147    }
148}
149
150/// The object form of `bin`. `deny_unknown_fields` rejects stray keys with a
151/// named message; both knobs stay permissive `Option<f64>` so `preflight`
152/// validates ranges with teaching errors. Neither present (`{}`) means
153/// automatic binning, exactly like `true`.
154#[derive(Debug, Clone, Copy, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct BinConfig {
157    #[serde(default)]
158    pub maxbins: Option<f64>,
159    #[serde(default)]
160    pub step: Option<f64>,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
164#[serde(rename_all = "lowercase")]
165pub enum FieldType {
166    Quantitative,
167    Nominal,
168    Ordinal,
169    /// Continuous time: positioned at true instants on a calendar scale (see
170    /// docs/plans/2026-07-05-temporal-family-design.md). An explicit
171    /// `"ordinal"` restores evenly-spaced categorical behavior.
172    Temporal,
173}
174
175/// A calendar-truncation bucket unit for `timeUnit` (temporal bars). Ordered
176/// coarse-relevant but semantics are per-variant: truncate a timestamp to this
177/// unit's boundary, KEEPING the year (design §spec semantics). Week anchors to
178/// Monday.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
180#[serde(rename_all = "lowercase")]
181pub enum TimeUnit {
182    Year,
183    Quarter,
184    Month,
185    Week,
186    Day,
187    Hour,
188    Minute,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
192#[serde(rename_all = "lowercase")]
193pub enum Aggregate {
194    Sum,
195    Mean,
196    Median,
197    Min,
198    Max,
199    Count,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// A minimal spec wrapping the given `x` channel object — the bin grammar
207    /// lives on a channel, so parse checks route through a full `Spec`
208    /// (the surface an agent actually posts) to exercise the real path.
209    fn parse_x(x: &str) -> Result<Spec, serde_json::Error> {
210        serde_json::from_str(&format!(
211            r#"{{"data":{{"values":[{{"a":1}}]}},"mark":"bar",
212                "encoding":{{"x":{x},"y":{{"field":"a"}}}}}}"#
213        ))
214    }
215
216    fn bin_of(spec: &Spec) -> Option<BinValue> {
217        spec.encoding.x.bin
218    }
219
220    #[test]
221    fn bin_true_parses_as_flag() {
222        let spec = parse_x(r#"{"field":"a","bin":true}"#).expect("bin:true parses");
223        assert!(matches!(bin_of(&spec), Some(BinValue::Flag(true))));
224    }
225
226    #[test]
227    fn bin_false_parses_as_flag() {
228        // `false` is accepted (Vega-Lite emits it) and means absent — the
229        // channel-placement checks treat it as if `bin` were omitted.
230        let spec = parse_x(r#"{"field":"a","bin":false}"#).expect("bin:false parses");
231        assert!(matches!(bin_of(&spec), Some(BinValue::Flag(false))));
232    }
233
234    #[test]
235    fn bin_absent_is_none() {
236        let spec = parse_x(r#"{"field":"a"}"#).expect("no bin parses");
237        assert!(bin_of(&spec).is_none());
238    }
239
240    #[test]
241    fn bin_maxbins_parses_as_config() {
242        let spec = parse_x(r#"{"field":"a","bin":{"maxbins":15}}"#).expect("maxbins parses");
243        assert!(matches!(
244            bin_of(&spec),
245            Some(BinValue::Config(BinConfig {
246                maxbins: Some(m),
247                step: None
248            })) if m == 15.0
249        ));
250    }
251
252    #[test]
253    fn bin_step_parses_as_config() {
254        let spec = parse_x(r#"{"field":"a","bin":{"step":10}}"#).expect("step parses");
255        assert!(matches!(
256            bin_of(&spec),
257            Some(BinValue::Config(BinConfig {
258                maxbins: None,
259                step: Some(s)
260            })) if s == 10.0
261        ));
262    }
263
264    #[test]
265    fn bin_empty_config_parses() {
266        // `{}` is the automatic-binning shape (bin:true parity), NOT an error.
267        let spec = parse_x(r#"{"field":"a","bin":{}}"#).expect("empty config parses");
268        assert!(matches!(
269            bin_of(&spec),
270            Some(BinValue::Config(BinConfig {
271                maxbins: None,
272                step: None
273            }))
274        ));
275    }
276
277    // Permissive numbers: serde MUST accept out-of-range knobs so `preflight`
278    // can teach instead of serde bouncing the spec with a generic type error.
279    #[test]
280    fn bin_maxbins_zero_parses() {
281        parse_x(r#"{"field":"a","bin":{"maxbins":0}}"#)
282            .expect("maxbins:0 parses (preflight teaches)");
283    }
284
285    #[test]
286    fn bin_maxbins_fractional_parses() {
287        parse_x(r#"{"field":"a","bin":{"maxbins":2.5}}"#)
288            .expect("maxbins:2.5 parses (preflight teaches)");
289    }
290
291    #[test]
292    fn bin_step_negative_parses() {
293        parse_x(r#"{"field":"a","bin":{"step":-1}}"#).expect("step:-1 parses (preflight teaches)");
294    }
295
296    #[test]
297    fn bin_step_zero_parses() {
298        parse_x(r#"{"field":"a","bin":{"step":0}}"#).expect("step:0 parses (preflight teaches)");
299    }
300
301    // `deny_unknown_fields` on BinConfig: an unknown key inside the object must
302    // ERROR — never silently fall through to `Flag` — and NAME the stray field
303    // (the manual `Deserialize` preserves BinConfig's precise message).
304    #[test]
305    fn bin_unknown_field_errors() {
306        let err = parse_x(r#"{"field":"a","bin":{"extent":[0,1]}}"#)
307            .expect_err("unknown bin field must error, not match Flag");
308        let msg = err.to_string();
309        assert!(
310            msg.contains("unknown field `extent`") && msg.contains("maxbins"),
311            "extent error must name the stray field and the valid knobs: {msg}"
312        );
313    }
314
315    // A bare number is neither a bool nor the object form: it must ERROR at the
316    // bin value with the `expecting` line, never silently match.
317    #[test]
318    fn bin_bare_number_errors() {
319        let err =
320            parse_x(r#"{"field":"a","bin":3}"#).expect_err("bare-number bin must error, not match");
321        let msg = err.to_string();
322        assert!(
323            msg.contains("maxbins") || msg.contains("bin object"),
324            "bare-number error must point at the bin shape: {msg}"
325        );
326    }
327}