1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Manifest {
9 pub presentar: String,
11 pub name: String,
13 pub version: String,
15 #[serde(default)]
17 pub description: String,
18 #[serde(default)]
20 pub score: Option<Score>,
21 #[serde(default)]
23 pub data: HashMap<String, DataSource>,
24 #[serde(default)]
26 pub models: HashMap<String, ModelRef>,
27 pub layout: LayoutConfig,
29 #[serde(default)]
31 pub interactions: Vec<Interaction>,
32 #[serde(default)]
34 pub theme: Option<ThemeConfig>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Score {
40 pub grade: String,
42 pub value: f64,
44 #[serde(default)]
46 pub coverage: Option<f64>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct DataSource {
52 pub source: String,
54 #[serde(default = "default_format")]
56 pub format: String,
57 #[serde(default)]
59 pub refresh: Option<String>,
60}
61
62fn default_format() -> String {
63 "ald".to_string()
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ModelRef {
69 pub source: String,
71 #[serde(default = "default_model_format")]
73 pub format: String,
74}
75
76fn default_model_format() -> String {
77 "apr".to_string()
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct LayoutConfig {
83 #[serde(rename = "type")]
85 pub layout_type: String,
86 #[serde(default = "default_columns")]
88 pub columns: u32,
89 #[serde(default)]
91 pub rows: String,
92 #[serde(default = "default_gap")]
94 pub gap: u32,
95 #[serde(default)]
97 pub sections: Vec<Section>,
98}
99
100const fn default_columns() -> u32 {
101 12
102}
103
104const fn default_gap() -> u32 {
105 16
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Section {
111 pub id: String,
113 #[serde(default)]
115 pub span: Option<[u32; 2]>,
116 #[serde(default)]
118 pub widgets: Vec<WidgetConfig>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct WidgetConfig {
124 #[serde(rename = "type")]
126 pub widget_type: String,
127 #[serde(default)]
129 pub id: Option<String>,
130 #[serde(default)]
132 pub content: Option<String>,
133 #[serde(default)]
135 pub style: Option<String>,
136 #[serde(default)]
138 pub data: Option<String>,
139 #[serde(default)]
141 pub chart_type: Option<String>,
142 #[serde(default)]
144 pub x: Option<String>,
145 #[serde(default)]
147 pub y: Option<String>,
148 #[serde(default)]
150 pub color: Option<String>,
151 #[serde(default)]
153 pub model_source: Option<String>,
154 #[serde(default)]
156 pub engine: Option<String>,
157 #[serde(default)]
159 pub acceleration: Option<String>,
160 #[serde(flatten)]
162 pub extra: HashMap<String, serde_yaml_ng::Value>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Interaction {
168 pub trigger: String,
170 pub action: String,
172 #[serde(default)]
174 pub target: Option<String>,
175 #[serde(default)]
177 pub content: Option<String>,
178 #[serde(default)]
180 pub script: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct ThemeConfig {
186 #[serde(default)]
188 pub preset: Option<String>,
189 #[serde(default)]
191 pub colors: HashMap<String, String>,
192}
193
194impl Manifest {
195 pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
201 serde_yaml_ng::from_str(yaml)
202 }
203
204 pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
210 serde_yaml_ng::to_string(self)
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 const EXAMPLE_YAML: &str = r#"
219presentar: "0.1"
220name: "test-app"
221version: "1.0.0"
222description: "Test application"
223
224layout:
225 type: "dashboard"
226 columns: 12
227 gap: 16
228 sections:
229 - id: "header"
230 span: [1, 12]
231 widgets:
232 - type: "text"
233 content: "Hello World"
234 style: "heading-1"
235"#;
236
237 #[test]
238 fn test_parse_manifest() {
239 let manifest = Manifest::from_yaml(EXAMPLE_YAML).unwrap();
240 assert_eq!(manifest.name, "test-app");
241 assert_eq!(manifest.version, "1.0.0");
242 assert_eq!(manifest.layout.columns, 12);
243 assert_eq!(manifest.layout.sections.len(), 1);
244 }
245
246 #[test]
247 fn test_parse_section() {
248 let manifest = Manifest::from_yaml(EXAMPLE_YAML).unwrap();
249 let section = &manifest.layout.sections[0];
250 assert_eq!(section.id, "header");
251 assert_eq!(section.span, Some([1, 12]));
252 assert_eq!(section.widgets.len(), 1);
253 }
254
255 #[test]
256 fn test_parse_widget() {
257 let manifest = Manifest::from_yaml(EXAMPLE_YAML).unwrap();
258 let widget = &manifest.layout.sections[0].widgets[0];
259 assert_eq!(widget.widget_type, "text");
260 assert_eq!(widget.content, Some("Hello World".to_string()));
261 assert_eq!(widget.style, Some("heading-1".to_string()));
262 }
263
264 #[test]
265 fn test_roundtrip() {
266 let manifest = Manifest::from_yaml(EXAMPLE_YAML).unwrap();
267 let yaml = manifest.to_yaml().unwrap();
268 let manifest2 = Manifest::from_yaml(&yaml).unwrap();
269 assert_eq!(manifest.name, manifest2.name);
270 assert_eq!(manifest.version, manifest2.version);
271 }
272
273 #[test]
274 fn test_data_source() {
275 let yaml = r#"
276presentar: "0.1"
277name: "test"
278version: "1.0.0"
279data:
280 transactions:
281 source: "pacha://datasets/transactions:latest"
282 format: "ald"
283 refresh: "5m"
284layout:
285 type: "app"
286"#;
287
288 let manifest = Manifest::from_yaml(yaml).unwrap();
289 assert!(manifest.data.contains_key("transactions"));
290 let ds = &manifest.data["transactions"];
291 assert_eq!(ds.format, "ald");
292 assert_eq!(ds.refresh, Some("5m".to_string()));
293 }
294
295 #[test]
296 fn test_model_ref() {
297 let yaml = r#"
298presentar: "0.1"
299name: "test"
300version: "1.0.0"
301models:
302 classifier:
303 source: "pacha://models/classifier:1.0.0"
304 format: "apr"
305layout:
306 type: "app"
307"#;
308
309 let manifest = Manifest::from_yaml(yaml).unwrap();
310 assert!(manifest.models.contains_key("classifier"));
311 let model = &manifest.models["classifier"];
312 assert_eq!(model.format, "apr");
313 }
314
315 #[test]
320 fn test_theme_preset() {
321 let yaml = r#"
322presentar: "0.1"
323name: "test"
324version: "1.0.0"
325layout:
326 type: "app"
327theme:
328 preset: "dark"
329"#;
330
331 let manifest = Manifest::from_yaml(yaml).unwrap();
332 assert!(manifest.theme.is_some());
333 let theme = manifest.theme.unwrap();
334 assert_eq!(theme.preset, Some("dark".to_string()));
335 }
336
337 #[test]
338 fn test_theme_custom_colors() {
339 let yaml = r##"
340presentar: "0.1"
341name: "test"
342version: "1.0.0"
343layout:
344 type: "app"
345theme:
346 preset: "light"
347 colors:
348 primary: "#6366f1"
349 danger: "#ef4444"
350 success: "#10b981"
351"##;
352
353 let manifest = Manifest::from_yaml(yaml).unwrap();
354 let theme = manifest.theme.unwrap();
355 assert_eq!(theme.colors.get("primary"), Some(&"#6366f1".to_string()));
356 assert_eq!(theme.colors.get("danger"), Some(&"#ef4444".to_string()));
357 assert_eq!(theme.colors.get("success"), Some(&"#10b981".to_string()));
358 }
359
360 #[test]
365 fn test_interaction_navigate() {
366 let yaml = r#"
367presentar: "0.1"
368name: "test"
369version: "1.0.0"
370layout:
371 type: "app"
372interactions:
373 - trigger: "table.row.click"
374 action: "navigate"
375 target: "/details/{{ row.id }}"
376"#;
377
378 let manifest = Manifest::from_yaml(yaml).unwrap();
379 assert_eq!(manifest.interactions.len(), 1);
380 let interaction = &manifest.interactions[0];
381 assert_eq!(interaction.trigger, "table.row.click");
382 assert_eq!(interaction.action, "navigate");
383 assert_eq!(
384 interaction.target,
385 Some("/details/{{ row.id }}".to_string())
386 );
387 }
388
389 #[test]
390 fn test_interaction_tooltip() {
391 let yaml = r#"
392presentar: "0.1"
393name: "test"
394version: "1.0.0"
395layout:
396 type: "app"
397interactions:
398 - trigger: "chart.point.hover"
399 action: "tooltip"
400 content: "Value: {{ point.value }}"
401"#;
402
403 let manifest = Manifest::from_yaml(yaml).unwrap();
404 let interaction = &manifest.interactions[0];
405 assert_eq!(interaction.action, "tooltip");
406 assert_eq!(
407 interaction.content,
408 Some("Value: {{ point.value }}".to_string())
409 );
410 }
411
412 #[test]
413 fn test_interaction_script() {
414 let yaml = r#"
415presentar: "0.1"
416name: "test"
417version: "1.0.0"
418layout:
419 type: "app"
420interactions:
421 - trigger: "button.click"
422 action: "custom"
423 script: |
424 let x = state.count + 1
425 set_state("count", x)
426"#;
427
428 let manifest = Manifest::from_yaml(yaml).unwrap();
429 let interaction = &manifest.interactions[0];
430 assert_eq!(interaction.action, "custom");
431 assert!(interaction.script.is_some());
432 assert!(interaction.script.as_ref().unwrap().contains("set_state"));
433 }
434
435 #[test]
440 fn test_score_metadata() {
441 let yaml = r#"
442presentar: "0.1"
443name: "test"
444version: "1.0.0"
445score:
446 grade: "A"
447 value: 92.3
448 coverage: 94.1
449layout:
450 type: "app"
451"#;
452
453 let manifest = Manifest::from_yaml(yaml).unwrap();
454 assert!(manifest.score.is_some());
455 let score = manifest.score.unwrap();
456 assert_eq!(score.grade, "A");
457 assert!((score.value - 92.3).abs() < 0.01);
458 assert_eq!(score.coverage, Some(94.1));
459 }
460
461 #[test]
462 fn test_score_without_coverage() {
463 let yaml = r#"
464presentar: "0.1"
465name: "test"
466version: "1.0.0"
467score:
468 grade: "B+"
469 value: 82.0
470layout:
471 type: "app"
472"#;
473
474 let manifest = Manifest::from_yaml(yaml).unwrap();
475 let score = manifest.score.unwrap();
476 assert_eq!(score.grade, "B+");
477 assert_eq!(score.coverage, None);
478 }
479
480 #[test]
485 fn test_default_columns() {
486 let yaml = r#"
487presentar: "0.1"
488name: "test"
489version: "1.0.0"
490layout:
491 type: "dashboard"
492"#;
493
494 let manifest = Manifest::from_yaml(yaml).unwrap();
495 assert_eq!(manifest.layout.columns, 12); }
497
498 #[test]
499 fn test_default_gap() {
500 let yaml = r#"
501presentar: "0.1"
502name: "test"
503version: "1.0.0"
504layout:
505 type: "dashboard"
506"#;
507
508 let manifest = Manifest::from_yaml(yaml).unwrap();
509 assert_eq!(manifest.layout.gap, 16); }
511
512 #[test]
513 fn test_default_data_format() {
514 let yaml = r#"
515presentar: "0.1"
516name: "test"
517version: "1.0.0"
518data:
519 test_data:
520 source: "file://data.csv"
521layout:
522 type: "app"
523"#;
524
525 let manifest = Manifest::from_yaml(yaml).unwrap();
526 let ds = &manifest.data["test_data"];
527 assert_eq!(ds.format, "ald"); }
529
530 #[test]
531 fn test_default_model_format() {
532 let yaml = r#"
533presentar: "0.1"
534name: "test"
535version: "1.0.0"
536models:
537 test_model:
538 source: "file://model.bin"
539layout:
540 type: "app"
541"#;
542
543 let manifest = Manifest::from_yaml(yaml).unwrap();
544 let model = &manifest.models["test_model"];
545 assert_eq!(model.format, "apr"); }
547
548 #[test]
553 fn test_chart_widget_config() {
554 let yaml = r#"
555presentar: "0.1"
556name: "test"
557version: "1.0.0"
558layout:
559 type: "dashboard"
560 sections:
561 - id: "chart-section"
562 widgets:
563 - type: "chart"
564 chart_type: "line"
565 data: "{{ data.transactions }}"
566 x: "timestamp"
567 y: "amount"
568 color: "{{ predictions.fraud }}"
569"#;
570
571 let manifest = Manifest::from_yaml(yaml).unwrap();
572 let widget = &manifest.layout.sections[0].widgets[0];
573 assert_eq!(widget.widget_type, "chart");
574 assert_eq!(widget.chart_type, Some("line".to_string()));
575 assert_eq!(widget.x, Some("timestamp".to_string()));
576 assert_eq!(widget.y, Some("amount".to_string()));
577 assert!(widget.color.is_some());
578 }
579
580 #[test]
581 fn test_widget_extra_properties() {
582 let yaml = r#"
583presentar: "0.1"
584name: "test"
585version: "1.0.0"
586layout:
587 type: "app"
588 sections:
589 - id: "main"
590 widgets:
591 - type: "data-table"
592 data: "{{ data.items }}"
593 pagination: 50
594 sortable: true
595 filterable: true
596"#;
597
598 let manifest = Manifest::from_yaml(yaml).unwrap();
599 let widget = &manifest.layout.sections[0].widgets[0];
600 assert_eq!(widget.widget_type, "data-table");
601 assert!(widget.extra.contains_key("pagination"));
602 assert!(widget.extra.contains_key("sortable"));
603 assert!(widget.extra.contains_key("filterable"));
604 }
605
606 #[test]
611 fn test_multiple_sections() {
612 let yaml = r#"
613presentar: "0.1"
614name: "dashboard"
615version: "1.0.0"
616layout:
617 type: "dashboard"
618 columns: 12
619 sections:
620 - id: "header"
621 span: [1, 12]
622 - id: "sidebar"
623 span: [1, 3]
624 - id: "main"
625 span: [4, 12]
626 - id: "footer"
627 span: [1, 12]
628"#;
629
630 let manifest = Manifest::from_yaml(yaml).unwrap();
631 assert_eq!(manifest.layout.sections.len(), 4);
632 assert_eq!(manifest.layout.sections[0].id, "header");
633 assert_eq!(manifest.layout.sections[1].span, Some([1, 3]));
634 assert_eq!(manifest.layout.sections[2].span, Some([4, 12]));
635 }
636
637 #[test]
642 fn test_missing_required_fields() {
643 let yaml = r#"
644presentar: "0.1"
645name: "test"
646"#;
647
648 let result = Manifest::from_yaml(yaml);
649 assert!(result.is_err()); }
651
652 #[test]
653 fn test_invalid_yaml() {
654 let yaml = "this is not valid yaml: [}";
655 let result = Manifest::from_yaml(yaml);
656 assert!(result.is_err());
657 }
658
659 #[test]
664 fn test_complex_manifest() {
665 let yaml = r##"
666presentar: "0.1"
667name: "fraud-detection-dashboard"
668version: "1.0.0"
669description: "Real-time fraud detection monitoring"
670
671score:
672 grade: "A"
673 value: 92.3
674 coverage: 94.1
675
676data:
677 transactions:
678 source: "pacha://datasets/transactions:latest"
679 format: "ald"
680 refresh: "5m"
681 predictions:
682 source: "./predictions.ald"
683
684models:
685 fraud_detector:
686 source: "pacha://models/fraud-detector:1.2.0"
687
688layout:
689 type: "dashboard"
690 columns: 12
691 gap: 16
692 sections:
693 - id: "header"
694 span: [1, 12]
695 widgets:
696 - type: "text"
697 content: "Fraud Detection Dashboard"
698 style: "heading-1"
699 - type: "model-card"
700 id: "model-info"
701
702 - id: "metrics"
703 span: [1, 4]
704 widgets:
705 - type: "metric"
706 data: "{{ data.transactions | count | rate(1m) }}"
707
708 - id: "chart"
709 span: [5, 12]
710 widgets:
711 - type: "chart"
712 chart_type: "line"
713 data: "{{ data.transactions }}"
714 x: "timestamp"
715 y: "amount"
716
717interactions:
718 - trigger: "chart.point.hover"
719 action: "tooltip"
720 content: "Amount: {{ point.amount }}"
721
722theme:
723 preset: "dark"
724 colors:
725 primary: "#6366f1"
726 danger: "#ef4444"
727"##;
728
729 let manifest = Manifest::from_yaml(yaml).unwrap();
730
731 assert_eq!(manifest.name, "fraud-detection-dashboard");
733 assert_eq!(manifest.version, "1.0.0");
734 assert!(!manifest.description.is_empty());
735
736 assert!(manifest.score.is_some());
738
739 assert_eq!(manifest.data.len(), 2);
741 assert!(manifest.data.contains_key("transactions"));
742 assert!(manifest.data.contains_key("predictions"));
743
744 assert_eq!(manifest.models.len(), 1);
746 assert!(manifest.models.contains_key("fraud_detector"));
747
748 assert_eq!(manifest.layout.layout_type, "dashboard");
750 assert_eq!(manifest.layout.columns, 12);
751 assert_eq!(manifest.layout.sections.len(), 3);
752
753 assert_eq!(manifest.interactions.len(), 1);
755
756 assert!(manifest.theme.is_some());
758 let theme = manifest.theme.unwrap();
759 assert_eq!(theme.preset, Some("dark".to_string()));
760 assert_eq!(theme.colors.len(), 2);
761 }
762}