Skip to main content

ferro_theme/
template.rs

1use serde::{Deserialize, Serialize};
2
3/// A slot list and optional layout name for one render mode of one intent.
4///
5/// Slots are ordered names from the fixed vocabulary:
6/// `title`, `body`, `fields`, `actions`, `relationships`, `pagination`, `metadata`, `stats`.
7/// The `layout` field names a component to use as the outer container
8/// (e.g., `"Table"`, `"Card"`, `"Form"`).
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct IntentSlotTemplate {
11    /// Ordered slot names to include in this render.
12    #[serde(default)]
13    pub slots: Vec<String>,
14
15    /// Optional outer container component name.
16    #[serde(default)]
17    pub layout: Option<String>,
18}
19
20/// Display and input mode templates for a single intent.
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct IntentModeTemplates {
23    /// Template used when rendering data for reading (list, detail, dashboard views).
24    #[serde(default)]
25    pub display: IntentSlotTemplate,
26
27    /// Template used when rendering a form for data entry or editing.
28    #[serde(default)]
29    pub input: IntentSlotTemplate,
30}
31
32/// Optional intent template overrides for all 7 structural intents.
33///
34/// Missing intents default to `None`, meaning the built-in Rust renderer
35/// handles layout for those intents unchanged.
36///
37/// ## Example (theme.json)
38///
39/// ```json
40/// {
41///   "browse": {
42///     "display": { "slots": ["title", "fields", "pagination"], "layout": "Table" }
43///   }
44/// }
45/// ```
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct ThemeTemplates {
48    /// Template for the Browse intent (list/grid of records).
49    #[serde(default)]
50    pub browse: Option<IntentModeTemplates>,
51
52    /// Template for the Focus intent (single record detail).
53    #[serde(default)]
54    pub focus: Option<IntentModeTemplates>,
55
56    /// Template for the Collect intent (data entry forms).
57    #[serde(default)]
58    pub collect: Option<IntentModeTemplates>,
59
60    /// Template for the Process intent (workflow / state-machine views).
61    #[serde(default)]
62    pub process: Option<IntentModeTemplates>,
63
64    /// Template for the Summarize intent (metrics / dashboards).
65    #[serde(default)]
66    pub summarize: Option<IntentModeTemplates>,
67
68    /// Template for the Analyze intent (data analysis / reporting).
69    #[serde(default)]
70    pub analyze: Option<IntentModeTemplates>,
71
72    /// Template for the Track intent (timeline / audit-log views).
73    #[serde(default)]
74    pub track: Option<IntentModeTemplates>,
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn intent_slot_template_default_has_empty_slots_and_none_layout() {
83        let t = IntentSlotTemplate::default();
84        assert!(t.slots.is_empty());
85        assert!(t.layout.is_none());
86    }
87
88    #[test]
89    fn intent_mode_templates_default_has_empty_display_and_input() {
90        let t = IntentModeTemplates::default();
91        assert!(t.display.slots.is_empty());
92        assert!(t.display.layout.is_none());
93        assert!(t.input.slots.is_empty());
94        assert!(t.input.layout.is_none());
95    }
96
97    #[test]
98    fn theme_templates_deserializes_empty_json_to_all_none() {
99        let t: ThemeTemplates = serde_json::from_str("{}").unwrap();
100        assert!(t.browse.is_none());
101        assert!(t.focus.is_none());
102        assert!(t.collect.is_none());
103        assert!(t.process.is_none());
104        assert!(t.summarize.is_none());
105        assert!(t.analyze.is_none());
106        assert!(t.track.is_none());
107    }
108
109    #[test]
110    fn theme_templates_deserializes_partial_json_with_other_fields_none() {
111        let json = r#"{"browse": {"display": {"slots": ["title", "fields"]}}}"#;
112        let t: ThemeTemplates = serde_json::from_str(json).unwrap();
113        assert!(t.browse.is_some());
114        let browse = t.browse.unwrap();
115        assert_eq!(browse.display.slots, vec!["title", "fields"]);
116        assert!(browse.display.layout.is_none());
117        assert!(t.focus.is_none());
118        assert!(t.collect.is_none());
119        assert!(t.process.is_none());
120        assert!(t.summarize.is_none());
121        assert!(t.analyze.is_none());
122        assert!(t.track.is_none());
123    }
124
125    #[test]
126    fn theme_templates_deserializes_full_json_with_all_7_intents() {
127        let json = r#"{
128            "browse": {"display": {"slots": ["title"], "layout": "Table"}, "input": {"slots": ["fields"]}},
129            "focus": {"display": {"slots": ["title", "body"]}},
130            "collect": {"input": {"slots": ["fields", "actions"], "layout": "Form"}},
131            "process": {"display": {"slots": ["title", "actions"]}},
132            "summarize": {"display": {"slots": ["stats", "metadata"]}},
133            "analyze": {"display": {"slots": ["fields", "stats"]}},
134            "track": {"display": {"slots": ["title", "metadata", "pagination"]}}
135        }"#;
136        let t: ThemeTemplates = serde_json::from_str(json).unwrap();
137        assert!(t.browse.is_some());
138        assert!(t.focus.is_some());
139        assert!(t.collect.is_some());
140        assert!(t.process.is_some());
141        assert!(t.summarize.is_some());
142        assert!(t.analyze.is_some());
143        assert!(t.track.is_some());
144        let browse = t.browse.unwrap();
145        assert_eq!(browse.display.layout, Some("Table".to_string()));
146        assert_eq!(browse.display.slots, vec!["title"]);
147        assert_eq!(browse.input.slots, vec!["fields"]);
148    }
149
150    #[test]
151    fn theme_templates_serde_round_trip_preserves_all_fields() {
152        let original = ThemeTemplates {
153            browse: Some(IntentModeTemplates {
154                display: IntentSlotTemplate {
155                    slots: vec!["title".to_string(), "fields".to_string()],
156                    layout: Some("Table".to_string()),
157                },
158                input: IntentSlotTemplate::default(),
159            }),
160            focus: Some(IntentModeTemplates::default()),
161            collect: None,
162            process: None,
163            summarize: None,
164            analyze: None,
165            track: None,
166        };
167        let json = serde_json::to_string(&original).unwrap();
168        let restored: ThemeTemplates = serde_json::from_str(&json).unwrap();
169        assert!(restored.browse.is_some());
170        let browse = restored.browse.unwrap();
171        assert_eq!(browse.display.slots, vec!["title", "fields"]);
172        assert_eq!(browse.display.layout, Some("Table".to_string()));
173        assert!(restored.focus.is_some());
174        assert!(restored.collect.is_none());
175    }
176}