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#[derive(Debug, Clone, Deserialize, Serialize)]
28#[serde(untagged)]
29pub enum ChartMLSpec {
30 Single(Box<Component>),
31 Array(Vec<Component>),
32}
33
34#[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
50pub fn parse(input: &str) -> Result<ChartMLSpec, ChartError> {
56 let documents: Vec<&str> = split_yaml_documents(input);
59
60 if documents.len() <= 1 {
61 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 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 if let Ok(component) = serde_yaml::from_str::<Component>(trimmed) {
76 components.push(component);
77 } else {
78 let array: Vec<Component> = serde_yaml::from_str(trimmed)?;
80 components.extend(array);
81 }
82 }
83 Ok(ChartMLSpec::Array(components))
84 }
85}
86
87fn split_yaml_documents(input: &str) -> Vec<&str> {
89 let mut documents = Vec::new();
90 let mut start = 0;
91
92 let bytes = input.as_bytes();
94 let len = bytes.len();
95 let mut i = 0;
96
97 while i < len {
98 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; }
107
108 let line = input[line_start..line_end].trim_end();
110 if line == "---" {
111 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 let remaining = &input[start..];
122 if !remaining.trim().is_empty() {
123 documents.push(remaining);
124 }
125
126 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 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 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}