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    use super::*;
137
138    /// Helper: unwrap a ChartMLSpec::Single into its inner Component.
139    fn unwrap_single(spec: ChartMLSpec) -> Component {
140        match spec {
141            ChartMLSpec::Single(component) => *component,
142            other => panic!("Expected Single, got {:?}", other),
143        }
144    }
145
146    #[test]
147    fn test_parse_single_source() {
148        let yaml = r#"
149type: source
150version: 1
151name: sales
152provider: inline
153rows:
154  - { month: "Jan", revenue: 100 }
155  - { month: "Feb", revenue: 200 }
156"#;
157        let result = parse(yaml).unwrap();
158        match unwrap_single(result) {
159            Component::Source(source) => {
160                assert_eq!(source.name, "sales");
161                assert_eq!(source.provider, "inline");
162                assert!(source.rows.is_some());
163                assert_eq!(source.rows.unwrap().len(), 2);
164            }
165            other => panic!("Expected Source, got {:?}", other),
166        }
167    }
168
169    #[test]
170    fn test_parse_single_chart() {
171        let yaml = r#"
172type: chart
173version: 1
174title: Revenue by Month
175data: sales
176visualize:
177  type: bar
178  columns: month
179  rows: revenue
180"#;
181        let result = parse(yaml).unwrap();
182        match unwrap_single(result) {
183            Component::Chart(chart) => {
184                assert_eq!(chart.title, Some("Revenue by Month".to_string()));
185                assert_eq!(chart.visualize.chart_type, "bar");
186                match &chart.data {
187                    DataRef::Named(name) => assert_eq!(name, "sales"),
188                    other => panic!("Expected Named data ref, got {:?}", other),
189                }
190            }
191            other => panic!("Expected Chart, got {:?}", other),
192        }
193    }
194
195    #[test]
196    fn test_parse_multi_document() {
197        let yaml = r#"---
198type: source
199version: 1
200name: sales
201provider: inline
202rows:
203  - { month: "Jan", revenue: 100 }
204---
205type: chart
206version: 1
207title: Revenue
208data: sales
209visualize:
210  type: bar
211  columns: month
212  rows: revenue
213"#;
214        let result = parse(yaml).unwrap();
215        match result {
216            ChartMLSpec::Array(components) => {
217                assert_eq!(components.len(), 2);
218                assert!(matches!(&components[0], Component::Source(_)));
219                assert!(matches!(&components[1], Component::Chart(_)));
220            }
221            other => panic!("Expected Array, got {:?}", other),
222        }
223    }
224
225    #[test]
226    fn test_parse_inline_data() {
227        let yaml = r#"
228type: chart
229version: 1
230data:
231  provider: inline
232  rows:
233    - { x: 1, y: 2 }
234visualize:
235  type: line
236  columns: x
237  rows: y
238"#;
239        let result = parse(yaml).unwrap();
240        match unwrap_single(result) {
241            Component::Chart(chart) => {
242                match &chart.data {
243                    DataRef::Inline(data) => {
244                        assert_eq!(data.provider, "inline");
245                        assert!(data.rows.is_some());
246                    }
247                    other => panic!("Expected Inline data ref, got {:?}", other),
248                }
249            }
250            other => panic!("Expected Chart, got {:?}", other),
251        }
252    }
253
254    #[test]
255    fn test_parse_field_ref_variants() {
256        // Simple string field ref
257        let yaml = r#"
258type: chart
259version: 1
260data: test
261visualize:
262  type: bar
263  columns: month
264  rows:
265    field: revenue
266    label: "Revenue ($)"
267"#;
268        let result = parse(yaml).unwrap();
269        match unwrap_single(result) {
270            Component::Chart(chart) => {
271                match &chart.visualize.columns {
272                    Some(FieldRef::Simple(s)) => assert_eq!(s, "month"),
273                    other => panic!("Expected Simple field ref, got {:?}", other),
274                }
275                match &chart.visualize.rows {
276                    Some(FieldRef::Detailed(spec)) => {
277                        assert_eq!(spec.field, "revenue");
278                        assert_eq!(spec.label, Some("Revenue ($)".to_string()));
279                    }
280                    other => panic!("Expected Detailed field ref, got {:?}", other),
281                }
282            }
283            other => panic!("Expected Chart, got {:?}", other),
284        }
285    }
286
287    #[test]
288    fn test_parse_metric_chart() {
289        let yaml = r#"
290type: chart
291version: 1
292data: kpis
293visualize:
294  type: metric
295  value: totalRevenue
296  label: Total Revenue
297  format: "$,.0f"
298  compareWith: previousRevenue
299  invertTrend: false
300"#;
301        let result = parse(yaml).unwrap();
302        match unwrap_single(result) {
303            Component::Chart(chart) => {
304                assert_eq!(chart.visualize.chart_type, "metric");
305                assert_eq!(chart.visualize.value, Some("totalRevenue".to_string()));
306                assert_eq!(chart.visualize.compare_with, Some("previousRevenue".to_string()));
307                assert_eq!(chart.visualize.invert_trend, Some(false));
308            }
309            other => panic!("Expected Chart, got {:?}", other),
310        }
311    }
312
313    #[test]
314    fn test_parse_style_component() {
315        let yaml = r##"
316type: style
317version: 1
318name: custom_theme
319colors: ["#4285f4", "#ea4335", "#34a853"]
320grid:
321  x: true
322  y: true
323  color: "#e0e0e0"
324  opacity: 0.5
325height: 400
326showDots: true
327strokeWidth: 2
328fonts:
329  title:
330    family: "Inter"
331    size: 16
332    weight: "bold"
333    color: "#333"
334  axis:
335    size: 12
336legend:
337  position: top
338  orientation: horizontal
339"##;
340        let result = parse(yaml).unwrap();
341        match unwrap_single(result) {
342            Component::Style(style) => {
343                assert_eq!(style.name, "custom_theme");
344                assert_eq!(style.colors.unwrap().len(), 3);
345                assert_eq!(style.height, Some(400.0));
346                assert_eq!(style.show_dots, Some(true));
347                assert_eq!(style.stroke_width, Some(2.0));
348                let fonts = style.fonts.unwrap();
349                assert_eq!(fonts.title.unwrap().family, Some("Inter".to_string()));
350                let legend = style.legend.unwrap();
351                assert_eq!(legend.position, Some("top".to_string()));
352            }
353            other => panic!("Expected Style, got {:?}", other),
354        }
355    }
356
357    #[test]
358    fn test_parse_config_component() {
359        let yaml = r#"
360type: config
361version: 1
362style: custom_theme
363"#;
364        let result = parse(yaml).unwrap();
365        match unwrap_single(result) {
366            Component::Config(config) => {
367                assert_eq!(config.version, 1);
368                match &config.style {
369                    StyleRef::Named(name) => assert_eq!(name, "custom_theme"),
370                    other => panic!("Expected Named style ref, got {:?}", other),
371                }
372            }
373            other => panic!("Expected Config, got {:?}", other),
374        }
375    }
376
377    #[test]
378    fn test_parse_params_component() {
379        let yaml = r#"
380type: params
381version: 1
382name: dashboard_filters
383params:
384  - id: date_range
385    type: daterange
386    label: "Date Range"
387    default:
388      start: "2024-01-01"
389      end: "2024-12-31"
390  - id: regions
391    type: multiselect
392    label: "Regions"
393    options: ["US", "EU", "APAC"]
394    default: ["US", "EU"]
395  - id: top_n
396    type: number
397    label: "Top N"
398    default: 10
399    placeholder: "Enter number"
400"#;
401        let result = parse(yaml).unwrap();
402        match unwrap_single(result) {
403            Component::Params(params) => {
404                assert_eq!(params.name, Some("dashboard_filters".to_string()));
405                assert_eq!(params.params.len(), 3);
406                assert_eq!(params.params[0].id, "date_range");
407                assert_eq!(params.params[0].param_type, "daterange");
408                assert_eq!(params.params[1].options.as_ref().unwrap().len(), 3);
409                assert_eq!(params.params[2].placeholder, Some("Enter number".to_string()));
410            }
411            other => panic!("Expected Params, got {:?}", other),
412        }
413    }
414
415    #[test]
416    fn test_parse_field_ref_multiple_with_strings() {
417        let yaml = r##"
418type: chart
419version: 1
420data: test
421visualize:
422  type: bar
423  columns: month
424  rows:
425    - field: actual
426      mark: bar
427      color: "#4285f4"
428      label: "Actual Revenue"
429    - field: target
430      mark: line
431      color: "#ea4335"
432      label: "Target"
433"##;
434        let result = parse(yaml).unwrap();
435        match unwrap_single(result) {
436            Component::Chart(chart) => {
437                match &chart.visualize.rows {
438                    Some(FieldRef::Multiple(items)) => {
439                        assert_eq!(items.len(), 2);
440                    }
441                    other => panic!("Expected Multiple field ref, got {:?}", other),
442                }
443            }
444            other => panic!("Expected Chart, got {:?}", other),
445        }
446    }
447
448    #[test]
449    fn test_parse_transform() {
450        let yaml = r#"
451type: chart
452version: 1
453data: raw_data
454transform:
455  aggregate:
456    dimensions:
457      - region
458      - column: category
459        name: Category
460    measures:
461      - column: amount
462        aggregation: sum
463        name: totalAmount
464    sort:
465      - field: totalAmount
466        direction: desc
467    limit: 10
468visualize:
469  type: bar
470  columns: region
471  rows: totalAmount
472"#;
473        let result = parse(yaml).unwrap();
474        match unwrap_single(result) {
475            Component::Chart(chart) => {
476                let transform = chart.transform.unwrap();
477                let agg = transform.aggregate.unwrap();
478                assert_eq!(agg.dimensions.len(), 2);
479                assert_eq!(agg.measures.len(), 1);
480                assert_eq!(agg.measures[0].name, "totalAmount");
481                assert_eq!(agg.limit, Some(10));
482            }
483            other => panic!("Expected Chart, got {:?}", other),
484        }
485    }
486}