//! UI Schema Types for GUI Plugin System //! //! This module defines the declarative UI schema that plugins use to define //! their user interfaces. The schema is JSON-based and rendered by the host UI //! into Dioxus components. //! //! ## Security Model //! //! - No arbitrary code execution in UI - only declarative schema //! - All text content is sanitized through ammonia before rendering //! - URLs are validated against whitelist //! - Component tree depth limited to prevent abuse //! - Action execution goes through typed API layer //! //! ## Example Schema //! //! ```json //! { //! "id": "music-analyzer", //! "title": "Music Analysis", //! "route": "/plugins/music-analyzer", //! "icon": "music", //! "layout": { //! "type": "split", //! "sidebar": { //! "type": "list", //! "data": "playlists", //! "item_template": { "type": "text", "content": "title" } //! }, //! "main": { //! "type": "data_table", //! "columns": [ //! { "key": "title", "header": "Title" }, //! { "key": "duration", "header": "Duration" } //! ], //! "data": "tracks" //! } //! }, //! "data_sources": { //! "playlists": { "type": "endpoint", "path": "/api/v1/collections" } //! } //! } //! //! Note: expression values are `Expression::Path` strings, not mustache //! templates. A bare string like `"title"` resolves the `title` field in the //! current item context. Nested fields use dotted segments: `"artist.name"`. //! Array indices use the same notation: `"items.0.title"`. //! ``` use std::collections::HashMap; use serde::{Deserialize, Serialize}; use thiserror::Error; /// Maximum allowed depth for nested UI elements pub const MAX_ELEMENT_DEPTH: usize = 10; /// Maximum allowed width percentage pub const MAX_WIDTH_PERCENT: u32 = 100; /// Maximum allowed height in pixels pub const MAX_HEIGHT_PX: u32 = 10000; /// Schema validation and rendering errors #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum SchemaError { #[error("validation failed: {0}")] ValidationError(String), #[error("invalid data source: {0}")] InvalidDataSource(String), #[error("invalid expression: {0}")] InvalidExpression(String), #[error("depth limit exceeded: max is {MAX_ELEMENT_DEPTH}")] DepthLimitExceeded, #[error("unknown field: {0}")] UnknownField(String), #[error("required field missing: {0}")] MissingField(String), } /// Result type for schema operations pub type SchemaResult = Result; /// Root page definition for a plugin UI page /// /// This is the top-level structure that defines a complete plugin page. /// Each page has a unique route, title, and layout. /// /// # Example /// /// ```rust /// use pinakes_plugin_api::ui_schema::{DataSource, UiElement, UiPage}; /// /// let page = UiPage { /// id: "demo".to_string(), /// title: "Demo Page".to_string(), /// route: "/plugins/demo".to_string(), /// icon: Some("star".to_string()), /// root_element: UiElement::Container { /// children: vec![], /// gap: 16, /// padding: None, /// }, /// data_sources: Default::default(), /// actions: Default::default(), /// }; /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct UiPage { /// Unique identifier for this page (must be unique across all plugins) pub id: String, /// Display title for the page pub title: String, /// URL route for this page (e.g., "/plugins/my-plugin/page1") /// Must be unique across all pages pub route: String, /// Optional icon identifier (matches icon names in dioxus-free-icons) #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, /// Root layout element defining the page structure #[serde(rename = "layout")] pub root_element: UiElement, /// Named data sources available to this page #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub data_sources: HashMap, /// Named actions available to this page (referenced by `ActionRef::Name`) #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub actions: HashMap, } impl UiPage { /// Validates the complete page definition /// /// Checks: /// - ID format (alphanumeric with dashes/underscores) /// - Route format (must start with "/") /// - Element tree depth /// - Data source validity /// - All data references in elements exist in `data_sources` /// /// # Errors /// /// Returns `SchemaError::ValidationError` if validation fails pub fn validate(&self) -> SchemaResult<()> { validate_id(&self.id)?; if !self.route.starts_with('/') { return Err(SchemaError::ValidationError( "Route must start with '/'".to_string(), )); } if crate::validation::SchemaValidator::is_reserved_route(&self.route) { return Err(SchemaError::ValidationError(format!( "Route '{}' conflicts with a built-in app route", self.route ))); } let depth = self.root_element.depth(); if depth > MAX_ELEMENT_DEPTH { return Err(SchemaError::DepthLimitExceeded); } self.root_element.validate(self)?; for (name, action) in &self.actions { validate_id(name)?; action.validate()?; } for (name, source) in &self.data_sources { validate_id(name)?; source.validate()?; // Check that Transform sources reference existing data sources if let DataSource::Transform { source_name, .. } = source && !self.data_sources.contains_key(source_name) { return Err(SchemaError::ValidationError(format!( "DataSource '{name}' references unknown source: {source_name}" ))); } } // Detect cycles in Transform dependencies using DFS self.validate_no_cycles()?; Ok(()) } /// Validates that there are no cycles in Transform data source dependencies fn validate_no_cycles(&self) -> SchemaResult<()> { let mut visited = std::collections::HashSet::new(); let mut stack = std::collections::HashSet::new(); for name in self.data_sources.keys() { Self::dfs_check_cycles(self, name, &mut visited, &mut stack)?; } Ok(()) } /// Helper function for cycle detection using DFS fn dfs_check_cycles( &self, name: &str, visited: &mut std::collections::HashSet, stack: &mut std::collections::HashSet, ) -> SchemaResult<()> { if stack.contains(name) { return Err(SchemaError::ValidationError(format!( "Cycle detected in data source dependencies: {name}" ))); } if visited.contains(name) { return Ok(()); } stack.insert(name.to_string()); if let Some(DataSource::Transform { source_name, .. }) = self.data_sources.get(name) { Self::dfs_check_cycles(self, source_name, visited, stack)?; } stack.remove(name); visited.insert(name.to_string()); Ok(()) } /// Returns all data source names referenced by the layout #[must_use] pub fn referenced_data_sources(&self) -> Vec { let mut refs = Vec::new(); self.root_element.collect_data_refs(&mut refs); refs.sort_unstable(); refs.dedup(); refs } } /// A widget that plugins can inject into existing host pages. /// /// Widgets differ from pages in that they are embedded at a specific /// `target` location within built-in views rather than occupying a full page. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct UiWidget { /// Unique identifier for this widget within the plugin pub id: String, /// Target injection point (matches `widget_location` constants) pub target: String, /// Content to render at the injection point pub content: UiElement, } impl UiWidget { /// Validates this widget definition /// /// # Errors /// /// Returns `SchemaError::ValidationError` if validation fails pub fn validate(&self) -> SchemaResult<()> { if self.target.is_empty() { return Err(SchemaError::ValidationError( "Widget target cannot be empty".to_string(), )); } validate_id(&self.id)?; Ok(()) } } /// String constants for widget injection locations. /// /// Use these with `UiWidget::target` in plugin manifests: /// ```toml /// [[ui.widgets]] /// id = "my-widget" /// target = "library_header" /// ``` pub mod widget_location { pub const LIBRARY_HEADER: &str = "library_header"; pub const LIBRARY_SIDEBAR: &str = "library_sidebar"; pub const DETAIL_PANEL: &str = "detail_panel"; pub const SEARCH_FILTERS: &str = "search_filters"; pub const SETTINGS_SECTION: &str = "settings_section"; } /// Core UI element enum - the building block of all plugin UIs /// /// Elements are categorized into groups: /// - **Layout containers**: Structure the page (Container, Grid, Flex, Split, /// Tabs) /// - **Typography**: Text display (Heading, Text, Code) /// - **Data display**: Show data (`DataTable`, Card, `MediaGrid`, List, /// `DescriptionList`) /// - **Interactive**: User input (Button, Form, Link, Progress, Badge) /// - **Visualization**: Charts and graphs (Chart) /// - **Conditional**: Dynamic rendering (Conditional, Loop) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum UiElement { /// Generic container with configurable layout /// /// Use for grouping elements with gap and padding control. Container { /// Child elements children: Vec, /// Gap between children in pixels #[serde(default = "default_gap")] gap: u32, /// Padding inside container (top, right, bottom, left) #[serde(skip_serializing_if = "Option::is_none")] padding: Option<[u32; 4]>, }, /// Grid layout with fixed columns /// /// Arranges children in a grid with configurable column count. Grid { /// Child elements children: Vec, /// Number of columns (1-12) #[serde(default = "default_grid_columns")] columns: u8, /// Gap between grid items in pixels #[serde(default = "default_gap")] gap: u32, }, /// Horizontal or vertical flex layout /// /// Use for linear arrangements with alignment control. Flex { /// Child elements children: Vec, /// Direction: "row" (horizontal) or "column" (vertical) #[serde(default)] direction: FlexDirection, /// Main axis alignment #[serde(default)] justify: JustifyContent, /// Cross axis alignment #[serde(default)] align: AlignItems, /// Gap between items in pixels #[serde(default = "default_gap")] gap: u32, /// Whether items should wrap to next line #[serde(default)] wrap: bool, }, /// Split view with sidebar and main content /// /// Classic sidebar + content layout with resizable divider. Split { /// Sidebar content sidebar: Box, /// Sidebar width in pixels #[serde(default = "default_sidebar_width")] sidebar_width: u32, /// Main content area main: Box, }, /// Tabbed interface /// /// Displays content in tabbed panels. Tabs { /// Tab definitions tabs: Vec, /// Initially active tab index #[serde(default)] default_tab: usize, }, /// Section heading (h1-h6) /// /// Semantic heading for page structure. Heading { /// Heading level 1-6 level: u8, /// Heading text (can be static or expression) #[serde(default)] content: TextContent, /// Optional ID for linking #[serde(skip_serializing_if = "Option::is_none")] id: Option, }, /// Paragraph text /// /// Multi-line text with optional formatting. Text { /// Text content (can be static or expression) #[serde(default)] content: TextContent, /// Text style variant #[serde(default)] variant: TextVariant, /// Whether to allow HTML (sanitized) in content #[serde(default)] allow_html: bool, }, /// Monospaced code block /// /// Displays code with syntax highlighting support. Code { /// Code content content: String, /// Language for syntax highlighting (e.g., "rust", "json") #[serde(skip_serializing_if = "Option::is_none")] language: Option, /// Whether to show line numbers #[serde(default)] show_line_numbers: bool, }, /// Data table with sortable columns /// /// Displays tabular data with sorting, filtering, and pagination. DataTable { /// Column definitions columns: Vec, /// Data source reference (key into `page.data_sources`) data: String, /// Whether to enable sorting #[serde(default = "default_true")] sortable: bool, /// Whether to enable filtering #[serde(default)] filterable: bool, /// Items per page (0 = no pagination) #[serde(default)] page_size: usize, /// Row actions (displayed as buttons per row) #[serde(default, skip_serializing_if = "Vec::is_empty")] row_actions: Vec, }, /// Card component for content grouping /// /// Displays content in a card with optional header and footer. Card { /// Card title #[serde(skip_serializing_if = "Option::is_none")] title: Option, /// Main content elements content: Vec, /// Footer elements #[serde(default, skip_serializing_if = "Vec::is_empty")] footer: Vec, }, /// Media grid for images/videos /// /// Displays media items in a responsive grid. MediaGrid { /// Data source reference containing media items data: String, /// Number of columns (1-12) #[serde(default = "default_grid_columns")] columns: u8, /// Gap between items in pixels #[serde(default = "default_gap")] gap: u32, }, /// List component with item template /// /// Renders a list by applying a template to each item. List { /// Data source reference data: String, /// Template for each list item item_template: Box, /// Whether to show dividers between items #[serde(default = "default_true")] dividers: bool, }, /// Description list (key-value pairs) /// /// Displays key-value pairs, ideal for metadata. DescriptionList { /// Data source reference containing key-value pairs data: String, /// Whether to display horizontally #[serde(default)] horizontal: bool, }, /// Button component /// /// Triggers actions when clicked. Button { /// Button label label: String, /// Button variant/style #[serde(default)] variant: ButtonVariant, /// Action to execute on click action: ActionRef, /// Whether button is disabled #[serde(default)] disabled: bool, }, /// Form with fields /// /// Input form with validation and submission. Form { /// Form field definitions fields: Vec, /// Submit button label #[serde(default = "default_submit_label")] submit_label: String, /// Action to execute on submit submit_action: ActionRef, /// Cancel button label (None = no cancel button) #[serde(skip_serializing_if = "Option::is_none")] cancel_label: Option, }, /// Link component /// /// Navigates to internal or external URLs. Link { /// Link text text: String, /// Target URL href: String, /// Whether to open in new tab #[serde(default)] external: bool, }, /// Progress bar or indicator /// /// Shows progress of an operation. Progress { /// Current value expression value: Expression, /// Maximum value #[serde(default = "default_progress_max")] max: f64, /// Whether to show percentage text #[serde(default = "default_true")] show_percentage: bool, }, /// Badge/chip component /// /// Small label for status, tags, etc. Badge { /// Badge text text: String, /// Badge color variant #[serde(default)] variant: BadgeVariant, }, /// Chart visualization /// /// Displays data as charts (bar, line, pie, etc.). Chart { /// Chart type chart_type: ChartType, /// Data source reference data: String, /// Chart title #[serde(skip_serializing_if = "Option::is_none")] title: Option, /// X-axis label #[serde(skip_serializing_if = "Option::is_none")] x_axis_label: Option, /// Y-axis label #[serde(skip_serializing_if = "Option::is_none")] y_axis_label: Option, /// Chart height in pixels #[serde(default = "default_chart_height")] height: u32, }, /// Conditional rendering /// /// Shows different content based on a condition. Conditional { /// Condition expression (evaluates to boolean) condition: Expression, /// Element to render when condition is true then: Box, /// Optional element to render when condition is false #[serde(rename = "else", skip_serializing_if = "Option::is_none")] else_element: Option>, }, /// Loop/repeat over data /// /// Renders a template for each item in a collection. Loop { /// Data source reference (must be an array) data: String, /// Template for each iteration template: Box, /// Content to show when data is empty #[serde(skip_serializing_if = "Option::is_none")] empty: Option>, }, } impl UiElement { /// Calculates the depth of this element tree pub fn depth(&self) -> usize { 1 + match self { Self::Container { children, .. } | Self::Grid { children, .. } | Self::Flex { children, .. } => { children.iter().map(Self::depth).max().unwrap_or(0) }, Self::Split { sidebar, main, .. } => sidebar.depth().max(main.depth()), Self::Tabs { tabs, .. } => { tabs.iter().map(|t| t.content.depth()).max().unwrap_or(0) }, Self::Card { content, footer, .. } => { content .iter() .chain(footer.iter()) .map(Self::depth) .max() .unwrap_or(0) }, Self::List { item_template, .. } => item_template.depth(), Self::Conditional { then, else_element, .. } => { let else_depth = else_element.as_ref().map_or(0, |e| e.depth()); then.depth().max(else_depth) }, Self::Loop { template, empty, .. } => { let empty_depth = empty.as_ref().map_or(0, |e| e.depth()); template.depth().max(empty_depth) }, _ => 0, } } /// Validates this element and its children /// /// # Errors /// /// Returns `SchemaError` if validation fails pub fn validate(&self, page: &UiPage) -> SchemaResult<()> { match self { Self::Container { children, .. } | Self::Flex { children, .. } => { for child in children { child.validate(page)?; } }, Self::Grid { children, columns, .. } => { if *columns == 0 || *columns > 12 { return Err(SchemaError::ValidationError(format!( "Grid columns must be between 1 and 12, got {columns}" ))); } for child in children { child.validate(page)?; } }, Self::Split { sidebar, main, .. } => { sidebar.validate(page)?; main.validate(page)?; }, Self::Tabs { tabs, default_tab, .. } => { if tabs.is_empty() { return Err(SchemaError::ValidationError( "Tabs array must contain at least one tab".to_string(), )); } if *default_tab >= tabs.len() { return Err(SchemaError::ValidationError(format!( "Default tab index {default_tab} out of range (max: {})", tabs.len().saturating_sub(1) ))); } for tab in tabs { tab.content.validate(page)?; } }, Self::Heading { level, .. } if *level == 0 || *level > 6 => { return Err(SchemaError::ValidationError(format!( "Heading level must be between 1 and 6, got {level}" ))); }, Self::DataTable { columns, data, .. } => { if columns.is_empty() { return Err(SchemaError::ValidationError( "DataTable must have at least one column".to_string(), )); } if !page.data_sources.contains_key(data) { return Err(SchemaError::ValidationError(format!( "DataTable references unknown data source: {data}" ))); } }, Self::Card { content, footer, .. } => { for child in content { child.validate(page)?; } for child in footer { child.validate(page)?; } }, Self::MediaGrid { columns, data, .. } => { if *columns == 0 || *columns > 12 { return Err(SchemaError::ValidationError(format!( "MediaGrid columns must be between 1 and 12, got {columns}" ))); } if !page.data_sources.contains_key(data) { return Err(SchemaError::ValidationError(format!( "MediaGrid references unknown data source: {data}" ))); } }, Self::Chart { data, .. } if !page.data_sources.contains_key(data) => { return Err(SchemaError::ValidationError(format!( "Chart references unknown data source: {data}" ))); }, Self::Conditional { then, else_element, .. } => { then.validate(page)?; if let Some(else_el) = else_element { else_el.validate(page)?; } }, Self::List { data, item_template, .. } => { if !page.data_sources.contains_key(data) { return Err(SchemaError::ValidationError(format!( "List references unknown data source: {data}" ))); } item_template.validate(page)?; }, Self::DescriptionList { data, .. } if !page.data_sources.contains_key(data) => { return Err(SchemaError::ValidationError(format!( "DescriptionList references unknown data source: {data}" ))); }, Self::Loop { template, empty, data, .. } => { if !page.data_sources.contains_key(data) { return Err(SchemaError::ValidationError(format!( "Loop references unknown data source: {data}" ))); } template.validate(page)?; if let Some(empty_el) = empty { empty_el.validate(page)?; } }, Self::Button { action, .. } => { action.validate()?; }, Self::Link { href, .. } if !is_safe_href(href) => { return Err(SchemaError::ValidationError(format!( "Link href has a disallowed scheme (must be '/', 'http://', or 'https://'): {href}" ))); }, Self::Form { fields, submit_action, .. } => { if fields.is_empty() { return Err(SchemaError::ValidationError( "Form must have at least one field".to_string(), )); } for field in fields { validate_id(&field.id)?; if field.label.is_empty() { return Err(SchemaError::ValidationError(format!( "Form field '{}' must have a label", field.id ))); } } submit_action.validate()?; }, _ => {}, } Ok(()) } /// Collects all data source references from this element fn collect_data_refs(&self, refs: &mut Vec) { match self { Self::Container { children, .. } | Self::Grid { children, .. } | Self::Flex { children, .. } => { for child in children { child.collect_data_refs(refs); } }, Self::Split { sidebar, main, .. } => { sidebar.collect_data_refs(refs); main.collect_data_refs(refs); }, Self::Tabs { tabs, .. } => { for tab in tabs { tab.content.collect_data_refs(refs); } }, Self::Card { content, footer, .. } => { for child in content { child.collect_data_refs(refs); } for child in footer { child.collect_data_refs(refs); } }, Self::DataTable { data, .. } | Self::MediaGrid { data, .. } | Self::DescriptionList { data, .. } | Self::Chart { data, .. } => { refs.push(data.clone()); }, Self::List { data, item_template, .. } => { refs.push(data.clone()); item_template.collect_data_refs(refs); }, Self::Loop { data, template, empty, .. } => { refs.push(data.clone()); template.collect_data_refs(refs); if let Some(empty_el) = empty { empty_el.collect_data_refs(refs); } }, Self::Conditional { then, else_element, .. } => { then.collect_data_refs(refs); if let Some(else_el) = else_element { else_el.collect_data_refs(refs); } }, _ => {}, } } } /// Flex direction for Flex layout #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum FlexDirection { /// Horizontal layout (left to right) #[default] Row, /// Vertical layout (top to bottom) Column, } /// Justify content alignment for Flex layout #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "kebab-case")] pub enum JustifyContent { /// Items packed to start #[default] FlexStart, /// Items packed to end FlexEnd, /// Items centered Center, /// Items evenly distributed with space between SpaceBetween, /// Items evenly distributed with space around SpaceAround, /// Items evenly distributed with equal space SpaceEvenly, } /// Align items for Flex layout (cross-axis alignment) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "kebab-case")] pub enum AlignItems { /// Items aligned to start #[default] FlexStart, /// Items aligned to end FlexEnd, /// Items centered Center, /// Items stretched to fill Stretch, /// Items aligned to baseline Baseline, } /// Text content can be static or dynamic #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(untagged)] pub enum TextContent { /// Static text string Static(String), /// Dynamic expression that evaluates to text Expression(Expression), /// Default (empty text) #[default] Empty, } impl TextContent { /// Returns true if content is empty #[must_use] pub const fn is_empty(&self) -> bool { matches!(self, Self::Empty) } } impl From for TextContent { fn from(s: String) -> Self { Self::Static(s) } } impl From<&str> for TextContent { fn from(s: &str) -> Self { Self::Static(s.to_string()) } } impl From for TextContent { fn from(expr: Expression) -> Self { Self::Expression(expr) } } /// Text style variants #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum TextVariant { /// Default body text #[default] Body, /// Secondary/muted text Secondary, /// Success text (green) Success, /// Warning text (yellow/orange) Warning, /// Error text (red) Error, /// Bold text Bold, /// Italic text Italic, /// Small/caption text Small, /// Large/lead text Large, } /// Column definition for `DataTable` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ColumnDef { /// Data key for this column pub key: String, /// Display header text pub header: String, /// Column width (e.g., "100px", "20%", or "auto") #[serde(skip_serializing_if = "Option::is_none")] pub width: Option, /// Whether column is sortable #[serde(default = "default_true")] pub sortable: bool, /// Data type for formatting (affects sorting and display) #[serde(default)] pub data_type: ColumnDataType, /// Custom format string (e.g., for dates: "YYYY-MM-DD") #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, } /// Row action for `DataTable` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RowAction { /// Action identifier (unique within this table) pub id: String, /// Display label pub label: String, /// Action to execute pub action: ActionRef, /// Button variant #[serde(default)] pub variant: ButtonVariant, } /// Data type for column formatting #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum ColumnDataType { /// Generic text #[default] Text, /// Numeric value Number, /// Date/time DateTime, /// Boolean (shows as checkmark/x) Boolean, /// File size (auto-formatted) FileSize, /// Duration in seconds (formatted as HH:MM:SS) Duration, } /// Tab definition for Tabs component #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TabDefinition { /// Tab label pub label: String, /// Tab icon (optional) #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, /// Tab content pub content: UiElement, } /// Button style variants #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum ButtonVariant { /// Primary action button (filled) #[default] Primary, /// Secondary button (outlined) Secondary, /// Tertiary button (text only) Tertiary, /// Danger/destructive action Danger, /// Success/confirm action Success, /// Ghost/invisible button Ghost, } /// Form field definition #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FormField { /// Field identifier (form data key) pub id: String, /// Field label pub label: String, /// Field type #[serde(rename = "type")] pub field_type: FieldType, /// Whether field is required #[serde(default)] pub required: bool, /// Placeholder text (for text inputs) #[serde(skip_serializing_if = "Option::is_none")] pub placeholder: Option, /// Default value #[serde(skip_serializing_if = "Option::is_none")] pub default_value: Option, /// Validation rules #[serde(default, skip_serializing_if = "Vec::is_empty")] pub validation: Vec, /// Help text displayed below field #[serde(skip_serializing_if = "Option::is_none")] pub help_text: Option, } /// Form field types #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum FieldType { /// Single-line text input Text { /// Maximum length #[serde(skip_serializing_if = "Option::is_none")] max_length: Option, }, /// Multi-line text area Textarea { /// Number of rows #[serde(default = "default_textarea_rows")] rows: u32, }, /// Email input with validation Email, /// URL input with validation Url, /// Number input Number { /// Minimum value #[serde(skip_serializing_if = "Option::is_none")] min: Option, /// Maximum value #[serde(skip_serializing_if = "Option::is_none")] max: Option, /// Step increment #[serde(skip_serializing_if = "Option::is_none")] step: Option, }, /// Toggle switch Switch, /// Checkbox Checkbox { /// Checkbox label (separate from field label) #[serde(skip_serializing_if = "Option::is_none")] checkbox_label: Option, }, /// Select dropdown Select { /// Available options options: Vec, /// Whether multiple selection is allowed #[serde(default)] multiple: bool, }, /// Radio button group Radio { /// Available options options: Vec, }, /// Date picker Date, /// `DateTime` picker DateTime, /// File upload File { /// Accepted MIME types (e.g., [`"image/*"`, `"application/pdf"`]) #[serde(skip_serializing_if = "Option::is_none")] accept: Option>, /// Maximum file size in bytes #[serde(skip_serializing_if = "Option::is_none")] max_size: Option, /// Whether multiple files allowed #[serde(default)] multiple: bool, }, } /// Select/Radio option #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SelectOption { /// Option value (stored in form data) pub value: String, /// Display label pub label: String, } /// Validation rules for form fields #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ValidationRule { /// Minimum length (for text) MinLength { value: usize }, /// Maximum length (for text) MaxLength { value: usize }, /// Pattern match (regex) Pattern { regex: String }, /// Custom validation message Custom { message: String }, } /// Badge color variants #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum BadgeVariant { /// Default/neutral #[default] Default, /// Primary color Primary, /// Secondary color Secondary, /// Success/green Success, /// Warning/yellow Warning, /// Error/red Error, /// Info/blue Info, } /// Chart types #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ChartType { /// Bar chart Bar, /// Line chart Line, /// Pie/donut chart Pie, /// Area chart Area, /// Scatter plot Scatter, } /// Client-side action types that do not require an HTTP call. /// /// Used as `{"action": "", ...}` in JSON. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "action", rename_all = "snake_case")] pub enum SpecialAction { /// Trigger a data refresh (re-runs all data sources for the current page). Refresh, /// Navigate to a different route. Navigate { /// Target route path (must start with `/`) to: String, }, /// Emit a named event to the server-side plugin event bus. Emit { /// Event name event: String, /// Optional payload (any JSON value) #[serde(default)] payload: serde_json::Value, }, /// Update a local state key (resolved against the current data context). UpdateState { /// State key name key: String, /// Expression whose value is stored at `key` value: Expression, }, /// Open a modal overlay containing the given element. OpenModal { /// Element to render inside the modal content: Box, }, /// Close the currently open modal overlay. CloseModal, } /// Action reference - identifies an action to execute /// /// Deserialization order for `#[serde(untagged)]`: /// 1. `Special` - JSON objects with an `"action"` string key /// 2. `Inline` - JSON objects with a `"path"` key /// 3. `Name` - bare JSON strings #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(untagged)] pub enum ActionRef { /// Client-side special action (no HTTP call required) Special(SpecialAction), /// Inline action definition (HTTP call) Inline(ActionDefinition), /// Simple action name (references page.actions) Name(String), } impl ActionRef { /// Validates the action reference /// /// For inline actions, validates the `ActionDefinition`. /// For named actions, validates the name is a valid identifier. /// /// # Errors /// /// Returns `SchemaError::ValidationError` if validation fails. pub fn validate(&self) -> SchemaResult<()> { match self { Self::Special(s) => { match s { SpecialAction::Navigate { to } if to.is_empty() => { return Err(SchemaError::ValidationError( "Navigate.to cannot be empty".to_string(), )); }, SpecialAction::UpdateState { key, .. } if key.is_empty() => { return Err(SchemaError::ValidationError( "UpdateState.key cannot be empty".to_string(), )); }, SpecialAction::Emit { event, .. } if event.is_empty() => { return Err(SchemaError::ValidationError( "Emit.event cannot be empty".to_string(), )); }, _ => {}, } }, Self::Name(name) => { if name.is_empty() { return Err(SchemaError::ValidationError( "Action name cannot be empty".to_string(), )); } validate_id(name)?; }, Self::Inline(action) => { action.validate()?; }, } Ok(()) } } /// Action definition #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ActionDefinition { /// HTTP method #[serde(default = "default_http_method")] pub method: HttpMethod, /// API endpoint path (must start with '/') pub path: String, /// Action parameters (merged with form data on submit) #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub params: HashMap, /// Success message #[serde(skip_serializing_if = "Option::is_none")] pub success_message: Option, /// Error message #[serde(skip_serializing_if = "Option::is_none")] pub error_message: Option, /// Navigation after success (route to navigate to) #[serde(skip_serializing_if = "Option::is_none")] pub navigate_to: Option, } impl ActionDefinition { /// Validates the action definition /// /// Checks: /// - Path is non-empty and starts with '/' /// /// # Errors /// /// Returns `SchemaError::ValidationError` if validation fails. pub fn validate(&self) -> SchemaResult<()> { if self.path.is_empty() { return Err(SchemaError::ValidationError( "Action path cannot be empty".to_string(), )); } if !self.path.starts_with('/') { return Err(SchemaError::ValidationError(format!( "Action path must start with '/': {}", self.path ))); } if !self.path.starts_with("/api/") { return Err(SchemaError::ValidationError(format!( "Action path must start with '/api/': {}", self.path ))); } if self.path.contains("..") { return Err(SchemaError::ValidationError(format!( "Action path contains invalid traversal sequence: {}", self.path ))); } Ok(()) } } impl Default for ActionDefinition { fn default() -> Self { Self { method: default_http_method(), path: String::new(), params: HashMap::new(), success_message: None, error_message: None, navigate_to: None, } } } /// HTTP methods for actions #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "UPPERCASE")] pub enum HttpMethod { #[default] Get, Post, Put, Patch, Delete, } /// Data source for dynamic content #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum DataSource { /// API endpoint Endpoint { /// HTTP method #[serde(default = "default_http_method")] method: HttpMethod, /// API path (relative to base URL) path: String, /// Query parameters #[serde(default, skip_serializing_if = "HashMap::is_empty")] params: HashMap, /// Polling interval in seconds (0 = no polling) #[serde(default)] poll_interval: u64, /// Transform expression applied to response #[serde(skip_serializing_if = "Option::is_none")] transform: Option, }, /// Transform of another data source Transform { /// Source data reference #[serde(rename = "source")] source_name: String, /// Transform expression expression: Expression, }, /// Static data Static { /// Static JSON value value: serde_json::Value, }, } impl DataSource { /// Validates this data source /// /// # Errors /// /// Returns `SchemaError::InvalidDataSource` if validation fails pub fn validate(&self) -> SchemaResult<()> { match self { Self::Endpoint { path, .. } => { if !path.starts_with('/') { return Err(SchemaError::InvalidDataSource(format!( "Endpoint path must start with '/': {path}" ))); } if !path.starts_with("/api/") { return Err(SchemaError::InvalidDataSource(format!( "Endpoint path must start with '/api/': {path}" ))); } if path.contains("..") { return Err(SchemaError::InvalidDataSource(format!( "Endpoint path contains invalid traversal sequence: {path}" ))); } }, Self::Transform { source_name, .. } => { validate_id(source_name)?; }, Self::Static { .. } => {}, } Ok(()) } } /// Expression for dynamic value evaluation /// /// Expressions use JSONPath-like syntax for data access. /// /// ## JSON representation (serde untagged; order matters) /// /// Variants are tried in declaration order during deserialization: /// /// | JSON shape | Deserializes as | /// |---------------------------------------------------|-----------------| /// | `"users.0.name"` (string) | `Path` | /// | `{"left":…,"op":"eq","right":…}` (object) | `Operation` | /// | `{"function":"len","args":[…]}` (object) | `Call` | /// | `42`, `true`, `null`, `[…]`, `{other fields}` … | `Literal` | /// /// `Literal` is intentionally last so that the more specific variants take /// priority. A bare JSON string is always a **path reference**; to embed a /// literal string value use `DataSource::Static` or a `Call` expression. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(untagged)] pub enum Expression { /// Data path reference: a dotted key sequence walked against the context. /// /// e.g. `"user.name"` resolves to `ctx["user"]["name"]`; `"items.0"` resolves /// to the first element. Path(String), /// Binary operation applied to two sub-expressions. Operation { /// Left operand left: Box, /// Operator op: Operator, /// Right operand right: Box, }, /// Built-in function call. /// /// e.g. `{"function": "len", "args": ["tags"]}` returns the count of items /// in the `tags` data source. Call { /// Function name (see built-in function table in docs) function: String, /// Positional arguments, each an `Expression` args: Vec, }, /// Literal JSON value: a constant that is returned unchanged. /// /// Matches numbers, booleans, null, arrays, and objects that do not match /// the `Operation` or `Call` shapes above. Literal(serde_json::Value), } impl Default for Expression { fn default() -> Self { Self::Literal(serde_json::Value::Null) } } /// Binary operators #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum Operator { /// Equal Eq, /// Not equal Ne, /// Greater than Gt, /// Greater than or equal Gte, /// Less than Lt, /// Less than or equal Lte, /// Logical and And, /// Logical or Or, /// String concatenation Concat, /// Addition Add, /// Subtraction Sub, /// Multiplication Mul, /// Division Div, } // Helper functions for defaults const fn default_gap() -> u32 { 16 } const fn default_sidebar_width() -> u32 { 280 } const fn default_grid_columns() -> u8 { 4 } const fn default_true() -> bool { true } const fn default_textarea_rows() -> u32 { 3 } const fn default_chart_height() -> u32 { 300 } const fn default_progress_max() -> f64 { 100.0 } fn default_submit_label() -> String { "Submit".to_string() } const fn default_http_method() -> HttpMethod { HttpMethod::Get } /// Returns `true` if `href` uses a scheme safe to render in an anchor element. /// /// Allows relative paths (`/`), plain `http://`, and `https://`. Rejects /// `javascript:`, `data:`, `vbscript:`, and any other scheme that could be /// used for script injection or data exfiltration. #[must_use] pub fn is_safe_href(href: &str) -> bool { href.starts_with('/') || href.starts_with("https://") || href.starts_with("http://") } /// Validates an identifier string /// /// IDs must: /// - Start with a letter /// - Contain only alphanumeric characters, underscores, and dashes /// - Be between 1 and 64 characters /// /// # Errors /// /// Returns `SchemaError::ValidationError` if the ID is invalid fn validate_id(id: &str) -> SchemaResult<()> { if id.is_empty() { return Err(SchemaError::ValidationError( "ID cannot be empty".to_string(), )); } if id.len() > 64 { return Err(SchemaError::ValidationError(format!( "ID exceeds maximum length of 64: {id}" ))); } let first_char = id.chars().next(); if first_char.is_none_or(|c| !c.is_ascii_alphabetic()) { return Err(SchemaError::ValidationError(format!( "ID must start with a letter: {id}" ))); } if !id .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { return Err(SchemaError::ValidationError(format!( "ID contains invalid characters: {id}" ))); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_ui_element_depth() { let element = UiElement::Container { children: vec![UiElement::Container { children: vec![UiElement::Text { content: TextContent::Static("test".to_string()), variant: TextVariant::default(), allow_html: false, }], gap: 16, padding: None, }], gap: 16, padding: None, }; assert_eq!(element.depth(), 3); } #[test] fn test_validate_id() { assert!(validate_id("valid-id").is_ok()); assert!(validate_id("valid_id_123").is_ok()); assert!(validate_id("ValidId").is_ok()); assert!(validate_id("").is_err()); assert!(validate_id("123-start").is_err()); assert!(validate_id("invalid.id").is_err()); assert!(validate_id("a".repeat(65).as_str()).is_err()); } #[test] fn test_column_data_type_default() { let column: ColumnDef = serde_json::from_str(r#"{"key": "name", "header": "Name"}"#).unwrap(); assert_eq!(column.data_type, ColumnDataType::Text); assert!(column.sortable); } #[test] fn test_data_source_endpoint_validation() { let valid = DataSource::Endpoint { method: HttpMethod::Get, path: "/api/test".to_string(), params: HashMap::new(), poll_interval: 0, transform: None, }; assert!(valid.validate().is_ok()); let invalid = DataSource::Endpoint { method: HttpMethod::Get, path: "api/test".to_string(), params: HashMap::new(), poll_interval: 0, transform: None, }; assert!(invalid.validate().is_err()); } #[test] fn test_text_content_from_string() { let content: TextContent = "hello".into(); assert_eq!(content, TextContent::Static("hello".to_string())); } #[test] fn test_expression_default() { let expr = Expression::default(); assert_eq!(expr, Expression::Literal(serde_json::Value::Null)); } #[test] fn test_action_definition_default() { let action = ActionDefinition::default(); assert_eq!(action.method, HttpMethod::Get); assert!(action.path.is_empty()); } #[test] fn test_data_source_serialization() { let source = DataSource::Static { value: serde_json::json!({"key": "value"}), }; let json = serde_json::to_string(&source).unwrap(); assert!(json.contains("static")); } #[test] fn test_ui_page_referenced_data_sources() { let page = UiPage { id: "test".to_string(), title: "Test".to_string(), route: "/test".to_string(), icon: None, root_element: UiElement::DataTable { columns: vec![], data: "items".to_string(), sortable: true, filterable: false, page_size: 0, row_actions: vec![], }, data_sources: HashMap::new(), actions: HashMap::new(), }; let refs = page.referenced_data_sources(); assert_eq!(refs, vec!["items"]); } #[test] fn test_grid_validation() { let page = UiPage { id: "test".to_string(), title: "Test".to_string(), route: "/test".to_string(), icon: None, root_element: UiElement::Grid { children: vec![], columns: 13, gap: 16, }, data_sources: HashMap::new(), actions: HashMap::new(), }; assert!(page.validate().is_err()); } #[test] fn test_heading_validation() { let page = UiPage { id: "test".to_string(), title: "Test".to_string(), route: "/test".to_string(), icon: None, root_element: UiElement::Heading { level: 7, content: TextContent::Static("Title".to_string()), id: None, }, data_sources: HashMap::new(), actions: HashMap::new(), }; assert!(page.validate().is_err()); } // Expression JSON round-trip tests /// A JSON string must deserialise as Path, not Literal. #[test] fn test_expression_string_deserialises_as_path() { let expr: Expression = serde_json::from_str(r#""user.name""#).unwrap(); assert_eq!(expr, Expression::Path("user.name".to_string())); } /// A JSON number must deserialise as Literal, not Path. #[test] fn test_expression_number_deserialises_as_literal() { let expr: Expression = serde_json::from_str("42").unwrap(); assert_eq!(expr, Expression::Literal(serde_json::json!(42))); } /// An Operation object is correctly deserialised. #[test] fn test_expression_operation_deserialises() { let json = r#"{"left": "count", "op": "gt", "right": 0}"#; let expr: Expression = serde_json::from_str(json).unwrap(); match expr { Expression::Operation { left, op, right } => { assert_eq!(*left, Expression::Path("count".to_string())); assert_eq!(op, Operator::Gt); assert_eq!(*right, Expression::Literal(serde_json::json!(0))); }, other => panic!("expected Operation, got {other:?}"), } } /// A Call object is correctly deserialised. #[test] fn test_expression_call_deserialises() { let json = r#"{"function": "len", "args": ["items"]}"#; let expr: Expression = serde_json::from_str(json).unwrap(); match expr { Expression::Call { function, args } => { assert_eq!(function, "len"); assert_eq!(args, vec![Expression::Path("items".to_string())]); }, other => panic!("expected Call, got {other:?}"), } } /// Path expressions survive a full JSON round-trip. #[test] fn test_expression_path_round_trip() { let original = Expression::Path("a.b.c".to_string()); let json = serde_json::to_string(&original).unwrap(); let recovered: Expression = serde_json::from_str(&json).unwrap(); assert_eq!(original, recovered); } // DataSource/ActionDefinition security validation tests #[test] fn test_endpoint_path_must_start_with_api() { let bad = DataSource::Endpoint { method: HttpMethod::Get, path: "/not-api/something".to_string(), params: HashMap::new(), poll_interval: 0, transform: None, }; assert!(bad.validate().is_err()); } #[test] fn test_endpoint_path_rejects_traversal() { let bad = DataSource::Endpoint { method: HttpMethod::Get, path: "/api/v1/../admin".to_string(), params: HashMap::new(), poll_interval: 0, transform: None, }; assert!(bad.validate().is_err()); } #[test] fn test_action_path_must_start_with_api() { let bad = ActionDefinition { method: HttpMethod::Post, path: "/admin/reset".to_string(), ..ActionDefinition::default() }; assert!(bad.validate().is_err()); } #[test] fn test_action_path_rejects_traversal() { let bad = ActionDefinition { method: HttpMethod::Post, path: "/api/v1/tags/../../auth/login".to_string(), ..ActionDefinition::default() }; assert!(bad.validate().is_err()); } // Link href safety tests #[test] fn test_is_safe_href_allows_relative() { assert!(is_safe_href("/some/path")); } #[test] fn test_is_safe_href_allows_https() { assert!(is_safe_href("https://example.com/page")); } #[test] fn test_is_safe_href_allows_http() { assert!(is_safe_href("http://example.com/page")); } #[test] fn test_is_safe_href_rejects_javascript() { assert!(!is_safe_href("javascript:alert(1)")); } #[test] fn test_is_safe_href_rejects_data_uri() { assert!(!is_safe_href("data:text/html,")); } #[test] fn test_is_safe_href_rejects_vbscript() { assert!(!is_safe_href("vbscript:msgbox(1)")); } #[test] fn test_link_validation_rejects_unsafe_href() { use std::collections::HashMap as HM; let page = UiPage { id: "p".to_string(), title: "P".to_string(), route: "/api/plugins/p/p".to_string(), icon: None, root_element: UiElement::Link { text: "click".to_string(), href: "javascript:alert(1)".to_string(), external: false, }, data_sources: HM::new(), actions: HM::new(), }; assert!(page.validate().is_err()); } #[test] fn test_reserved_route_rejected() { use std::collections::HashMap as HM; let page = UiPage { id: "search-page".to_string(), title: "Search".to_string(), route: "/search".to_string(), icon: None, root_element: UiElement::Container { children: vec![], gap: 0, padding: None, }, data_sources: HM::new(), actions: HM::new(), }; let err = page.validate().unwrap_err(); assert!( matches!(err, SchemaError::ValidationError(_)), "expected ValidationError, got {err:?}" ); assert!( format!("{err}").contains("/search"), "error should mention the conflicting route" ); } // --- SpecialAction JSON round-trips --- #[test] fn test_special_action_refresh_roundtrip() { let action = SpecialAction::Refresh; let json = serde_json::to_value(&action).unwrap(); assert_eq!(json["action"], "refresh"); let back: SpecialAction = serde_json::from_value(json).unwrap(); assert_eq!(back, SpecialAction::Refresh); } #[test] fn test_special_action_navigate_roundtrip() { let action = SpecialAction::Navigate { to: "/foo".to_string(), }; let json = serde_json::to_value(&action).unwrap(); assert_eq!(json["action"], "navigate"); assert_eq!(json["to"], "/foo"); let back: SpecialAction = serde_json::from_value(json).unwrap(); assert_eq!(back, SpecialAction::Navigate { to: "/foo".to_string(), }); } #[test] fn test_special_action_emit_roundtrip() { let action = SpecialAction::Emit { event: "my-event".to_string(), payload: serde_json::json!({"key": "val"}), }; let json = serde_json::to_value(&action).unwrap(); assert_eq!(json["action"], "emit"); assert_eq!(json["event"], "my-event"); let back: SpecialAction = serde_json::from_value(json).unwrap(); assert_eq!(back, action); } #[test] fn test_special_action_update_state_roundtrip() { let action = SpecialAction::UpdateState { key: "my-key".to_string(), value: Expression::Literal(serde_json::json!(42)), }; let json = serde_json::to_value(&action).unwrap(); assert_eq!(json["action"], "update_state"); assert_eq!(json["key"], "my-key"); let back: SpecialAction = serde_json::from_value(json).unwrap(); assert_eq!(back, action); } #[test] fn test_special_action_close_modal_roundtrip() { let action = SpecialAction::CloseModal; let json = serde_json::to_value(&action).unwrap(); assert_eq!(json["action"], "close_modal"); let back: SpecialAction = serde_json::from_value(json).unwrap(); assert_eq!(back, SpecialAction::CloseModal); } // --- ActionRef deserialization ordering --- #[test] fn test_action_ref_special_refresh_deserializes() { let json = serde_json::json!({"action": "refresh"}); let action_ref: ActionRef = serde_json::from_value(json).unwrap(); assert!(matches!( action_ref, ActionRef::Special(SpecialAction::Refresh) )); } #[test] fn test_action_ref_special_navigate_deserializes() { let json = serde_json::json!({"action": "navigate", "to": "/foo"}); let action_ref: ActionRef = serde_json::from_value(json).unwrap(); assert!(matches!( action_ref, ActionRef::Special(SpecialAction::Navigate { to }) if to == "/foo" )); } #[test] fn test_action_ref_name_still_works() { let json = serde_json::json!("my-action"); let action_ref: ActionRef = serde_json::from_value(json).unwrap(); assert!(matches!(action_ref, ActionRef::Name(n) if n == "my-action")); } #[test] fn test_action_ref_special_takes_priority_over_inline() { // An object with "action":"refresh" must be SpecialAction, not // misinterpreted as ActionDefinition. let json = serde_json::json!({"action": "refresh"}); let action_ref: ActionRef = serde_json::from_value(json).unwrap(); assert!( matches!(action_ref, ActionRef::Special(_)), "SpecialAction must be matched before ActionDefinition" ); } }