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 #![allow(clippy::unwrap_used)]
137 use super::*;
138
139 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 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 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 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 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 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}