Skip to main content

presentar_yaml/
scene.rs

1//! Presentar Scene Format (.prs) parser.
2//!
3//! This module implements the `.prs` format specification for shareable
4//! visualization manifests. The format is YAML-based and declarative,
5//! enabling WASM-native parsing without runtime interpreters.
6//!
7//! # Design Philosophy
8//!
9//! A `.prs` file is a *bill of materials* for a visualization—it declares
10//! **what** to display and **where** data lives, not **how** to fetch or render it.
11
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt;
15
16/// Presentar Scene - top-level structure for `.prs` files.
17///
18/// A Scene is a declarative manifest that references external resources
19/// (models, datasets) and defines widget layout and interactions.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Scene {
22    /// Format version (semver, e.g., "1.0")
23    pub prs_version: String,
24
25    /// Scene metadata
26    pub metadata: SceneMetadata,
27
28    /// External resources (models, datasets)
29    #[serde(default)]
30    pub resources: Resources,
31
32    /// Widget layout configuration
33    pub layout: SceneLayout,
34
35    /// Widget definitions
36    pub widgets: Vec<SceneWidget>,
37
38    /// Event → action bindings
39    #[serde(default)]
40    pub bindings: Vec<Binding>,
41
42    /// Theme configuration
43    #[serde(default)]
44    pub theme: Option<SceneTheme>,
45
46    /// Security permissions
47    #[serde(default)]
48    pub permissions: Permissions,
49
50    /// Header bar configuration (for tmux layout)
51    #[serde(default)]
52    pub header: Option<HeaderFooter>,
53
54    /// Footer bar configuration (for tmux layout)
55    #[serde(default)]
56    pub footer: Option<HeaderFooter>,
57
58    /// Keyboard sequence bindings (for tmux layout)
59    #[serde(default)]
60    pub key_bindings: Option<KeyBindings>,
61}
62
63/// Scene metadata.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SceneMetadata {
66    /// Unique scene identifier (kebab-case, e.g., "sentiment-analysis-demo")
67    pub name: String,
68
69    /// Human-readable title
70    #[serde(default)]
71    pub title: Option<String>,
72
73    /// Description
74    #[serde(default)]
75    pub description: Option<String>,
76
77    /// Author email or identifier
78    #[serde(default)]
79    pub author: Option<String>,
80
81    /// Creation timestamp (ISO 8601)
82    #[serde(default)]
83    pub created: Option<String>,
84
85    /// License identifier (e.g., "MIT", "Apache-2.0")
86    #[serde(default)]
87    pub license: Option<String>,
88
89    /// Tags for categorization
90    #[serde(default)]
91    pub tags: Vec<String>,
92}
93
94/// External resources container.
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct Resources {
97    /// Model resources
98    #[serde(default)]
99    pub models: HashMap<String, ModelResource>,
100
101    /// Dataset resources
102    #[serde(default)]
103    pub datasets: HashMap<String, DatasetResource>,
104}
105
106/// Model resource reference.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ModelResource {
109    /// Model format (apr, gguf, safetensors)
110    #[serde(rename = "type")]
111    pub resource_type: ModelType,
112
113    /// Source URL or path (can be array for fallback)
114    pub source: ResourceSource,
115
116    /// Content hash for verification (blake3:<hex>)
117    #[serde(default)]
118    pub hash: Option<String>,
119
120    /// File size in bytes (for progress indication)
121    #[serde(default)]
122    pub size_bytes: Option<u64>,
123}
124
125/// Dataset resource reference.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DatasetResource {
128    /// Dataset format (ald, parquet, csv)
129    #[serde(rename = "type")]
130    pub resource_type: DatasetType,
131
132    /// Source URL or path (can be array for fallback)
133    pub source: ResourceSource,
134
135    /// Content hash for verification (blake3:<hex>)
136    #[serde(default)]
137    pub hash: Option<String>,
138
139    /// File size in bytes (for progress indication)
140    #[serde(default)]
141    pub size_bytes: Option<u64>,
142}
143
144/// Model format types.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub enum ModelType {
148    /// Aprender model format
149    Apr,
150    /// GGUF quantized format
151    Gguf,
152    /// SafeTensors format
153    Safetensors,
154}
155
156/// Dataset format types.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "lowercase")]
159pub enum DatasetType {
160    /// Alimentar dataset format
161    Ald,
162    /// Apache Parquet
163    Parquet,
164    /// Comma-separated values
165    Csv,
166}
167
168/// Resource source - single URL/path or array of fallbacks.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(untagged)]
171pub enum ResourceSource {
172    /// Single source
173    Single(String),
174    /// Multiple sources (tried in order)
175    Multiple(Vec<String>),
176}
177
178impl ResourceSource {
179    /// Get all sources as a slice.
180    #[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    /// Get primary source.
189    #[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/// Scene layout configuration.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct SceneLayout {
201    /// Layout type
202    #[serde(rename = "type")]
203    pub layout_type: LayoutType,
204
205    /// Number of columns (for grid layout)
206    #[serde(default)]
207    pub columns: Option<u32>,
208
209    /// Number of rows (for grid layout)
210    #[serde(default)]
211    pub rows: Option<u32>,
212
213    /// Gap between widgets in pixels
214    #[serde(default = "default_gap")]
215    pub gap: u32,
216
217    /// Flex direction (for flex layout)
218    #[serde(default)]
219    pub direction: Option<FlexDirection>,
220
221    /// Flex wrap (for flex layout)
222    #[serde(default)]
223    pub wrap: Option<bool>,
224
225    /// Canvas width (for absolute layout)
226    #[serde(default)]
227    pub width: Option<u32>,
228
229    /// Canvas height (for absolute layout)
230    #[serde(default)]
231    pub height: Option<u32>,
232}
233
234const fn default_gap() -> u32 {
235    16
236}
237
238/// Layout type enum.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum LayoutType {
242    /// CSS Grid layout
243    Grid,
244    /// Flexbox layout
245    Flex,
246    /// Absolute positioning
247    Absolute,
248    /// TMUX-style multi-pane terminal layout
249    Tmux,
250}
251
252/// Flex direction.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "lowercase")]
255pub enum FlexDirection {
256    /// Horizontal (left to right)
257    Row,
258    /// Vertical (top to bottom)
259    Column,
260}
261
262/// Scene widget definition.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct SceneWidget {
265    /// Unique widget identifier
266    pub id: String,
267
268    /// Widget type
269    #[serde(rename = "type")]
270    pub widget_type: WidgetType,
271
272    /// Grid position (for grid layout)
273    #[serde(default)]
274    pub position: Option<GridPosition>,
275
276    /// Widget-specific configuration
277    #[serde(default)]
278    pub config: WidgetConfig,
279}
280
281/// Widget types.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum WidgetType {
285    /// Text input field
286    Textbox,
287    /// Numeric slider
288    Slider,
289    /// Selection dropdown
290    Dropdown,
291    /// Clickable button
292    Button,
293    /// Image display
294    Image,
295    /// Bar chart visualization
296    BarChart,
297    /// Line chart visualization
298    LineChart,
299    /// Single-value gauge
300    Gauge,
301    /// Data table
302    Table,
303    /// Markdown content
304    Markdown,
305    /// Model inference runner
306    Inference,
307    /// Interactive terminal pane (APR shell, WOS, or static)
308    Terminal,
309}
310
311/// Grid position for widgets.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct GridPosition {
314    /// Row index (0-based)
315    pub row: u32,
316    /// Column index (0-based)
317    pub col: u32,
318    /// Column span (defaults to 1)
319    #[serde(default = "default_span")]
320    pub colspan: u32,
321    /// Row span (defaults to 1)
322    #[serde(default = "default_span")]
323    pub rowspan: u32,
324}
325
326const fn default_span() -> u32 {
327    1
328}
329
330/// Widget configuration - varies by widget type.
331#[derive(Debug, Clone, Default, Serialize, Deserialize)]
332pub struct WidgetConfig {
333    // Common fields
334    /// Label text
335    #[serde(default)]
336    pub label: Option<String>,
337    /// Title text
338    #[serde(default)]
339    pub title: Option<String>,
340
341    // Textbox fields
342    /// Placeholder text
343    #[serde(default)]
344    pub placeholder: Option<String>,
345    /// Maximum input length
346    #[serde(default)]
347    pub max_length: Option<u32>,
348
349    // Slider fields
350    /// Minimum value
351    #[serde(default)]
352    pub min: Option<f64>,
353    /// Maximum value
354    #[serde(default)]
355    pub max: Option<f64>,
356    /// Step increment
357    #[serde(default)]
358    pub step: Option<f64>,
359    /// Default value
360    #[serde(default)]
361    pub default: Option<f64>,
362
363    // Dropdown fields
364    /// Selection options
365    #[serde(default)]
366    pub options: Option<String>,
367    /// Allow multiple selection
368    #[serde(default)]
369    pub multi_select: Option<bool>,
370
371    // Button fields
372    /// Button action
373    #[serde(default)]
374    pub action: Option<String>,
375
376    // Image fields
377    /// Image source URL/path
378    #[serde(default)]
379    pub source: Option<String>,
380    /// Alt text
381    #[serde(default)]
382    pub alt: Option<String>,
383    /// Upload mode
384    #[serde(default)]
385    pub mode: Option<String>,
386    /// Accepted MIME types
387    #[serde(default)]
388    pub accept: Option<Vec<String>>,
389
390    // Chart fields
391    /// Data source expression
392    #[serde(default)]
393    pub data: Option<String>,
394    /// X-axis field/expression
395    #[serde(default)]
396    pub x_axis: Option<String>,
397    /// Y-axis field/expression
398    #[serde(default)]
399    pub y_axis: Option<String>,
400
401    // Gauge fields
402    /// Gauge value expression
403    #[serde(default)]
404    pub value: Option<String>,
405    /// Gauge thresholds
406    #[serde(default)]
407    pub thresholds: Option<Vec<Threshold>>,
408
409    // Table fields
410    /// Column definitions
411    #[serde(default)]
412    pub columns: Option<Vec<String>>,
413    /// Sortable flag
414    #[serde(default)]
415    pub sortable: Option<bool>,
416
417    // Markdown fields
418    /// Markdown content
419    #[serde(default)]
420    pub content: Option<String>,
421
422    // Inference fields
423    /// Model reference
424    #[serde(default)]
425    pub model: Option<String>,
426    /// Input expression
427    #[serde(default)]
428    pub input: Option<String>,
429    /// Output field
430    #[serde(default)]
431    pub output: Option<String>,
432
433    // Terminal fields (mode field shared with Image)
434    /// URL to APR model file
435    #[serde(default)]
436    pub model_url: Option<String>,
437    /// Terminal prompt string
438    #[serde(default)]
439    pub prompt: Option<String>,
440    /// Enable search bar at bottom of pane
441    #[serde(default)]
442    pub search_bar: Option<bool>,
443    /// Scrollback history size (lines)
444    #[serde(default)]
445    pub history_size: Option<u32>,
446    /// Script path for WOS mode
447    #[serde(default)]
448    pub script: Option<String>,
449    /// Auto-run script on load
450    #[serde(default)]
451    pub auto_run: Option<bool>,
452    /// Hint text shown when auto-run is enabled
453    #[serde(default)]
454    pub auto_run_hint: Option<String>,
455}
456
457/// Gauge threshold.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct Threshold {
460    /// Threshold value
461    pub value: f64,
462    /// Color at/below threshold
463    pub color: String,
464}
465
466/// Event binding.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct Binding {
469    /// Event trigger (e.g., "text_input.change")
470    pub trigger: String,
471
472    /// Debounce delay in milliseconds
473    #[serde(default)]
474    pub debounce_ms: Option<u32>,
475
476    /// Actions to execute
477    pub actions: Vec<BindingAction>,
478}
479
480/// Binding action.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct BindingAction {
483    /// Target (widget ID or inference.model)
484    pub target: String,
485
486    /// Action type (refresh, set, etc.)
487    #[serde(default)]
488    pub action: Option<String>,
489
490    /// Input expression
491    #[serde(default)]
492    pub input: Option<String>,
493}
494
495/// Theme configuration.
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct SceneTheme {
498    /// Theme preset (light, dark)
499    #[serde(default)]
500    pub preset: Option<String>,
501
502    /// Custom theme values
503    #[serde(default)]
504    pub custom: HashMap<String, String>,
505}
506
507/// Security permissions.
508#[derive(Debug, Clone, Default, Serialize, Deserialize)]
509pub struct Permissions {
510    /// Allowed network URLs (glob patterns)
511    #[serde(default)]
512    pub network: Vec<String>,
513
514    /// Allowed filesystem paths (glob patterns)
515    #[serde(default)]
516    pub filesystem: Vec<String>,
517
518    /// Clipboard access
519    #[serde(default)]
520    pub clipboard: bool,
521
522    /// Camera access
523    #[serde(default)]
524    pub camera: bool,
525}
526
527/// Header or footer bar configuration for tmux layout.
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct HeaderFooter {
530    /// Height in pixels
531    #[serde(default = "default_header_height")]
532    pub height: u32,
533
534    /// Background color
535    #[serde(default)]
536    pub background: Option<String>,
537
538    /// Content sections (left, center, right)
539    #[serde(default)]
540    pub content: HeaderContent,
541}
542
543fn default_header_height() -> u32 {
544    48
545}
546
547/// Header/footer content layout.
548#[derive(Debug, Clone, Default, Serialize, Deserialize)]
549pub struct HeaderContent {
550    /// Left-aligned items
551    #[serde(default)]
552    pub left: Vec<ContentItem>,
553    /// Center-aligned items
554    #[serde(default)]
555    pub center: Vec<ContentItem>,
556    /// Right-aligned items
557    #[serde(default)]
558    pub right: Vec<ContentItem>,
559}
560
561/// A content item within header/footer.
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct ContentItem {
564    /// Item type (text, nav, pane_tabs)
565    #[serde(rename = "type")]
566    pub item_type: String,
567    /// Text content
568    #[serde(default)]
569    pub content: Option<String>,
570    /// Style name
571    #[serde(default)]
572    pub style: Option<String>,
573    /// Navigation items (for nav type)
574    #[serde(default)]
575    pub items: Vec<NavItem>,
576}
577
578/// Navigation link item.
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct NavItem {
581    /// Display label
582    pub label: String,
583    /// Link target
584    pub href: String,
585    /// Open in new tab
586    #[serde(default)]
587    pub external: bool,
588}
589
590/// Keyboard binding configuration for tmux-style prefix keys.
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct KeyBindings {
593    /// Prefix key (e.g., "ctrl+b")
594    #[serde(default = "default_prefix_key")]
595    pub prefix_key: String,
596
597    /// Timeout in ms after prefix key before returning to normal mode
598    #[serde(default = "default_prefix_timeout")]
599    pub prefix_timeout_ms: u32,
600
601    /// Two-key sequences (prefix + follow-up)
602    #[serde(default)]
603    pub sequences: Vec<KeySequence>,
604
605    /// Single-key global bindings (no prefix needed)
606    #[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/// A two-key sequence binding.
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct KeySequence {
621    /// Key sequence (e.g., `["ctrl+b", "0"]`)
622    pub keys: Vec<String>,
623    /// Action to perform
624    pub action: serde_yaml_ng::Value,
625}
626
627/// A single global key binding.
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct GlobalKeyBinding {
630    /// Key name
631    pub key: String,
632    /// Action to perform
633    pub action: serde_yaml_ng::Value,
634}
635
636/// Error type for scene parsing and validation.
637#[derive(Debug)]
638pub enum SceneError {
639    /// YAML parsing error
640    Yaml(serde_yaml_ng::Error),
641
642    /// Invalid prs_version format
643    InvalidVersion(String),
644
645    /// Duplicate widget ID
646    DuplicateWidgetId(String),
647
648    /// Invalid binding target (references non-existent widget)
649    InvalidBindingTarget {
650        /// The binding trigger
651        trigger: String,
652        /// The invalid target
653        target: String,
654    },
655
656    /// Invalid hash format
657    InvalidHashFormat {
658        /// Resource name
659        resource: String,
660        /// The invalid hash
661        hash: String,
662    },
663
664    /// Missing required hash for remote resource
665    MissingRemoteHash {
666        /// Resource name
667        resource: String,
668    },
669
670    /// Invalid expression syntax
671    InvalidExpression {
672        /// Widget ID or context
673        context: String,
674        /// The invalid expression
675        expression: String,
676        /// Error message
677        message: String,
678    },
679
680    /// Invalid metadata name (must be kebab-case)
681    InvalidMetadataName(String),
682
683    /// Layout validation error
684    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    /// Parse a scene from YAML string.
740    ///
741    /// # Errors
742    ///
743    /// Returns an error if the YAML is invalid or fails validation.
744    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    /// Serialize scene to YAML string.
751    ///
752    /// # Errors
753    ///
754    /// Returns an error if serialization fails.
755    pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
756        serde_yaml_ng::to_string(self)
757    }
758
759    /// Validate the scene structure.
760    ///
761    /// Checks:
762    /// 1. prs_version format (semver)
763    /// 2. metadata.name is kebab-case
764    /// 3. Widget IDs are unique
765    /// 4. Binding targets reference valid widgets/resources
766    /// 5. Remote resources have hashes
767    /// 6. Hash formats are valid (blake3:<hex>)
768    ///
769    /// # Errors
770    ///
771    /// Returns the first validation error found.
772    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        // Version should be "X.Y" format
784        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        // Must be kebab-case: lowercase letters, numbers, hyphens
799        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        // Cannot start or end with hyphen
806        if name.starts_with('-') || name.ends_with('-') {
807            return Err(SceneError::InvalidMetadataName(name.clone()));
808        }
809        // Cannot have consecutive hyphens
810        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                // Check if target is a widget ID
837                if widget_ids.contains(target.as_str()) {
838                    continue;
839                }
840
841                // Check if target is inference.<model_name>
842                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        // Validate model hashes
859        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        // Validate dataset hashes
871        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                // Flex layout has optional fields
903            }
904            LayoutType::Tmux => {
905                // Tmux layout uses rows/cols from SceneLayout (optional)
906            }
907        }
908        Ok(())
909    }
910
911    /// Get all widget IDs.
912    #[must_use]
913    pub fn widget_ids(&self) -> Vec<&str> {
914        self.widgets.iter().map(|w| w.id.as_str()).collect()
915    }
916
917    /// Get a widget by ID.
918    #[must_use]
919    pub fn get_widget(&self, id: &str) -> Option<&SceneWidget> {
920        self.widgets.iter().find(|w| w.id == id)
921    }
922
923    /// Get a model resource by name.
924    #[must_use]
925    pub fn get_model(&self, name: &str) -> Option<&ModelResource> {
926        self.resources.models.get(name)
927    }
928
929    /// Get a dataset resource by name.
930    #[must_use]
931    pub fn get_dataset(&self, name: &str) -> Option<&DatasetResource> {
932        self.resources.datasets.get(name)
933    }
934}
935
936/// Check if a resource source is remote (https://).
937fn is_remote_source(source: &ResourceSource) -> bool {
938    source.sources().iter().any(|s| s.starts_with("https://"))
939}
940
941/// Validate hash format (blake3:<64-hex-chars>).
942fn validate_hash_format(resource: &str, hash: &str) -> Result<(), SceneError> {
943    if let Some(hex) = hash.strip_prefix("blake3:") {
944        // BLAKE3 produces 256-bit (32-byte) hashes = 64 hex characters
945        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;