use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub type ElementId = String;
pub type CanvasId = String;
fn default_text_format() -> String {
"plain".into()
}
fn default_field_type() -> String {
"text".into()
}
fn default_chart_type() -> String {
"bar".into()
}
fn default_true() -> bool {
true
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CanvasElement {
Text {
content: String,
#[serde(default = "default_text_format")]
format: String,
},
Button {
label: String,
action: String,
#[serde(default)]
disabled: bool,
},
Input {
label: String,
#[serde(default)]
placeholder: String,
#[serde(default)]
value: String,
},
Image {
src: String,
#[serde(default)]
alt: String,
},
Code {
code: String,
#[serde(default)]
language: String,
},
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
Form {
fields: Vec<FormField>,
submit_action: String,
},
Chart {
data: Vec<ChartDataPoint>,
#[serde(default = "default_chart_type")]
chart_type: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
colors: Option<Vec<String>>,
},
CodeEditor {
code: String,
#[serde(default)]
language: String,
#[serde(default)]
editable: bool,
#[serde(default = "default_true")]
line_numbers: bool,
},
FormAdvanced {
fields: Vec<AdvancedFormField>,
#[serde(default)]
submit_action: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChartDataPoint {
pub label: String,
pub value: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FormField {
pub name: String,
pub label: String,
#[serde(default = "default_field_type")]
pub field_type: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub placeholder: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AdvancedFormField {
pub name: String,
#[serde(default = "default_field_type")]
pub field_type: String,
pub label: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub options: Option<Vec<String>>,
#[serde(default)]
pub min: Option<f64>,
#[serde(default)]
pub max: Option<f64>,
#[serde(default)]
pub placeholder: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case")]
pub enum CanvasCommand {
Render {
id: ElementId,
element: CanvasElement,
#[serde(default)]
position: Option<u32>,
},
Update {
id: ElementId,
element: CanvasElement,
},
Remove { id: ElementId },
Reset,
Batch { commands: Vec<CanvasCommand> },
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "interaction", rename_all = "snake_case")]
pub enum CanvasInteraction {
Click {
element_id: ElementId,
action: String,
},
InputSubmit {
element_id: ElementId,
value: String,
},
FormSubmit {
element_id: ElementId,
values: HashMap<String, String>,
},
CodeSubmit {
element_id: ElementId,
code: String,
language: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_text_element() {
let elem = CanvasElement::Text {
content: "Hello, world!".into(),
format: "markdown".into(),
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["content"], "Hello, world!");
assert_eq!(json["format"], "markdown");
}
#[test]
fn deserialize_text_element_with_default_format() {
let json = serde_json::json!({ "type": "text", "content": "hi" });
let elem: CanvasElement = serde_json::from_value(json).unwrap();
match elem {
CanvasElement::Text { content, format } => {
assert_eq!(content, "hi");
assert_eq!(format, "plain");
}
other => panic!("expected Text, got: {other:?}"),
}
}
#[test]
fn serialize_button_element() {
let elem = CanvasElement::Button {
label: "Click me".into(),
action: "do_thing".into(),
disabled: false,
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "button");
assert_eq!(json["label"], "Click me");
assert_eq!(json["action"], "do_thing");
assert_eq!(json["disabled"], false);
}
#[test]
fn deserialize_button_element_default_disabled() {
let json = serde_json::json!({
"type": "button",
"label": "Go",
"action": "run"
});
let elem: CanvasElement = serde_json::from_value(json).unwrap();
match elem {
CanvasElement::Button { disabled, .. } => assert!(!disabled),
other => panic!("expected Button, got: {other:?}"),
}
}
#[test]
fn serialize_input_element() {
let elem = CanvasElement::Input {
label: "Name".into(),
placeholder: "Enter name".into(),
value: "".into(),
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "input");
assert_eq!(json["label"], "Name");
assert_eq!(json["placeholder"], "Enter name");
}
#[test]
fn deserialize_input_element_defaults() {
let json = serde_json::json!({ "type": "input", "label": "Email" });
let elem: CanvasElement = serde_json::from_value(json).unwrap();
match elem {
CanvasElement::Input {
label,
placeholder,
value,
} => {
assert_eq!(label, "Email");
assert_eq!(placeholder, "");
assert_eq!(value, "");
}
other => panic!("expected Input, got: {other:?}"),
}
}
#[test]
fn serialize_image_element() {
let elem = CanvasElement::Image {
src: "https://example.com/img.png".into(),
alt: "Logo".into(),
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "image");
assert_eq!(json["src"], "https://example.com/img.png");
assert_eq!(json["alt"], "Logo");
}
#[test]
fn deserialize_image_element_default_alt() {
let json = serde_json::json!({ "type": "image", "src": "a.png" });
let elem: CanvasElement = serde_json::from_value(json).unwrap();
match elem {
CanvasElement::Image { alt, .. } => assert_eq!(alt, ""),
other => panic!("expected Image, got: {other:?}"),
}
}
#[test]
fn serialize_code_element() {
let elem = CanvasElement::Code {
code: "fn main() {}".into(),
language: "rust".into(),
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "code");
assert_eq!(json["code"], "fn main() {}");
assert_eq!(json["language"], "rust");
}
#[test]
fn deserialize_code_element_default_language() {
let json = serde_json::json!({ "type": "code", "code": "x = 1" });
let elem: CanvasElement = serde_json::from_value(json).unwrap();
match elem {
CanvasElement::Code { language, .. } => assert_eq!(language, ""),
other => panic!("expected Code, got: {other:?}"),
}
}
#[test]
fn serialize_table_element() {
let elem = CanvasElement::Table {
headers: vec!["Name".into(), "Age".into()],
rows: vec![vec!["Alice".into(), "30".into()]],
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "table");
assert_eq!(json["headers"], serde_json::json!(["Name", "Age"]));
assert_eq!(json["rows"], serde_json::json!([["Alice", "30"]]));
}
#[test]
fn serialize_form_element() {
let elem = CanvasElement::Form {
fields: vec![FormField {
name: "username".into(),
label: "Username".into(),
field_type: "text".into(),
required: true,
placeholder: Some("Enter username".into()),
}],
submit_action: "create_user".into(),
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "form");
assert_eq!(json["submit_action"], "create_user");
assert_eq!(json["fields"][0]["name"], "username");
assert_eq!(json["fields"][0]["required"], true);
}
#[test]
fn deserialize_form_field_defaults() {
let json = serde_json::json!({
"name": "email",
"label": "Email Address"
});
let field: FormField = serde_json::from_value(json).unwrap();
assert_eq!(field.name, "email");
assert_eq!(field.label, "Email Address");
assert_eq!(field.field_type, "text");
assert!(!field.required);
assert!(field.placeholder.is_none());
}
#[test]
fn form_field_roundtrip() {
let field = FormField {
name: "age".into(),
label: "Age".into(),
field_type: "number".into(),
required: false,
placeholder: Some("0".into()),
};
let json = serde_json::to_string(&field).unwrap();
let restored: FormField = serde_json::from_str(&json).unwrap();
assert_eq!(field, restored);
}
#[test]
fn serialize_chart_element() {
let elem = CanvasElement::Chart {
data: vec![
ChartDataPoint {
label: "Jan".into(),
value: 100.0,
},
ChartDataPoint {
label: "Feb".into(),
value: 200.0,
},
],
chart_type: "bar".into(),
title: Some("Monthly Revenue".into()),
colors: Some(vec!["#6366f1".into(), "#22c55e".into()]),
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "chart");
assert_eq!(json["chart_type"], "bar");
assert_eq!(json["title"], "Monthly Revenue");
assert_eq!(json["data"].as_array().unwrap().len(), 2);
assert_eq!(json["data"][0]["label"], "Jan");
assert_eq!(json["data"][0]["value"], 100.0);
}
#[test]
fn deserialize_chart_element_defaults() {
let json = serde_json::json!({
"type": "chart",
"data": [{"label": "A", "value": 10}]
});
let elem: CanvasElement = serde_json::from_value(json).unwrap();
match elem {
CanvasElement::Chart {
data,
chart_type,
title,
colors,
} => {
assert_eq!(data.len(), 1);
assert_eq!(data[0].label, "A");
assert_eq!(data[0].value, 10.0);
assert_eq!(chart_type, "bar");
assert!(title.is_none());
assert!(colors.is_none());
}
other => panic!("expected Chart, got: {other:?}"),
}
}
#[test]
fn chart_data_point_roundtrip() {
let point = ChartDataPoint {
label: "March".into(),
value: 42.5,
};
let json = serde_json::to_string(&point).unwrap();
let restored: ChartDataPoint = serde_json::from_str(&json).unwrap();
assert_eq!(point, restored);
}
#[test]
fn chart_element_pie_type() {
let elem = CanvasElement::Chart {
data: vec![
ChartDataPoint {
label: "Desktop".into(),
value: 60.0,
},
ChartDataPoint {
label: "Mobile".into(),
value: 40.0,
},
],
chart_type: "pie".into(),
title: Some("Device Share".into()),
colors: None,
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "chart");
assert_eq!(json["chart_type"], "pie");
let roundtripped: CanvasElement = serde_json::from_value(json).unwrap();
assert_eq!(elem, roundtripped);
}
#[test]
fn serialize_code_editor_element() {
let elem = CanvasElement::CodeEditor {
code: "console.log('hello')".into(),
language: "javascript".into(),
editable: true,
line_numbers: true,
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "code_editor");
assert_eq!(json["code"], "console.log('hello')");
assert_eq!(json["language"], "javascript");
assert_eq!(json["editable"], true);
assert_eq!(json["line_numbers"], true);
}
#[test]
fn deserialize_code_editor_element_defaults() {
let json = serde_json::json!({
"type": "code_editor",
"code": "x = 1"
});
let elem: CanvasElement = serde_json::from_value(json).unwrap();
match elem {
CanvasElement::CodeEditor {
code,
language,
editable,
line_numbers,
} => {
assert_eq!(code, "x = 1");
assert_eq!(language, "");
assert!(!editable);
assert!(line_numbers); }
other => panic!("expected CodeEditor, got: {other:?}"),
}
}
#[test]
fn code_editor_roundtrip() {
let elem = CanvasElement::CodeEditor {
code: "fn main() {\n println!(\"Hello\");\n}".into(),
language: "rust".into(),
editable: false,
line_numbers: false,
};
let json = serde_json::to_string(&elem).unwrap();
let restored: CanvasElement = serde_json::from_str(&json).unwrap();
assert_eq!(elem, restored);
}
#[test]
fn serialize_form_advanced_element() {
let elem = CanvasElement::FormAdvanced {
fields: vec![
AdvancedFormField {
name: "name".into(),
field_type: "text".into(),
label: "Full Name".into(),
required: true,
options: None,
min: None,
max: None,
placeholder: Some("Enter your name".into()),
},
AdvancedFormField {
name: "age".into(),
field_type: "number".into(),
label: "Age".into(),
required: false,
options: None,
min: Some(0.0),
max: Some(150.0),
placeholder: None,
},
AdvancedFormField {
name: "role".into(),
field_type: "select".into(),
label: "Role".into(),
required: true,
options: Some(vec!["Admin".into(), "User".into(), "Guest".into()]),
min: None,
max: None,
placeholder: None,
},
],
submit_action: Some("create_user".into()),
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["type"], "form_advanced");
assert_eq!(json["submit_action"], "create_user");
assert_eq!(json["fields"].as_array().unwrap().len(), 3);
assert_eq!(json["fields"][0]["name"], "name");
assert_eq!(json["fields"][0]["required"], true);
assert_eq!(json["fields"][1]["min"], 0.0);
assert_eq!(json["fields"][2]["options"], serde_json::json!(["Admin", "User", "Guest"]));
}
#[test]
fn deserialize_advanced_form_field_defaults() {
let json = serde_json::json!({
"name": "notes",
"label": "Notes"
});
let field: AdvancedFormField = serde_json::from_value(json).unwrap();
assert_eq!(field.name, "notes");
assert_eq!(field.label, "Notes");
assert_eq!(field.field_type, "text");
assert!(!field.required);
assert!(field.options.is_none());
assert!(field.min.is_none());
assert!(field.max.is_none());
assert!(field.placeholder.is_none());
}
#[test]
fn advanced_form_field_roundtrip() {
let field = AdvancedFormField {
name: "priority".into(),
field_type: "select".into(),
label: "Priority".into(),
required: true,
options: Some(vec!["Low".into(), "Medium".into(), "High".into()]),
min: None,
max: None,
placeholder: Some("Select priority".into()),
};
let json = serde_json::to_string(&field).unwrap();
let restored: AdvancedFormField = serde_json::from_str(&json).unwrap();
assert_eq!(field, restored);
}
#[test]
fn serialize_code_submit_interaction() {
let interaction = CanvasInteraction::CodeSubmit {
element_id: "editor-1".into(),
code: "print('done')".into(),
language: "python".into(),
};
let json = serde_json::to_value(&interaction).unwrap();
assert_eq!(json["interaction"], "code_submit");
assert_eq!(json["element_id"], "editor-1");
assert_eq!(json["code"], "print('done')");
assert_eq!(json["language"], "python");
}
#[test]
fn deserialize_code_submit_interaction() {
let json = serde_json::json!({
"interaction": "code_submit",
"element_id": "ed-2",
"code": "x = 1",
"language": "python"
});
let interaction: CanvasInteraction = serde_json::from_value(json).unwrap();
match interaction {
CanvasInteraction::CodeSubmit {
element_id,
code,
language,
} => {
assert_eq!(element_id, "ed-2");
assert_eq!(code, "x = 1");
assert_eq!(language, "python");
}
other => panic!("expected CodeSubmit, got: {other:?}"),
}
}
#[test]
fn serialize_render_command() {
let cmd = CanvasCommand::Render {
id: "el-1".into(),
element: CanvasElement::Text {
content: "Hello".into(),
format: "plain".into(),
},
position: Some(0),
};
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["command"], "render");
assert_eq!(json["id"], "el-1");
assert_eq!(json["element"]["type"], "text");
assert_eq!(json["position"], 0);
}
#[test]
fn deserialize_render_command_no_position() {
let json = serde_json::json!({
"command": "render",
"id": "el-2",
"element": { "type": "button", "label": "Go", "action": "run" }
});
let cmd: CanvasCommand = serde_json::from_value(json).unwrap();
match cmd {
CanvasCommand::Render { id, position, .. } => {
assert_eq!(id, "el-2");
assert!(position.is_none());
}
other => panic!("expected Render, got: {other:?}"),
}
}
#[test]
fn serialize_update_command() {
let cmd = CanvasCommand::Update {
id: "el-1".into(),
element: CanvasElement::Text {
content: "Updated".into(),
format: "markdown".into(),
},
};
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["command"], "update");
assert_eq!(json["id"], "el-1");
}
#[test]
fn serialize_remove_command() {
let cmd = CanvasCommand::Remove {
id: "el-3".into(),
};
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["command"], "remove");
assert_eq!(json["id"], "el-3");
}
#[test]
fn serialize_reset_command() {
let cmd = CanvasCommand::Reset;
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["command"], "reset");
}
#[test]
fn serialize_batch_command() {
let cmd = CanvasCommand::Batch {
commands: vec![
CanvasCommand::Reset,
CanvasCommand::Render {
id: "el-1".into(),
element: CanvasElement::Text {
content: "Fresh".into(),
format: "plain".into(),
},
position: None,
},
],
};
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["command"], "batch");
assert_eq!(json["commands"].as_array().unwrap().len(), 2);
}
#[test]
fn deserialize_reset_command() {
let json = serde_json::json!({ "command": "reset" });
let cmd: CanvasCommand = serde_json::from_value(json).unwrap();
assert!(matches!(cmd, CanvasCommand::Reset));
}
#[test]
fn serialize_click_interaction() {
let interaction = CanvasInteraction::Click {
element_id: "btn-1".into(),
action: "submit".into(),
};
let json = serde_json::to_value(&interaction).unwrap();
assert_eq!(json["interaction"], "click");
assert_eq!(json["element_id"], "btn-1");
assert_eq!(json["action"], "submit");
}
#[test]
fn serialize_input_submit_interaction() {
let interaction = CanvasInteraction::InputSubmit {
element_id: "input-1".into(),
value: "hello".into(),
};
let json = serde_json::to_value(&interaction).unwrap();
assert_eq!(json["interaction"], "input_submit");
assert_eq!(json["element_id"], "input-1");
assert_eq!(json["value"], "hello");
}
#[test]
fn serialize_form_submit_interaction() {
let mut values = HashMap::new();
values.insert("username".into(), "alice".into());
values.insert("email".into(), "alice@example.com".into());
let interaction = CanvasInteraction::FormSubmit {
element_id: "form-1".into(),
values,
};
let json = serde_json::to_value(&interaction).unwrap();
assert_eq!(json["interaction"], "form_submit");
assert_eq!(json["element_id"], "form-1");
assert_eq!(json["values"]["username"], "alice");
assert_eq!(json["values"]["email"], "alice@example.com");
}
#[test]
fn deserialize_click_interaction() {
let json = serde_json::json!({
"interaction": "click",
"element_id": "btn-x",
"action": "delete"
});
let interaction: CanvasInteraction = serde_json::from_value(json).unwrap();
match interaction {
CanvasInteraction::Click {
element_id,
action,
} => {
assert_eq!(element_id, "btn-x");
assert_eq!(action, "delete");
}
other => panic!("expected Click, got: {other:?}"),
}
}
#[test]
fn deserialize_form_submit_interaction() {
let json = serde_json::json!({
"interaction": "form_submit",
"element_id": "form-2",
"values": { "name": "Bob" }
});
let interaction: CanvasInteraction = serde_json::from_value(json).unwrap();
match interaction {
CanvasInteraction::FormSubmit {
element_id,
values,
} => {
assert_eq!(element_id, "form-2");
assert_eq!(values.get("name").unwrap(), "Bob");
}
other => panic!("expected FormSubmit, got: {other:?}"),
}
}
#[test]
fn canvas_element_roundtrip_all_variants() {
let elements = vec![
CanvasElement::Text {
content: "test".into(),
format: "markdown".into(),
},
CanvasElement::Button {
label: "OK".into(),
action: "confirm".into(),
disabled: true,
},
CanvasElement::Input {
label: "Query".into(),
placeholder: "Type here".into(),
value: "default".into(),
},
CanvasElement::Image {
src: "logo.png".into(),
alt: "Company Logo".into(),
},
CanvasElement::Code {
code: "print('hi')".into(),
language: "python".into(),
},
CanvasElement::Table {
headers: vec!["Col1".into()],
rows: vec![vec!["val".into()]],
},
CanvasElement::Form {
fields: vec![FormField {
name: "f".into(),
label: "Field".into(),
field_type: "text".into(),
required: false,
placeholder: None,
}],
submit_action: "go".into(),
},
CanvasElement::Chart {
data: vec![ChartDataPoint {
label: "Q1".into(),
value: 42.0,
}],
chart_type: "line".into(),
title: Some("Quarterly".into()),
colors: None,
},
CanvasElement::CodeEditor {
code: "let x = 1;".into(),
language: "typescript".into(),
editable: true,
line_numbers: true,
},
CanvasElement::FormAdvanced {
fields: vec![AdvancedFormField {
name: "email".into(),
field_type: "text".into(),
label: "Email".into(),
required: true,
options: None,
min: None,
max: None,
placeholder: Some("you@example.com".into()),
}],
submit_action: Some("register".into()),
},
];
for elem in &elements {
let json = serde_json::to_string(elem).unwrap();
let restored: CanvasElement = serde_json::from_str(&json).unwrap();
assert_eq!(*elem, restored);
}
}
#[test]
fn render_chart_command() {
let cmd = CanvasCommand::Render {
id: "chart-1".into(),
element: CanvasElement::Chart {
data: vec![
ChartDataPoint { label: "A".into(), value: 10.0 },
ChartDataPoint { label: "B".into(), value: 20.0 },
],
chart_type: "bar".into(),
title: None,
colors: None,
},
position: None,
};
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["command"], "render");
assert_eq!(json["element"]["type"], "chart");
assert_eq!(json["element"]["data"].as_array().unwrap().len(), 2);
}
#[test]
fn render_code_editor_command() {
let cmd = CanvasCommand::Render {
id: "editor-1".into(),
element: CanvasElement::CodeEditor {
code: "SELECT * FROM users;".into(),
language: "sql".into(),
editable: true,
line_numbers: true,
},
position: Some(0),
};
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["command"], "render");
assert_eq!(json["element"]["type"], "code_editor");
assert_eq!(json["element"]["editable"], true);
}
}