use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Scene {
pub prs_version: String,
pub metadata: SceneMetadata,
#[serde(default)]
pub resources: Resources,
pub layout: SceneLayout,
pub widgets: Vec<SceneWidget>,
#[serde(default)]
pub bindings: Vec<Binding>,
#[serde(default)]
pub theme: Option<SceneTheme>,
#[serde(default)]
pub permissions: Permissions,
#[serde(default)]
pub header: Option<HeaderFooter>,
#[serde(default)]
pub footer: Option<HeaderFooter>,
#[serde(default)]
pub key_bindings: Option<KeyBindings>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneMetadata {
pub name: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Resources {
#[serde(default)]
pub models: HashMap<String, ModelResource>,
#[serde(default)]
pub datasets: HashMap<String, DatasetResource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelResource {
#[serde(rename = "type")]
pub resource_type: ModelType,
pub source: ResourceSource,
#[serde(default)]
pub hash: Option<String>,
#[serde(default)]
pub size_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatasetResource {
#[serde(rename = "type")]
pub resource_type: DatasetType,
pub source: ResourceSource,
#[serde(default)]
pub hash: Option<String>,
#[serde(default)]
pub size_bytes: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ModelType {
Apr,
Gguf,
Safetensors,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DatasetType {
Ald,
Parquet,
Csv,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResourceSource {
Single(String),
Multiple(Vec<String>),
}
impl ResourceSource {
#[must_use]
pub fn sources(&self) -> Vec<&str> {
match self {
Self::Single(s) => vec![s.as_str()],
Self::Multiple(v) => v.iter().map(String::as_str).collect(),
}
}
#[must_use]
pub fn primary(&self) -> &str {
match self {
Self::Single(s) => s.as_str(),
Self::Multiple(v) => v.first().map_or("", String::as_str),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneLayout {
#[serde(rename = "type")]
pub layout_type: LayoutType,
#[serde(default)]
pub columns: Option<u32>,
#[serde(default)]
pub rows: Option<u32>,
#[serde(default = "default_gap")]
pub gap: u32,
#[serde(default)]
pub direction: Option<FlexDirection>,
#[serde(default)]
pub wrap: Option<bool>,
#[serde(default)]
pub width: Option<u32>,
#[serde(default)]
pub height: Option<u32>,
}
const fn default_gap() -> u32 {
16
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LayoutType {
Grid,
Flex,
Absolute,
Tmux,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FlexDirection {
Row,
Column,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneWidget {
pub id: String,
#[serde(rename = "type")]
pub widget_type: WidgetType,
#[serde(default)]
pub position: Option<GridPosition>,
#[serde(default)]
pub config: WidgetConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WidgetType {
Textbox,
Slider,
Dropdown,
Button,
Image,
BarChart,
LineChart,
Gauge,
Table,
Markdown,
Inference,
Terminal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridPosition {
pub row: u32,
pub col: u32,
#[serde(default = "default_span")]
pub colspan: u32,
#[serde(default = "default_span")]
pub rowspan: u32,
}
const fn default_span() -> u32 {
1
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WidgetConfig {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default)]
pub max_length: Option<u32>,
#[serde(default)]
pub min: Option<f64>,
#[serde(default)]
pub max: Option<f64>,
#[serde(default)]
pub step: Option<f64>,
#[serde(default)]
pub default: Option<f64>,
#[serde(default)]
pub options: Option<String>,
#[serde(default)]
pub multi_select: Option<bool>,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub alt: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub accept: Option<Vec<String>>,
#[serde(default)]
pub data: Option<String>,
#[serde(default)]
pub x_axis: Option<String>,
#[serde(default)]
pub y_axis: Option<String>,
#[serde(default)]
pub value: Option<String>,
#[serde(default)]
pub thresholds: Option<Vec<Threshold>>,
#[serde(default)]
pub columns: Option<Vec<String>>,
#[serde(default)]
pub sortable: Option<bool>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub input: Option<String>,
#[serde(default)]
pub output: Option<String>,
#[serde(default)]
pub model_url: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub search_bar: Option<bool>,
#[serde(default)]
pub history_size: Option<u32>,
#[serde(default)]
pub script: Option<String>,
#[serde(default)]
pub auto_run: Option<bool>,
#[serde(default)]
pub auto_run_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Threshold {
pub value: f64,
pub color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Binding {
pub trigger: String,
#[serde(default)]
pub debounce_ms: Option<u32>,
pub actions: Vec<BindingAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BindingAction {
pub target: String,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub input: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneTheme {
#[serde(default)]
pub preset: Option<String>,
#[serde(default)]
pub custom: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Permissions {
#[serde(default)]
pub network: Vec<String>,
#[serde(default)]
pub filesystem: Vec<String>,
#[serde(default)]
pub clipboard: bool,
#[serde(default)]
pub camera: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderFooter {
#[serde(default = "default_header_height")]
pub height: u32,
#[serde(default)]
pub background: Option<String>,
#[serde(default)]
pub content: HeaderContent,
}
fn default_header_height() -> u32 {
48
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HeaderContent {
#[serde(default)]
pub left: Vec<ContentItem>,
#[serde(default)]
pub center: Vec<ContentItem>,
#[serde(default)]
pub right: Vec<ContentItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentItem {
#[serde(rename = "type")]
pub item_type: String,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub style: Option<String>,
#[serde(default)]
pub items: Vec<NavItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NavItem {
pub label: String,
pub href: String,
#[serde(default)]
pub external: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyBindings {
#[serde(default = "default_prefix_key")]
pub prefix_key: String,
#[serde(default = "default_prefix_timeout")]
pub prefix_timeout_ms: u32,
#[serde(default)]
pub sequences: Vec<KeySequence>,
#[serde(default)]
pub global: Vec<GlobalKeyBinding>,
}
fn default_prefix_key() -> String {
"ctrl+b".into()
}
fn default_prefix_timeout() -> u32 {
500
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeySequence {
pub keys: Vec<String>,
pub action: serde_yaml_ng::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalKeyBinding {
pub key: String,
pub action: serde_yaml_ng::Value,
}
#[derive(Debug)]
pub enum SceneError {
Yaml(serde_yaml_ng::Error),
InvalidVersion(String),
DuplicateWidgetId(String),
InvalidBindingTarget {
trigger: String,
target: String,
},
InvalidHashFormat {
resource: String,
hash: String,
},
MissingRemoteHash {
resource: String,
},
InvalidExpression {
context: String,
expression: String,
message: String,
},
InvalidMetadataName(String),
LayoutError(String),
}
impl fmt::Display for SceneError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Yaml(e) => write!(f, "YAML error: {e}"),
Self::InvalidVersion(v) => write!(f, "Invalid prs_version: {v}"),
Self::DuplicateWidgetId(id) => write!(f, "Duplicate widget id: {id}"),
Self::InvalidBindingTarget { trigger, target } => {
write!(
f,
"Invalid binding target '{target}' in trigger '{trigger}'"
)
}
Self::InvalidHashFormat { resource, hash } => {
write!(f, "Invalid hash format for '{resource}': {hash}")
}
Self::MissingRemoteHash { resource } => {
write!(f, "Missing hash for remote resource: {resource}")
}
Self::InvalidExpression {
context,
expression,
message,
} => {
write!(
f,
"Invalid expression in {context}: '{expression}' - {message}"
)
}
Self::InvalidMetadataName(name) => {
write!(f, "Invalid metadata name '{name}': must be kebab-case")
}
Self::LayoutError(msg) => write!(f, "Layout error: {msg}"),
}
}
}
impl std::error::Error for SceneError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Yaml(e) => Some(e),
_ => None,
}
}
}
impl From<serde_yaml_ng::Error> for SceneError {
fn from(e: serde_yaml_ng::Error) -> Self {
Self::Yaml(e)
}
}
impl Scene {
pub fn from_yaml(yaml: &str) -> Result<Self, SceneError> {
let scene: Self = serde_yaml_ng::from_str(yaml)?;
scene.validate()?;
Ok(scene)
}
pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
serde_yaml_ng::to_string(self)
}
pub fn validate(&self) -> Result<(), SceneError> {
self.validate_version()?;
self.validate_metadata_name()?;
self.validate_widget_ids()?;
self.validate_bindings()?;
self.validate_resource_hashes()?;
self.validate_layout()?;
Ok(())
}
fn validate_version(&self) -> Result<(), SceneError> {
let parts: Vec<&str> = self.prs_version.split('.').collect();
if parts.len() != 2 {
return Err(SceneError::InvalidVersion(self.prs_version.clone()));
}
for part in parts {
if part.parse::<u32>().is_err() {
return Err(SceneError::InvalidVersion(self.prs_version.clone()));
}
}
Ok(())
}
fn validate_metadata_name(&self) -> Result<(), SceneError> {
let name = &self.metadata.name;
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SceneError::InvalidMetadataName(name.clone()));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(SceneError::InvalidMetadataName(name.clone()));
}
if name.contains("--") {
return Err(SceneError::InvalidMetadataName(name.clone()));
}
Ok(())
}
fn validate_widget_ids(&self) -> Result<(), SceneError> {
let mut seen = std::collections::HashSet::new();
for widget in &self.widgets {
if !seen.insert(&widget.id) {
return Err(SceneError::DuplicateWidgetId(widget.id.clone()));
}
}
Ok(())
}
fn validate_bindings(&self) -> Result<(), SceneError> {
let widget_ids: std::collections::HashSet<&str> =
self.widgets.iter().map(|w| w.id.as_str()).collect();
let model_ids: std::collections::HashSet<&str> =
self.resources.models.keys().map(String::as_str).collect();
for binding in &self.bindings {
for action in &binding.actions {
let target = &action.target;
if widget_ids.contains(target.as_str()) {
continue;
}
if let Some(model_name) = target.strip_prefix("inference.") {
if model_ids.contains(model_name) {
continue;
}
}
return Err(SceneError::InvalidBindingTarget {
trigger: binding.trigger.clone(),
target: target.clone(),
});
}
}
Ok(())
}
fn validate_resource_hashes(&self) -> Result<(), SceneError> {
for (name, resource) in &self.resources.models {
if is_remote_source(&resource.source) && resource.hash.is_none() {
return Err(SceneError::MissingRemoteHash {
resource: name.clone(),
});
}
if let Some(hash) = &resource.hash {
validate_hash_format(name, hash)?;
}
}
for (name, resource) in &self.resources.datasets {
if is_remote_source(&resource.source) && resource.hash.is_none() {
return Err(SceneError::MissingRemoteHash {
resource: name.clone(),
});
}
if let Some(hash) = &resource.hash {
validate_hash_format(name, hash)?;
}
}
Ok(())
}
fn validate_layout(&self) -> Result<(), SceneError> {
match self.layout.layout_type {
LayoutType::Grid => {
if self.layout.columns.is_none() {
return Err(SceneError::LayoutError(
"Grid layout requires 'columns' field".to_string(),
));
}
}
LayoutType::Absolute => {
if self.layout.width.is_none() || self.layout.height.is_none() {
return Err(SceneError::LayoutError(
"Absolute layout requires 'width' and 'height' fields".to_string(),
));
}
}
LayoutType::Flex => {
}
LayoutType::Tmux => {
}
}
Ok(())
}
#[must_use]
pub fn widget_ids(&self) -> Vec<&str> {
self.widgets.iter().map(|w| w.id.as_str()).collect()
}
#[must_use]
pub fn get_widget(&self, id: &str) -> Option<&SceneWidget> {
self.widgets.iter().find(|w| w.id == id)
}
#[must_use]
pub fn get_model(&self, name: &str) -> Option<&ModelResource> {
self.resources.models.get(name)
}
#[must_use]
pub fn get_dataset(&self, name: &str) -> Option<&DatasetResource> {
self.resources.datasets.get(name)
}
}
fn is_remote_source(source: &ResourceSource) -> bool {
source.sources().iter().any(|s| s.starts_with("https://"))
}
fn validate_hash_format(resource: &str, hash: &str) -> Result<(), SceneError> {
if let Some(hex) = hash.strip_prefix("blake3:") {
if hex.len() >= 12 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(());
}
}
Err(SceneError::InvalidHashFormat {
resource: resource.to_string(),
hash: hash.to_string(),
})
}
#[cfg(test)]
#[path = "scene_tests.rs"]
mod tests;