1use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Scene {
22 pub prs_version: String,
24
25 pub metadata: SceneMetadata,
27
28 #[serde(default)]
30 pub resources: Resources,
31
32 pub layout: SceneLayout,
34
35 pub widgets: Vec<SceneWidget>,
37
38 #[serde(default)]
40 pub bindings: Vec<Binding>,
41
42 #[serde(default)]
44 pub theme: Option<SceneTheme>,
45
46 #[serde(default)]
48 pub permissions: Permissions,
49
50 #[serde(default)]
52 pub header: Option<HeaderFooter>,
53
54 #[serde(default)]
56 pub footer: Option<HeaderFooter>,
57
58 #[serde(default)]
60 pub key_bindings: Option<KeyBindings>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SceneMetadata {
66 pub name: String,
68
69 #[serde(default)]
71 pub title: Option<String>,
72
73 #[serde(default)]
75 pub description: Option<String>,
76
77 #[serde(default)]
79 pub author: Option<String>,
80
81 #[serde(default)]
83 pub created: Option<String>,
84
85 #[serde(default)]
87 pub license: Option<String>,
88
89 #[serde(default)]
91 pub tags: Vec<String>,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct Resources {
97 #[serde(default)]
99 pub models: HashMap<String, ModelResource>,
100
101 #[serde(default)]
103 pub datasets: HashMap<String, DatasetResource>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ModelResource {
109 #[serde(rename = "type")]
111 pub resource_type: ModelType,
112
113 pub source: ResourceSource,
115
116 #[serde(default)]
118 pub hash: Option<String>,
119
120 #[serde(default)]
122 pub size_bytes: Option<u64>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DatasetResource {
128 #[serde(rename = "type")]
130 pub resource_type: DatasetType,
131
132 pub source: ResourceSource,
134
135 #[serde(default)]
137 pub hash: Option<String>,
138
139 #[serde(default)]
141 pub size_bytes: Option<u64>,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub enum ModelType {
148 Apr,
150 Gguf,
152 Safetensors,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "lowercase")]
159pub enum DatasetType {
160 Ald,
162 Parquet,
164 Csv,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(untagged)]
171pub enum ResourceSource {
172 Single(String),
174 Multiple(Vec<String>),
176}
177
178impl ResourceSource {
179 #[must_use]
181 pub fn sources(&self) -> Vec<&str> {
182 match self {
183 Self::Single(s) => vec![s.as_str()],
184 Self::Multiple(v) => v.iter().map(String::as_str).collect(),
185 }
186 }
187
188 #[must_use]
190 pub fn primary(&self) -> &str {
191 match self {
192 Self::Single(s) => s.as_str(),
193 Self::Multiple(v) => v.first().map_or("", String::as_str),
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct SceneLayout {
201 #[serde(rename = "type")]
203 pub layout_type: LayoutType,
204
205 #[serde(default)]
207 pub columns: Option<u32>,
208
209 #[serde(default)]
211 pub rows: Option<u32>,
212
213 #[serde(default = "default_gap")]
215 pub gap: u32,
216
217 #[serde(default)]
219 pub direction: Option<FlexDirection>,
220
221 #[serde(default)]
223 pub wrap: Option<bool>,
224
225 #[serde(default)]
227 pub width: Option<u32>,
228
229 #[serde(default)]
231 pub height: Option<u32>,
232}
233
234const fn default_gap() -> u32 {
235 16
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum LayoutType {
242 Grid,
244 Flex,
246 Absolute,
248 Tmux,
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "lowercase")]
255pub enum FlexDirection {
256 Row,
258 Column,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct SceneWidget {
265 pub id: String,
267
268 #[serde(rename = "type")]
270 pub widget_type: WidgetType,
271
272 #[serde(default)]
274 pub position: Option<GridPosition>,
275
276 #[serde(default)]
278 pub config: WidgetConfig,
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum WidgetType {
285 Textbox,
287 Slider,
289 Dropdown,
291 Button,
293 Image,
295 BarChart,
297 LineChart,
299 Gauge,
301 Table,
303 Markdown,
305 Inference,
307 Terminal,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct GridPosition {
314 pub row: u32,
316 pub col: u32,
318 #[serde(default = "default_span")]
320 pub colspan: u32,
321 #[serde(default = "default_span")]
323 pub rowspan: u32,
324}
325
326const fn default_span() -> u32 {
327 1
328}
329
330#[derive(Debug, Clone, Default, Serialize, Deserialize)]
332pub struct WidgetConfig {
333 #[serde(default)]
336 pub label: Option<String>,
337 #[serde(default)]
339 pub title: Option<String>,
340
341 #[serde(default)]
344 pub placeholder: Option<String>,
345 #[serde(default)]
347 pub max_length: Option<u32>,
348
349 #[serde(default)]
352 pub min: Option<f64>,
353 #[serde(default)]
355 pub max: Option<f64>,
356 #[serde(default)]
358 pub step: Option<f64>,
359 #[serde(default)]
361 pub default: Option<f64>,
362
363 #[serde(default)]
366 pub options: Option<String>,
367 #[serde(default)]
369 pub multi_select: Option<bool>,
370
371 #[serde(default)]
374 pub action: Option<String>,
375
376 #[serde(default)]
379 pub source: Option<String>,
380 #[serde(default)]
382 pub alt: Option<String>,
383 #[serde(default)]
385 pub mode: Option<String>,
386 #[serde(default)]
388 pub accept: Option<Vec<String>>,
389
390 #[serde(default)]
393 pub data: Option<String>,
394 #[serde(default)]
396 pub x_axis: Option<String>,
397 #[serde(default)]
399 pub y_axis: Option<String>,
400
401 #[serde(default)]
404 pub value: Option<String>,
405 #[serde(default)]
407 pub thresholds: Option<Vec<Threshold>>,
408
409 #[serde(default)]
412 pub columns: Option<Vec<String>>,
413 #[serde(default)]
415 pub sortable: Option<bool>,
416
417 #[serde(default)]
420 pub content: Option<String>,
421
422 #[serde(default)]
425 pub model: Option<String>,
426 #[serde(default)]
428 pub input: Option<String>,
429 #[serde(default)]
431 pub output: Option<String>,
432
433 #[serde(default)]
436 pub model_url: Option<String>,
437 #[serde(default)]
439 pub prompt: Option<String>,
440 #[serde(default)]
442 pub search_bar: Option<bool>,
443 #[serde(default)]
445 pub history_size: Option<u32>,
446 #[serde(default)]
448 pub script: Option<String>,
449 #[serde(default)]
451 pub auto_run: Option<bool>,
452 #[serde(default)]
454 pub auto_run_hint: Option<String>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct Threshold {
460 pub value: f64,
462 pub color: String,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct Binding {
469 pub trigger: String,
471
472 #[serde(default)]
474 pub debounce_ms: Option<u32>,
475
476 pub actions: Vec<BindingAction>,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct BindingAction {
483 pub target: String,
485
486 #[serde(default)]
488 pub action: Option<String>,
489
490 #[serde(default)]
492 pub input: Option<String>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct SceneTheme {
498 #[serde(default)]
500 pub preset: Option<String>,
501
502 #[serde(default)]
504 pub custom: HashMap<String, String>,
505}
506
507#[derive(Debug, Clone, Default, Serialize, Deserialize)]
509pub struct Permissions {
510 #[serde(default)]
512 pub network: Vec<String>,
513
514 #[serde(default)]
516 pub filesystem: Vec<String>,
517
518 #[serde(default)]
520 pub clipboard: bool,
521
522 #[serde(default)]
524 pub camera: bool,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct HeaderFooter {
530 #[serde(default = "default_header_height")]
532 pub height: u32,
533
534 #[serde(default)]
536 pub background: Option<String>,
537
538 #[serde(default)]
540 pub content: HeaderContent,
541}
542
543fn default_header_height() -> u32 {
544 48
545}
546
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
549pub struct HeaderContent {
550 #[serde(default)]
552 pub left: Vec<ContentItem>,
553 #[serde(default)]
555 pub center: Vec<ContentItem>,
556 #[serde(default)]
558 pub right: Vec<ContentItem>,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct ContentItem {
564 #[serde(rename = "type")]
566 pub item_type: String,
567 #[serde(default)]
569 pub content: Option<String>,
570 #[serde(default)]
572 pub style: Option<String>,
573 #[serde(default)]
575 pub items: Vec<NavItem>,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct NavItem {
581 pub label: String,
583 pub href: String,
585 #[serde(default)]
587 pub external: bool,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct KeyBindings {
593 #[serde(default = "default_prefix_key")]
595 pub prefix_key: String,
596
597 #[serde(default = "default_prefix_timeout")]
599 pub prefix_timeout_ms: u32,
600
601 #[serde(default)]
603 pub sequences: Vec<KeySequence>,
604
605 #[serde(default)]
607 pub global: Vec<GlobalKeyBinding>,
608}
609
610fn default_prefix_key() -> String {
611 "ctrl+b".into()
612}
613
614fn default_prefix_timeout() -> u32 {
615 500
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct KeySequence {
621 pub keys: Vec<String>,
623 pub action: serde_yaml_ng::Value,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct GlobalKeyBinding {
630 pub key: String,
632 pub action: serde_yaml_ng::Value,
634}
635
636#[derive(Debug)]
638pub enum SceneError {
639 Yaml(serde_yaml_ng::Error),
641
642 InvalidVersion(String),
644
645 DuplicateWidgetId(String),
647
648 InvalidBindingTarget {
650 trigger: String,
652 target: String,
654 },
655
656 InvalidHashFormat {
658 resource: String,
660 hash: String,
662 },
663
664 MissingRemoteHash {
666 resource: String,
668 },
669
670 InvalidExpression {
672 context: String,
674 expression: String,
676 message: String,
678 },
679
680 InvalidMetadataName(String),
682
683 LayoutError(String),
685}
686
687impl fmt::Display for SceneError {
688 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
689 match self {
690 Self::Yaml(e) => write!(f, "YAML error: {e}"),
691 Self::InvalidVersion(v) => write!(f, "Invalid prs_version: {v}"),
692 Self::DuplicateWidgetId(id) => write!(f, "Duplicate widget id: {id}"),
693 Self::InvalidBindingTarget { trigger, target } => {
694 write!(
695 f,
696 "Invalid binding target '{target}' in trigger '{trigger}'"
697 )
698 }
699 Self::InvalidHashFormat { resource, hash } => {
700 write!(f, "Invalid hash format for '{resource}': {hash}")
701 }
702 Self::MissingRemoteHash { resource } => {
703 write!(f, "Missing hash for remote resource: {resource}")
704 }
705 Self::InvalidExpression {
706 context,
707 expression,
708 message,
709 } => {
710 write!(
711 f,
712 "Invalid expression in {context}: '{expression}' - {message}"
713 )
714 }
715 Self::InvalidMetadataName(name) => {
716 write!(f, "Invalid metadata name '{name}': must be kebab-case")
717 }
718 Self::LayoutError(msg) => write!(f, "Layout error: {msg}"),
719 }
720 }
721}
722
723impl std::error::Error for SceneError {
724 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
725 match self {
726 Self::Yaml(e) => Some(e),
727 _ => None,
728 }
729 }
730}
731
732impl From<serde_yaml_ng::Error> for SceneError {
733 fn from(e: serde_yaml_ng::Error) -> Self {
734 Self::Yaml(e)
735 }
736}
737
738impl Scene {
739 pub fn from_yaml(yaml: &str) -> Result<Self, SceneError> {
745 let scene: Self = serde_yaml_ng::from_str(yaml)?;
746 scene.validate()?;
747 Ok(scene)
748 }
749
750 pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
756 serde_yaml_ng::to_string(self)
757 }
758
759 pub fn validate(&self) -> Result<(), SceneError> {
773 self.validate_version()?;
774 self.validate_metadata_name()?;
775 self.validate_widget_ids()?;
776 self.validate_bindings()?;
777 self.validate_resource_hashes()?;
778 self.validate_layout()?;
779 Ok(())
780 }
781
782 fn validate_version(&self) -> Result<(), SceneError> {
783 let parts: Vec<&str> = self.prs_version.split('.').collect();
785 if parts.len() != 2 {
786 return Err(SceneError::InvalidVersion(self.prs_version.clone()));
787 }
788 for part in parts {
789 if part.parse::<u32>().is_err() {
790 return Err(SceneError::InvalidVersion(self.prs_version.clone()));
791 }
792 }
793 Ok(())
794 }
795
796 fn validate_metadata_name(&self) -> Result<(), SceneError> {
797 let name = &self.metadata.name;
798 if !name
800 .chars()
801 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
802 {
803 return Err(SceneError::InvalidMetadataName(name.clone()));
804 }
805 if name.starts_with('-') || name.ends_with('-') {
807 return Err(SceneError::InvalidMetadataName(name.clone()));
808 }
809 if name.contains("--") {
811 return Err(SceneError::InvalidMetadataName(name.clone()));
812 }
813 Ok(())
814 }
815
816 fn validate_widget_ids(&self) -> Result<(), SceneError> {
817 let mut seen = std::collections::HashSet::new();
818 for widget in &self.widgets {
819 if !seen.insert(&widget.id) {
820 return Err(SceneError::DuplicateWidgetId(widget.id.clone()));
821 }
822 }
823 Ok(())
824 }
825
826 fn validate_bindings(&self) -> Result<(), SceneError> {
827 let widget_ids: std::collections::HashSet<&str> =
828 self.widgets.iter().map(|w| w.id.as_str()).collect();
829 let model_ids: std::collections::HashSet<&str> =
830 self.resources.models.keys().map(String::as_str).collect();
831
832 for binding in &self.bindings {
833 for action in &binding.actions {
834 let target = &action.target;
835
836 if widget_ids.contains(target.as_str()) {
838 continue;
839 }
840
841 if let Some(model_name) = target.strip_prefix("inference.") {
843 if model_ids.contains(model_name) {
844 continue;
845 }
846 }
847
848 return Err(SceneError::InvalidBindingTarget {
849 trigger: binding.trigger.clone(),
850 target: target.clone(),
851 });
852 }
853 }
854 Ok(())
855 }
856
857 fn validate_resource_hashes(&self) -> Result<(), SceneError> {
858 for (name, resource) in &self.resources.models {
860 if is_remote_source(&resource.source) && resource.hash.is_none() {
861 return Err(SceneError::MissingRemoteHash {
862 resource: name.clone(),
863 });
864 }
865 if let Some(hash) = &resource.hash {
866 validate_hash_format(name, hash)?;
867 }
868 }
869
870 for (name, resource) in &self.resources.datasets {
872 if is_remote_source(&resource.source) && resource.hash.is_none() {
873 return Err(SceneError::MissingRemoteHash {
874 resource: name.clone(),
875 });
876 }
877 if let Some(hash) = &resource.hash {
878 validate_hash_format(name, hash)?;
879 }
880 }
881
882 Ok(())
883 }
884
885 fn validate_layout(&self) -> Result<(), SceneError> {
886 match self.layout.layout_type {
887 LayoutType::Grid => {
888 if self.layout.columns.is_none() {
889 return Err(SceneError::LayoutError(
890 "Grid layout requires 'columns' field".to_string(),
891 ));
892 }
893 }
894 LayoutType::Absolute => {
895 if self.layout.width.is_none() || self.layout.height.is_none() {
896 return Err(SceneError::LayoutError(
897 "Absolute layout requires 'width' and 'height' fields".to_string(),
898 ));
899 }
900 }
901 LayoutType::Flex => {
902 }
904 LayoutType::Tmux => {
905 }
907 }
908 Ok(())
909 }
910
911 #[must_use]
913 pub fn widget_ids(&self) -> Vec<&str> {
914 self.widgets.iter().map(|w| w.id.as_str()).collect()
915 }
916
917 #[must_use]
919 pub fn get_widget(&self, id: &str) -> Option<&SceneWidget> {
920 self.widgets.iter().find(|w| w.id == id)
921 }
922
923 #[must_use]
925 pub fn get_model(&self, name: &str) -> Option<&ModelResource> {
926 self.resources.models.get(name)
927 }
928
929 #[must_use]
931 pub fn get_dataset(&self, name: &str) -> Option<&DatasetResource> {
932 self.resources.datasets.get(name)
933 }
934}
935
936fn is_remote_source(source: &ResourceSource) -> bool {
938 source.sources().iter().any(|s| s.starts_with("https://"))
939}
940
941fn validate_hash_format(resource: &str, hash: &str) -> Result<(), SceneError> {
943 if let Some(hex) = hash.strip_prefix("blake3:") {
944 if hex.len() >= 12 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
946 return Ok(());
947 }
948 }
949 Err(SceneError::InvalidHashFormat {
950 resource: resource.to_string(),
951 hash: hash.to_string(),
952 })
953}
954
955#[cfg(test)]
956#[path = "scene_tests.rs"]
957mod tests;