pinakes/crates/pinakes-plugin-api/src/ui_schema.rs
NotAShelf f0fdd2ab91
pinakes-plugin-api: add reserved-route and required-endpoint validation
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id85a7e729b26af8eb028e19418a5a1706a6a6964
2026-03-11 21:30:59 +03:00

2223 lines
57 KiB
Rust

//! 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<T> = Result<T, SchemaError>;
/// 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<String>,
/// 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<String, DataSource>,
/// Named actions available to this page (referenced by `ActionRef::Name`)
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub actions: HashMap<String, ActionDefinition>,
}
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<String>,
stack: &mut std::collections::HashSet<String>,
) -> 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<String> {
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<Self>,
/// 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<Self>,
/// 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<Self>,
/// 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<Self>,
/// Sidebar width in pixels
#[serde(default = "default_sidebar_width")]
sidebar_width: u32,
/// Main content area
main: Box<Self>,
},
/// Tabbed interface
///
/// Displays content in tabbed panels.
Tabs {
/// Tab definitions
tabs: Vec<TabDefinition>,
/// 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<String>,
},
/// 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<String>,
/// 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<ColumnDef>,
/// 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<RowAction>,
},
/// 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<String>,
/// Main content elements
content: Vec<Self>,
/// Footer elements
#[serde(default, skip_serializing_if = "Vec::is_empty")]
footer: Vec<Self>,
},
/// 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<Self>,
/// 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<FormField>,
/// 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<String>,
},
/// 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<String>,
/// X-axis label
#[serde(skip_serializing_if = "Option::is_none")]
x_axis_label: Option<String>,
/// Y-axis label
#[serde(skip_serializing_if = "Option::is_none")]
y_axis_label: Option<String>,
/// 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<Self>,
/// Optional element to render when condition is false
#[serde(rename = "else", skip_serializing_if = "Option::is_none")]
else_element: Option<Box<Self>>,
},
/// 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<Self>,
/// Content to show when data is empty
#[serde(skip_serializing_if = "Option::is_none")]
empty: Option<Box<Self>>,
},
}
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<String>) {
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<String> 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<Expression> 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<String>,
/// 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<String>,
}
/// 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<String>,
/// 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<String>,
/// Default value
#[serde(skip_serializing_if = "Option::is_none")]
pub default_value: Option<serde_json::Value>,
/// Validation rules
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub validation: Vec<ValidationRule>,
/// Help text displayed below field
#[serde(skip_serializing_if = "Option::is_none")]
pub help_text: Option<String>,
}
/// 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<usize>,
},
/// 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<f64>,
/// Maximum value
#[serde(skip_serializing_if = "Option::is_none")]
max: Option<f64>,
/// Step increment
#[serde(skip_serializing_if = "Option::is_none")]
step: Option<f64>,
},
/// Toggle switch
Switch,
/// Checkbox
Checkbox {
/// Checkbox label (separate from field label)
#[serde(skip_serializing_if = "Option::is_none")]
checkbox_label: Option<String>,
},
/// Select dropdown
Select {
/// Available options
options: Vec<SelectOption>,
/// Whether multiple selection is allowed
#[serde(default)]
multiple: bool,
},
/// Radio button group
Radio {
/// Available options
options: Vec<SelectOption>,
},
/// 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<Vec<String>>,
/// Maximum file size in bytes
#[serde(skip_serializing_if = "Option::is_none")]
max_size: Option<u64>,
/// 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": "<kind>", ...}` 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<UiElement>,
},
/// 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<String, serde_json::Value>,
/// Success message
#[serde(skip_serializing_if = "Option::is_none")]
pub success_message: Option<String>,
/// Error message
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
/// Navigation after success (route to navigate to)
#[serde(skip_serializing_if = "Option::is_none")]
pub navigate_to: Option<String>,
}
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<String, Expression>,
/// 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<Expression>,
},
/// 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<Self>,
/// Operator
op: Operator,
/// Right operand
right: Box<Self>,
},
/// 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<Self>,
},
/// 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,<script>alert(1)</script>"));
}
#[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"
);
}
}