Skip to main content

chartml_core/spec/
mod.rs

1pub mod chart;
2pub mod config;
3pub mod params;
4pub mod source;
5pub mod style;
6pub mod transform;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::ChartError;
11
12pub use chart::{
13    AnnotationSpec, AxesSpec, AxisSpec, ChartMode, ChartSpec, ChartStyleSpec, DataLabelsSpec,
14    DataRef, FieldRef, FieldRefItem, FieldSpec, InlineData, LayoutSpec, MarkEncoding,
15    MarkEncodingSpec, MarksSpec, Orientation, StyleRefOrInline, VisualizeSpec,
16};
17pub use config::{ConfigSpec, StyleRef};
18pub use params::{ParamDef, ParamsSpec};
19pub use source::{CacheConfig, SourceSpec};
20pub use style::{FontSpec, FontsSpec, GridSpec, LegendSpec, StyleSpec};
21pub use transform::{
22    AggregateSpec, Dimension, DimensionSpec, FilterGroup, FilterRule, ForecastSpec, Measure,
23    SortSpec, SqlSpec, TransformSpec,
24};
25
26/// A parsed ChartML specification: either a single component or multiple components.
27#[derive(Debug, Clone, Deserialize, Serialize)]
28#[serde(untagged)]
29pub enum ChartMLSpec {
30    Single(Box<Component>),
31    Array(Vec<Component>),
32}
33
34/// A component within a ChartML document, discriminated by the `type` field.
35#[derive(Debug, Clone, Deserialize, Serialize)]
36#[serde(tag = "type")]
37pub enum Component {
38    #[serde(rename = "chart")]
39    Chart(Box<ChartSpec>),
40    #[serde(rename = "source")]
41    Source(SourceSpec),
42    #[serde(rename = "style")]
43    Style(Box<StyleSpec>),
44    #[serde(rename = "config")]
45    Config(ConfigSpec),
46    #[serde(rename = "params")]
47    Params(ParamsSpec),
48}
49
50/// Parse a ChartML YAML string into a `ChartMLSpec`.
51///
52/// Handles both single-document and multi-document YAML (separated by `---`).
53/// For multi-document YAML, each document is parsed as a separate `Component`
54/// and returned as `ChartMLSpec::Array`.
55pub fn parse(input: &str) -> Result<ChartMLSpec, ChartError> {
56    // Check if the input contains multiple YAML documents by looking for
57    // document separators. We split on "---" that appears at the start of a line.
58    let documents: Vec<&str> = split_yaml_documents(input);
59
60    if documents.len() <= 1 {
61        // Single document: try to parse as a single component first,
62        // then fall back to an array of components.
63        let yaml_str = documents.first().copied().unwrap_or(input);
64        let spec: ChartMLSpec = serde_yaml::from_str(yaml_str)?;
65        Ok(spec)
66    } else {
67        // Multiple documents: parse each as a Component or array of Components.
68        let mut components = Vec::with_capacity(documents.len());
69        for doc in documents {
70            let trimmed = doc.trim();
71            if trimmed.is_empty() {
72                continue;
73            }
74            // Try parsing as a single component first, then as an array of components
75            if let Ok(component) = serde_yaml::from_str::<Component>(trimmed) {
76                components.push(component);
77            } else {
78                // May be an array of components (e.g., multiple charts in one document)
79                let array: Vec<Component> = serde_yaml::from_str(trimmed)?;
80                components.extend(array);
81            }
82        }
83        Ok(ChartMLSpec::Array(components))
84    }
85}
86
87/// Split a YAML string into individual documents by `---` separators.
88fn split_yaml_documents(input: &str) -> Vec<&str> {
89    let mut documents = Vec::new();
90    let mut start = 0;
91
92    // Walk through the input looking for lines that are exactly "---" (possibly with trailing whitespace)
93    let bytes = input.as_bytes();
94    let len = bytes.len();
95    let mut i = 0;
96
97    while i < len {
98        // Find end of current line
99        let line_start = i;
100        while i < len && bytes[i] != b'\n' {
101            i += 1;
102        }
103        let line_end = i;
104        if i < len {
105            i += 1; // skip newline
106        }
107
108        // Check if this line is a document separator
109        let line = input[line_start..line_end].trim_end();
110        if line == "---" {
111            // Everything before this separator is a document (if non-empty)
112            let doc = &input[start..line_start];
113            if !doc.trim().is_empty() {
114                documents.push(doc);
115            }
116            start = if i < len { i } else { line_end };
117        }
118    }
119
120    // Remaining content after the last separator
121    let remaining = &input[start..];
122    if !remaining.trim().is_empty() {
123        documents.push(remaining);
124    }
125
126    // If no separators were found, return the whole input as one document
127    if documents.is_empty() && !input.trim().is_empty() {
128        documents.push(input);
129    }
130
131    documents
132}
133
134#[cfg(test)]
135mod tests {
136    #![allow(clippy::unwrap_used)]
137    use super::*;
138
139    /// Helper: unwrap a ChartMLSpec::Single into its inner Component.
140    fn unwrap_single(spec: ChartMLSpec) -> Component {
141        match spec {
142            ChartMLSpec::Single(component) => *component,
143            other => panic!("Expected Single, got {:?}", other),
144        }
145    }
146
147    #[test]
148    fn test_parse_single_source() {
149        let yaml = r#"
150type: source
151version: 1
152name: sales
153provider: inline
154rows:
155  - { month: "Jan", revenue: 100 }
156  - { month: "Feb", revenue: 200 }
157"#;
158        let result = parse(yaml).unwrap();
159        match unwrap_single(result) {
160            Component::Source(source) => {
161                assert_eq!(source.name, "sales");
162                assert_eq!(source.provider, "inline");
163                assert!(source.rows.is_some());
164                assert_eq!(source.rows.unwrap().len(), 2);
165            }
166            other => panic!("Expected Source, got {:?}", other),
167        }
168    }
169
170    #[test]
171    fn test_parse_single_chart() {
172        let yaml = r#"
173type: chart
174version: 1
175title: Revenue by Month
176data: sales
177visualize:
178  type: bar
179  columns: month
180  rows: revenue
181"#;
182        let result = parse(yaml).unwrap();
183        match unwrap_single(result) {
184            Component::Chart(chart) => {
185                assert_eq!(chart.title, Some("Revenue by Month".to_string()));
186                assert_eq!(chart.visualize.chart_type, "bar");
187                match &chart.data {
188                    DataRef::Named(name) => assert_eq!(name, "sales"),
189                    other => panic!("Expected Named data ref, got {:?}", other),
190                }
191            }
192            other => panic!("Expected Chart, got {:?}", other),
193        }
194    }
195
196    #[test]
197    fn test_parse_multi_document() {
198        let yaml = r#"---
199type: source
200version: 1
201name: sales
202provider: inline
203rows:
204  - { month: "Jan", revenue: 100 }
205---
206type: chart
207version: 1
208title: Revenue
209data: sales
210visualize:
211  type: bar
212  columns: month
213  rows: revenue
214"#;
215        let result = parse(yaml).unwrap();
216        match result {
217            ChartMLSpec::Array(components) => {
218                assert_eq!(components.len(), 2);
219                assert!(matches!(&components[0], Component::Source(_)));
220                assert!(matches!(&components[1], Component::Chart(_)));
221            }
222            other => panic!("Expected Array, got {:?}", other),
223        }
224    }
225
226    #[test]
227    fn test_parse_inline_data() {
228        let yaml = r#"
229type: chart
230version: 1
231data:
232  provider: inline
233  rows:
234    - { x: 1, y: 2 }
235visualize:
236  type: line
237  columns: x
238  rows: y
239"#;
240        let result = parse(yaml).unwrap();
241        match unwrap_single(result) {
242            Component::Chart(chart) => {
243                match &chart.data {
244                    DataRef::Inline(data) => {
245                        assert_eq!(data.provider.as_deref(), Some("inline"));
246                        assert!(data.rows.is_some());
247                    }
248                    other => panic!("Expected Inline data ref, got {:?}", other),
249                }
250            }
251            other => panic!("Expected Chart, got {:?}", other),
252        }
253    }
254
255    #[test]
256    fn test_parse_field_ref_variants() {
257        // Simple string field ref
258        let yaml = r#"
259type: chart
260version: 1
261data: test
262visualize:
263  type: bar
264  columns: month
265  rows:
266    field: revenue
267    label: "Revenue ($)"
268"#;
269        let result = parse(yaml).unwrap();
270        match unwrap_single(result) {
271            Component::Chart(chart) => {
272                match &chart.visualize.columns {
273                    Some(FieldRef::Simple(s)) => assert_eq!(s, "month"),
274                    other => panic!("Expected Simple field ref, got {:?}", other),
275                }
276                match &chart.visualize.rows {
277                    Some(FieldRef::Detailed(spec)) => {
278                        assert_eq!(spec.field.as_deref(), Some("revenue"));
279                        assert_eq!(spec.label, Some("Revenue ($)".to_string()));
280                    }
281                    other => panic!("Expected Detailed field ref, got {:?}", other),
282                }
283            }
284            other => panic!("Expected Chart, got {:?}", other),
285        }
286    }
287
288    #[test]
289    fn test_parse_metric_chart() {
290        let yaml = r#"
291type: chart
292version: 1
293data: kpis
294visualize:
295  type: metric
296  value: totalRevenue
297  label: Total Revenue
298  format: "$,.0f"
299  compareWith: previousRevenue
300  invertTrend: false
301"#;
302        let result = parse(yaml).unwrap();
303        match unwrap_single(result) {
304            Component::Chart(chart) => {
305                assert_eq!(chart.visualize.chart_type, "metric");
306                assert_eq!(chart.visualize.value, Some("totalRevenue".to_string()));
307                assert_eq!(chart.visualize.compare_with, Some("previousRevenue".to_string()));
308                assert_eq!(chart.visualize.invert_trend, Some(false));
309            }
310            other => panic!("Expected Chart, got {:?}", other),
311        }
312    }
313
314    #[test]
315    fn test_parse_style_component() {
316        let yaml = r##"
317type: style
318version: 1
319name: custom_theme
320colors: ["#4285f4", "#ea4335", "#34a853"]
321grid:
322  x: true
323  y: true
324  color: "#e0e0e0"
325  opacity: 0.5
326height: 400
327showDots: true
328strokeWidth: 2
329fonts:
330  title:
331    family: "Inter"
332    size: 16
333    weight: "bold"
334    color: "#333"
335  axis:
336    size: 12
337legend:
338  position: top
339  orientation: horizontal
340"##;
341        let result = parse(yaml).unwrap();
342        match unwrap_single(result) {
343            Component::Style(style) => {
344                assert_eq!(style.name, "custom_theme");
345                assert_eq!(style.colors.unwrap().len(), 3);
346                assert_eq!(style.height, Some(400.0));
347                assert_eq!(style.show_dots, Some(true));
348                assert_eq!(style.stroke_width, Some(2.0));
349                let fonts = style.fonts.unwrap();
350                assert_eq!(fonts.title.unwrap().family, Some("Inter".to_string()));
351                let legend = style.legend.unwrap();
352                assert_eq!(legend.position, Some("top".to_string()));
353            }
354            other => panic!("Expected Style, got {:?}", other),
355        }
356    }
357
358    #[test]
359    fn test_parse_config_component() {
360        let yaml = r#"
361type: config
362version: 1
363style: custom_theme
364"#;
365        let result = parse(yaml).unwrap();
366        match unwrap_single(result) {
367            Component::Config(config) => {
368                assert_eq!(config.version, 1);
369                match &config.style {
370                    StyleRef::Named(name) => assert_eq!(name, "custom_theme"),
371                    other => panic!("Expected Named style ref, got {:?}", other),
372                }
373            }
374            other => panic!("Expected Config, got {:?}", other),
375        }
376    }
377
378    #[test]
379    fn test_parse_params_component() {
380        let yaml = r#"
381type: params
382version: 1
383name: dashboard_filters
384params:
385  - id: date_range
386    type: daterange
387    label: "Date Range"
388    default:
389      start: "2024-01-01"
390      end: "2024-12-31"
391  - id: regions
392    type: multiselect
393    label: "Regions"
394    options: ["US", "EU", "APAC"]
395    default: ["US", "EU"]
396  - id: top_n
397    type: number
398    label: "Top N"
399    default: 10
400    placeholder: "Enter number"
401"#;
402        let result = parse(yaml).unwrap();
403        match unwrap_single(result) {
404            Component::Params(params) => {
405                assert_eq!(params.name, Some("dashboard_filters".to_string()));
406                assert_eq!(params.params.len(), 3);
407                assert_eq!(params.params[0].id, "date_range");
408                assert_eq!(params.params[0].param_type, "daterange");
409                assert_eq!(params.params[1].options.as_ref().unwrap().len(), 3);
410                assert_eq!(params.params[2].placeholder, Some("Enter number".to_string()));
411            }
412            other => panic!("Expected Params, got {:?}", other),
413        }
414    }
415
416    #[test]
417    fn test_parse_field_ref_multiple_with_strings() {
418        let yaml = r##"
419type: chart
420version: 1
421data: test
422visualize:
423  type: bar
424  columns: month
425  rows:
426    - field: actual
427      mark: bar
428      color: "#4285f4"
429      label: "Actual Revenue"
430    - field: target
431      mark: line
432      color: "#ea4335"
433      label: "Target"
434"##;
435        let result = parse(yaml).unwrap();
436        match unwrap_single(result) {
437            Component::Chart(chart) => {
438                match &chart.visualize.rows {
439                    Some(FieldRef::Multiple(items)) => {
440                        assert_eq!(items.len(), 2);
441                    }
442                    other => panic!("Expected Multiple field ref, got {:?}", other),
443                }
444            }
445            other => panic!("Expected Chart, got {:?}", other),
446        }
447    }
448
449    #[test]
450    fn test_parse_named_source_map() {
451        // Prod YAML shape: `data:` is a map of user-chosen source names to
452        // per-source specs. This shape is what the Kyomi AI agent and dashboard
453        // authors use when a chart needs to fan out SQL across multiple
454        // datasources or when the host app pre-fetches each one independently.
455        let yaml = r#"
456type: chart
457version: 1
458data:
459  visitors:
460    datasource: plausible-analytics
461    query: |
462      SELECT toDate(start) AS date, COUNT(DISTINCT user_id) FROM sessions
463    cache:
464      ttl: 6h
465      autoRefresh: true
466  revenue:
467    datasource: billing-postgres
468    query: |
469      SELECT date, sum(amount) FROM charges GROUP BY 1
470visualize:
471  type: line
472  columns: date
473  rows: value
474"#;
475        let result = parse(yaml).unwrap();
476        match unwrap_single(result) {
477            Component::Chart(chart) => match &chart.data {
478                DataRef::NamedMap(sources) => {
479                    assert_eq!(sources.len(), 2);
480                    // IndexMap preserves declaration order — visitors first.
481                    let (first_name, first_source) = sources.iter().next().unwrap();
482                    assert_eq!(first_name, "visitors");
483                    assert_eq!(first_source.datasource.as_deref(), Some("plausible-analytics"));
484                    assert!(first_source.query.as_ref().unwrap().contains("sessions"));
485                    let cache = first_source.cache.as_ref().expect("Expected cache config");
486                    assert_eq!(cache.ttl.as_deref(), Some("6h"));
487                    assert_eq!(cache.auto_refresh, Some(true));
488
489                    let revenue = sources.get("revenue").unwrap();
490                    assert_eq!(revenue.datasource.as_deref(), Some("billing-postgres"));
491                }
492                other => panic!("Expected NamedMap data ref, got {:?}", other),
493            },
494            other => panic!("Expected Chart, got {:?}", other),
495        }
496    }
497
498    #[test]
499    fn test_parse_datasource_query_inline() {
500        // Unnamed shape with `datasource` + `query` — commonly emitted when a
501        // chart reads from exactly one slug-resolved datasource. Before this
502        // fix InlineData required `provider`, so this shape failed to parse.
503        let yaml = r#"
504type: chart
505version: 1
506data:
507  datasource: production-postgres
508  query: SELECT month, revenue FROM monthly_sales
509visualize:
510  type: bar
511  columns: month
512  rows: revenue
513"#;
514        let result = parse(yaml).unwrap();
515        match unwrap_single(result) {
516            Component::Chart(chart) => match &chart.data {
517                DataRef::Inline(data) => {
518                    assert_eq!(data.datasource.as_deref(), Some("production-postgres"));
519                    assert!(data.query.is_some());
520                    assert!(data.provider.is_none(), "No explicit provider → should be None");
521                }
522                other => panic!("Expected Inline data ref, got {:?}", other),
523            },
524            other => panic!("Expected Chart, got {:?}", other),
525        }
526    }
527
528    #[test]
529    fn test_parse_range_mark_without_field() {
530        // Range marks on line charts shade a confidence band between upper
531        // and lower bound columns — there's no single `field` name, just
532        // `upper` and `lower`. JS chartml accepts this shape; Rust must too.
533        let yaml = r##"
534type: chart
535version: 1
536data: sales_forecast
537visualize:
538  type: line
539  columns: date
540  rows:
541    - field: visitor_count
542    - field: forecast
543    - mark: range
544      upper: upper_bound
545      lower: lower_bound
546      color: "#4285f4"
547      opacity: 0.15
548"##;
549        let result = parse(yaml).unwrap();
550        match unwrap_single(result) {
551            Component::Chart(chart) => match &chart.visualize.rows {
552                Some(FieldRef::Multiple(items)) => {
553                    assert_eq!(items.len(), 3);
554                    match &items[2] {
555                        FieldRefItem::Detailed(spec) => {
556                            assert!(spec.field.is_none(), "Range-mark spec has no `field`");
557                            assert_eq!(spec.mark.as_deref(), Some("range"));
558                            assert_eq!(spec.upper.as_deref(), Some("upper_bound"));
559                            assert_eq!(spec.lower.as_deref(), Some("lower_bound"));
560                            assert_eq!(spec.opacity, Some(0.15));
561                        }
562                        other => panic!("Expected Detailed range-mark, got {:?}", other),
563                    }
564                }
565                other => panic!("Expected Multiple field ref, got {:?}", other),
566            },
567            other => panic!("Expected Chart, got {:?}", other),
568        }
569    }
570
571    #[test]
572    fn test_parse_transform() {
573        let yaml = r#"
574type: chart
575version: 1
576data: raw_data
577transform:
578  aggregate:
579    dimensions:
580      - region
581      - column: category
582        name: Category
583    measures:
584      - column: amount
585        aggregation: sum
586        name: totalAmount
587    sort:
588      - field: totalAmount
589        direction: desc
590    limit: 10
591visualize:
592  type: bar
593  columns: region
594  rows: totalAmount
595"#;
596        let result = parse(yaml).unwrap();
597        match unwrap_single(result) {
598            Component::Chart(chart) => {
599                let transform = chart.transform.unwrap();
600                let agg = transform.aggregate.unwrap();
601                assert_eq!(agg.dimensions.len(), 2);
602                assert_eq!(agg.measures.len(), 1);
603                assert_eq!(agg.measures[0].name, "totalAmount");
604                assert_eq!(agg.limit, Some(10));
605            }
606            other => panic!("Expected Chart, got {:?}", other),
607        }
608    }
609}