presentar_yaml/
manifest.rs

1//! YAML manifest types for Presentar applications.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Application manifest loaded from app.yaml.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Manifest {
9    /// Presentar version
10    pub presentar: String,
11    /// Application name
12    pub name: String,
13    /// Application version
14    pub version: String,
15    /// Application description
16    #[serde(default)]
17    pub description: String,
18    /// Quality score (auto-computed)
19    #[serde(default)]
20    pub score: Option<Score>,
21    /// Data sources
22    #[serde(default)]
23    pub data: HashMap<String, DataSource>,
24    /// Model references
25    #[serde(default)]
26    pub models: HashMap<String, ModelRef>,
27    /// Layout configuration
28    pub layout: LayoutConfig,
29    /// Interactions
30    #[serde(default)]
31    pub interactions: Vec<Interaction>,
32    /// Theme configuration
33    #[serde(default)]
34    pub theme: Option<ThemeConfig>,
35}
36
37/// Quality score metadata.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Score {
40    /// Letter grade (F-A+)
41    pub grade: String,
42    /// Numeric score (0-100)
43    pub value: f64,
44    /// Test coverage percentage
45    #[serde(default)]
46    pub coverage: Option<f64>,
47}
48
49/// Data source configuration.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct DataSource {
52    /// Source URI (pacha://, file://, https://)
53    pub source: String,
54    /// Data format (ald, csv, json)
55    #[serde(default = "default_format")]
56    pub format: String,
57    /// Refresh interval
58    #[serde(default)]
59    pub refresh: Option<String>,
60}
61
62fn default_format() -> String {
63    "ald".to_string()
64}
65
66/// Model reference configuration.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ModelRef {
69    /// Source URI (pacha://, file://)
70    pub source: String,
71    /// Model format (apr)
72    #[serde(default = "default_model_format")]
73    pub format: String,
74}
75
76fn default_model_format() -> String {
77    "apr".to_string()
78}
79
80/// Layout configuration.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct LayoutConfig {
83    /// Layout type (dashboard, app, custom)
84    #[serde(rename = "type")]
85    pub layout_type: String,
86    /// Number of columns for grid layout
87    #[serde(default = "default_columns")]
88    pub columns: u32,
89    /// Row configuration
90    #[serde(default)]
91    pub rows: String,
92    /// Gap between sections
93    #[serde(default = "default_gap")]
94    pub gap: u32,
95    /// Layout sections
96    #[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/// Layout section.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Section {
111    /// Section ID
112    pub id: String,
113    /// Grid span [start, end]
114    #[serde(default)]
115    pub span: Option<[u32; 2]>,
116    /// Widgets in this section
117    #[serde(default)]
118    pub widgets: Vec<WidgetConfig>,
119}
120
121/// Widget configuration from YAML.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct WidgetConfig {
124    /// Widget type (text, button, chart, data-table, etc.)
125    #[serde(rename = "type")]
126    pub widget_type: String,
127    /// Widget ID
128    #[serde(default)]
129    pub id: Option<String>,
130    /// Content (for text widgets)
131    #[serde(default)]
132    pub content: Option<String>,
133    /// Style name
134    #[serde(default)]
135    pub style: Option<String>,
136    /// Data binding expression
137    #[serde(default)]
138    pub data: Option<String>,
139    /// Chart type (for chart widgets)
140    #[serde(default)]
141    pub chart_type: Option<String>,
142    /// X axis field
143    #[serde(default)]
144    pub x: Option<String>,
145    /// Y axis field
146    #[serde(default)]
147    pub y: Option<String>,
148    /// Color field
149    #[serde(default)]
150    pub color: Option<String>,
151    /// Model source (for inference widgets)
152    #[serde(default)]
153    pub model_source: Option<String>,
154    /// Inference engine (for inference widgets, e.g., "ngram-v1", "onnx-simd")
155    #[serde(default)]
156    pub engine: Option<String>,
157    /// Acceleration preference (for inference widgets, e.g., "auto", "simd", "wgpu")
158    #[serde(default)]
159    pub acceleration: Option<String>,
160    /// Additional properties
161    #[serde(flatten)]
162    pub extra: HashMap<String, serde_yaml::Value>,
163}
164
165/// Interaction configuration.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Interaction {
168    /// Event trigger
169    pub trigger: String,
170    /// Action type
171    pub action: String,
172    /// Target (for navigation)
173    #[serde(default)]
174    pub target: Option<String>,
175    /// Content (for tooltips)
176    #[serde(default)]
177    pub content: Option<String>,
178    /// Script (for custom actions)
179    #[serde(default)]
180    pub script: Option<String>,
181}
182
183/// Theme configuration.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct ThemeConfig {
186    /// Theme preset (light, dark)
187    #[serde(default)]
188    pub preset: Option<String>,
189    /// Custom colors
190    #[serde(default)]
191    pub colors: HashMap<String, String>,
192}
193
194impl Manifest {
195    /// Parse a manifest from YAML string.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if the YAML is invalid.
200    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
201        serde_yaml::from_str(yaml)
202    }
203
204    /// Serialize manifest to YAML string.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if serialization fails.
209    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
210        serde_yaml::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    // =========================================================================
316    // Theme Config Tests
317    // =========================================================================
318
319    #[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    // =========================================================================
361    // Interaction Tests
362    // =========================================================================
363
364    #[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    // =========================================================================
436    // Score Tests
437    // =========================================================================
438
439    #[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    // =========================================================================
481    // Default Value Tests
482    // =========================================================================
483
484    #[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); // Default value
496    }
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); // Default value
510    }
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"); // Default format
528    }
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"); // Default format
546    }
547
548    // =========================================================================
549    // Widget Config Tests
550    // =========================================================================
551
552    #[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    // =========================================================================
607    // Multiple Sections Tests
608    // =========================================================================
609
610    #[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    // =========================================================================
638    // Error Cases
639    // =========================================================================
640
641    #[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()); // Missing version and layout
650    }
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    // =========================================================================
660    // Full Integration Test
661    // =========================================================================
662
663    #[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        // Basic info
732        assert_eq!(manifest.name, "fraud-detection-dashboard");
733        assert_eq!(manifest.version, "1.0.0");
734        assert!(!manifest.description.is_empty());
735
736        // Score
737        assert!(manifest.score.is_some());
738
739        // Data sources
740        assert_eq!(manifest.data.len(), 2);
741        assert!(manifest.data.contains_key("transactions"));
742        assert!(manifest.data.contains_key("predictions"));
743
744        // Models
745        assert_eq!(manifest.models.len(), 1);
746        assert!(manifest.models.contains_key("fraud_detector"));
747
748        // Layout
749        assert_eq!(manifest.layout.layout_type, "dashboard");
750        assert_eq!(manifest.layout.columns, 12);
751        assert_eq!(manifest.layout.sections.len(), 3);
752
753        // Interactions
754        assert_eq!(manifest.interactions.len(), 1);
755
756        // Theme
757        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}