2304 lines
88 KiB
Rust
2304 lines
88 KiB
Rust
use regex::Regex;
|
|
use serde_json::{Map, Value, json};
|
|
use std::cell::Cell;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::sync::OnceLock;
|
|
|
|
pub(crate) const VERSION: &str = "v1.0";
|
|
pub(crate) const CATALOG_ID: &str = "https://ds4server.local/a2ui/v1_0/catalog.json";
|
|
pub(crate) const BASIC_CATALOG_ID: &str =
|
|
"https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json";
|
|
pub(crate) const CATALOG_JSON: &str = include_str!("../assets/a2ui/catalog.json");
|
|
|
|
thread_local! {
|
|
static TEMPLATE_INDEX: Cell<Option<usize>> = const { Cell::new(None) };
|
|
}
|
|
pub(crate) const BASIC_COMPONENTS: &[&str] = &[
|
|
"Text",
|
|
"Image",
|
|
"Icon",
|
|
"Video",
|
|
"AudioPlayer",
|
|
"Divider",
|
|
"Row",
|
|
"Column",
|
|
"List",
|
|
"Card",
|
|
"Modal",
|
|
"Tabs",
|
|
"Button",
|
|
"TextField",
|
|
"CheckBox",
|
|
"Slider",
|
|
"DateTimeInput",
|
|
"ChoicePicker",
|
|
];
|
|
pub(crate) const BASIC_NON_MEDIA_COMPONENTS: &[&str] = &[
|
|
"Text",
|
|
"Image",
|
|
"Icon",
|
|
"Divider",
|
|
"Row",
|
|
"Column",
|
|
"List",
|
|
"Card",
|
|
"Modal",
|
|
"Tabs",
|
|
"Button",
|
|
"TextField",
|
|
"CheckBox",
|
|
"Slider",
|
|
"DateTimeInput",
|
|
"ChoicePicker",
|
|
];
|
|
const FUNCTIONS: &[&str] = &[
|
|
"required",
|
|
"regex",
|
|
"length",
|
|
"numeric",
|
|
"email",
|
|
"formatString",
|
|
"formatNumber",
|
|
"formatCurrency",
|
|
"formatDate",
|
|
"pluralize",
|
|
"openUrl",
|
|
"and",
|
|
"or",
|
|
"not",
|
|
"@index",
|
|
];
|
|
|
|
pub(crate) const SYSTEM_PROMPT: &str = r#"A2UI local-chat rendering is enabled. Use the newest A2UI v1.0 protocol. Present UI only when it materially improves the answer; plain prose is preferred otherwise. Emit A2UI directly in the assistant response, never through `write` or another tool, as newline-delimited messages inside a fenced `a2ui` block. Keep ordinary prose outside the block. Use catalogId `https://ds4server.local/a2ui/v1_0/catalog.json`.
|
|
|
|
Every line must be one JSON object with `version":"v1.0"` and exactly one of `createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`, `callFunction`, or `actionResponse`. Create a surface before updating it. Treat the current client metadata as authoritative: if its surfaces object is empty, prior surfaces in chat history were dismissed and you must create a new surface instead of updating them. Components are a flat adjacency list and the root component has id `root`. Compose complete UIs by combining basic components through container child ids; include every referenced child, tab child, and list template component. Reuse the active surfaceId to update it incrementally; never recreate an existing surface. `createSurface` may include initial `components`, `dataModel`, and `surfaceProperties`. For `actionResponse`, put `actionId` beside `version` and put only `value` or `error` inside `actionResponse`.
|
|
|
|
Catalog components:
|
|
- Basic: Text(text Markdown,variant caption|body), Image(url,description,fit contain|cover|fill|none|scaleDown,variant icon|avatar|smallFeature|mediumFeature|largeFeature|header), Icon(name; safe examples: info|error|check|close|search|settings), Video(url,posterUrl), AudioPlayer(url,description), Divider(axis horizontal|vertical), Row/Column(children,justify start|center|end|spaceBetween|spaceAround|spaceEvenly|stretch,align start|center|end|stretch), List(children,direction vertical|horizontal,align start|center|end|stretch), Card(child), Modal(trigger,content), Tabs(tabs[{title,child}]), Button(child,variant default|primary|borderless,action:{event:{name,context,wantResponse}}), TextField(label,value:{path},variant shortText|longText|number|obscured,placeholder), CheckBox(label,value:{path}), Slider(value:{path},max,min,steps positive integer), DateTimeInput(label,value:{path},enableDate,enableTime,min,max), ChoicePicker(label,options[{label,value}],value:{path},variant multipleSelection|mutuallyExclusive,displayStyle checkbox|chips,filterable). Put numeric weight on direct Row/Column children to distribute available space.
|
|
- Research: Chart(title,chartType bar|line|area|stackedBar|pie|donut|heatmap,series[{label,value,segments}]), Table(title,columns,rows), Metric(label,value,detail,trend), Timeline(title,events[{time,title,description,status}]), Map(title,locations[{label,latitude,longitude,detail}]), MindMap(title,nodes[{id,label,children}]), Form(title,children,submitLabel,action). Use pie or donut for proportional breakdowns; donut displays the total in its center. Use heatmap for a matrix: every series entry is a row and its segments are the labeled columns whose numeric values determine color intensity. A heatmap without segments is empty.
|
|
- Shared fields: id, accessibility, weight, checks. Checks use {"condition":{"call":"required","args":{...}},"message":"..."}. Bind dynamic values with {"path":"/json/pointer"}. The renderer supports every function in the v1.0 Basic Catalog, including validation, formatting, logic, pluralize, openUrl, and @index. Input edits are local and synchronous. Agent events receive their resolved context and current data model. Server-initiated `callFunction` messages are supported.
|
|
|
|
Example:
|
|
```a2ui
|
|
{"version":"v1.0","createSurface":{"surfaceId":"answer","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json","sendDataModel":true,"surfaceProperties":{},"dataModel":{"name":""},"components":[{"id":"root","component":"Card","child":"body"},{"id":"body","component":"Column","children":["title","name","submit"]},{"id":"title","component":"Text","text":"Research result","variant":"body"},{"id":"name","component":"TextField","label":"Name","value":{"path":"/name"}},{"id":"submit-label","component":"Text","text":"Continue"},{"id":"submit","component":"Button","child":"submit-label","checks":[{"condition":{"call":"required","args":{"value":{"path":"/name"}}},"message":"Name is required"}],"action":{"event":{"name":"continue","wantResponse":true,"responsePath":"/result","context":{"name":{"path":"/name"}}}}}]}}
|
|
```
|
|
If the renderer reports a `VALIDATION_FAILED` A2UI client error, correct the named message or component with another valid A2UI message."#;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct Surface {
|
|
pub(crate) id: String,
|
|
pub(crate) catalog_id: String,
|
|
pub(crate) surface_properties: Value,
|
|
pub(crate) send_data_model: bool,
|
|
pub(crate) components: BTreeMap<String, Value>,
|
|
pub(crate) data: Value,
|
|
pub(crate) owner_message_id: i32,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct Store {
|
|
surfaces: BTreeMap<String, Surface>,
|
|
surface_order: Vec<String>,
|
|
pending_actions: BTreeMap<String, (String, Option<String>)>,
|
|
next_action_id: u64,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct ExtractedLine {
|
|
pub(crate) raw: String,
|
|
pub(crate) value: Result<Value, String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct Applied {
|
|
pub(crate) raws: Vec<String>,
|
|
pub(crate) reply: Option<Value>,
|
|
pub(crate) open_url: Option<String>,
|
|
}
|
|
|
|
pub(crate) fn replay_epochs<'a>(
|
|
records: impl IntoIterator<Item = (i32, i32, bool, &'a str)>,
|
|
) -> (Vec<Store>, Store, Vec<String>) {
|
|
let mut history = Vec::new();
|
|
let mut active = Store::default();
|
|
let mut errors = Vec::new();
|
|
for (record_id, message_id, dismissed, raw) in records {
|
|
if dismissed {
|
|
if active.active_surface().is_some() {
|
|
history.push(active.clone());
|
|
}
|
|
active.clear();
|
|
} else if let Err(error) = active.apply_raw(raw, message_id) {
|
|
errors.push(format!("A2UI message {record_id}: {error}"));
|
|
}
|
|
}
|
|
(history, active, errors)
|
|
}
|
|
|
|
pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet<String>, String> {
|
|
fn visit(
|
|
surface: &Surface,
|
|
id: &str,
|
|
ancestors: &mut BTreeSet<String>,
|
|
visited: &mut BTreeSet<String>,
|
|
kinds: &mut BTreeSet<String>,
|
|
) -> Result<(), String> {
|
|
if visited.contains(id) {
|
|
return Ok(());
|
|
}
|
|
if !ancestors.insert(id.to_owned()) {
|
|
return Err(format!("cyclic component reference at `{id}`"));
|
|
}
|
|
let component = surface
|
|
.components
|
|
.get(id)
|
|
.and_then(Value::as_object)
|
|
.ok_or_else(|| format!("referenced component `{id}` is missing"))?;
|
|
let kind = required_string(component, "component")?;
|
|
kinds.insert(kind.to_owned());
|
|
|
|
let mut children = Vec::new();
|
|
match kind {
|
|
"Row" | "Column" | "List" | "Form" => {
|
|
if let Some(ids) = component.get("children").and_then(Value::as_array) {
|
|
children.extend(ids.iter().filter_map(Value::as_str));
|
|
} else if let Some(id) = component
|
|
.get("children")
|
|
.and_then(Value::as_object)
|
|
.and_then(|template| template.get("componentId"))
|
|
.and_then(Value::as_str)
|
|
{
|
|
children.push(id);
|
|
}
|
|
}
|
|
"Card" | "Button" => {
|
|
children.extend(component.get("child").and_then(Value::as_str));
|
|
}
|
|
"Modal" => {
|
|
children.extend(component.get("trigger").and_then(Value::as_str));
|
|
children.extend(component.get("content").and_then(Value::as_str));
|
|
}
|
|
"Tabs" => {
|
|
children.extend(
|
|
component
|
|
.get("tabs")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|tab| tab.get("child").and_then(Value::as_str)),
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
for child in children {
|
|
visit(surface, child, ancestors, visited, kinds)?;
|
|
}
|
|
ancestors.remove(id);
|
|
visited.insert(id.to_owned());
|
|
Ok(())
|
|
}
|
|
|
|
let mut ancestors = BTreeSet::new();
|
|
let mut visited = BTreeSet::new();
|
|
let mut kinds = BTreeSet::new();
|
|
visit(surface, "root", &mut ancestors, &mut visited, &mut kinds)?;
|
|
Ok(kinds)
|
|
}
|
|
|
|
impl Store {
|
|
pub(crate) fn clear(&mut self) {
|
|
self.surfaces.clear();
|
|
self.surface_order.clear();
|
|
self.pending_actions.clear();
|
|
self.next_action_id = 0;
|
|
}
|
|
|
|
pub(crate) fn surfaces(&self) -> impl Iterator<Item = &Surface> {
|
|
self.surfaces.values()
|
|
}
|
|
|
|
pub(crate) fn surface(&self, id: &str) -> Option<&Surface> {
|
|
self.surfaces.get(id)
|
|
}
|
|
|
|
pub(crate) fn active_surface(&self) -> Option<&Surface> {
|
|
self.surface_order
|
|
.last()
|
|
.and_then(|id| self.surfaces.get(id))
|
|
}
|
|
|
|
pub(crate) fn image_urls(&self) -> impl Iterator<Item = String> + '_ {
|
|
self.active_surface().into_iter().flat_map(|surface| {
|
|
surface.components.values().filter_map(|component| {
|
|
let component = component.as_object()?;
|
|
let field = match component.get("component").and_then(Value::as_str) {
|
|
Some("Image") => "url",
|
|
Some("Video") => "posterUrl",
|
|
_ => return None,
|
|
};
|
|
component.get(field).map(|value| {
|
|
display_value(&bound_value_at(Some(value), &surface.data, &surface.data))
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
pub(crate) fn apply_raw(
|
|
&mut self,
|
|
raw: &str,
|
|
owner_message_id: i32,
|
|
) -> Result<Applied, String> {
|
|
let value: Value = serde_json::from_str(raw).map_err(|error| error.to_string())?;
|
|
self.apply(value, raw.to_owned(), owner_message_id)
|
|
}
|
|
|
|
pub(crate) fn apply(
|
|
&mut self,
|
|
value: Value,
|
|
raw: String,
|
|
owner_message_id: i32,
|
|
) -> Result<Applied, String> {
|
|
let mut next = self.clone();
|
|
let applied = next.apply_inner(value, raw, owner_message_id)?;
|
|
*self = next;
|
|
Ok(applied)
|
|
}
|
|
|
|
fn apply_inner(
|
|
&mut self,
|
|
value: Value,
|
|
raw: String,
|
|
owner_message_id: i32,
|
|
) -> Result<Applied, String> {
|
|
let envelope = value
|
|
.as_object()
|
|
.ok_or_else(|| "A2UI message must be a JSON object".to_owned())?;
|
|
let version = envelope
|
|
.get("version")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| "A2UI message requires a string version".to_owned())?;
|
|
if version != VERSION {
|
|
return Err(format!("unsupported A2UI version `{version}`"));
|
|
}
|
|
let kinds = [
|
|
"createSurface",
|
|
"updateComponents",
|
|
"updateDataModel",
|
|
"deleteSurface",
|
|
"callFunction",
|
|
"actionResponse",
|
|
];
|
|
let present = kinds
|
|
.iter()
|
|
.filter(|key| envelope.contains_key(**key))
|
|
.copied()
|
|
.collect::<Vec<_>>();
|
|
if present.len() != 1 {
|
|
return Err("A2UI envelope must contain version and exactly one message type".into());
|
|
}
|
|
let kind = present[0];
|
|
let allowed_envelope: &[&str] = match kind {
|
|
"callFunction" => &["version", "callFunction", "functionCallId", "wantResponse"],
|
|
"actionResponse" => &["version", "actionResponse", "actionId"],
|
|
_ => &["version", kind],
|
|
};
|
|
reject_unknown(envelope, allowed_envelope, "A2UI envelope")?;
|
|
let payload = object(envelope.get(kind), kind)?;
|
|
let mut raws = vec![raw];
|
|
let mut reply = None;
|
|
let mut open_url = None;
|
|
match kind {
|
|
"createSurface" => {
|
|
reject_unknown(
|
|
payload,
|
|
&[
|
|
"surfaceId",
|
|
"catalogId",
|
|
"surfaceProperties",
|
|
"sendDataModel",
|
|
"components",
|
|
"dataModel",
|
|
],
|
|
"createSurface",
|
|
)?;
|
|
let id = required_string(payload, "surfaceId")?;
|
|
if self.surfaces.contains_key(id) {
|
|
return Err(format!("surface `{id}` already exists"));
|
|
}
|
|
let catalog_id = required_string(payload, "catalogId")?;
|
|
if !matches!(catalog_id, CATALOG_ID | BASIC_CATALOG_ID) {
|
|
return Err(format!("unsupported catalog `{catalog_id}`"));
|
|
}
|
|
let surface_properties = payload
|
|
.get("surfaceProperties")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!({}));
|
|
if !surface_properties.is_object() {
|
|
return Err("createSurface surface properties must be an object".into());
|
|
}
|
|
let properties = surface_properties.as_object().unwrap();
|
|
if properties
|
|
.get("agentDisplayName")
|
|
.is_some_and(|value| !value.is_string())
|
|
{
|
|
return Err("surfaceProperties.agentDisplayName must be a string".into());
|
|
}
|
|
if let Some(icon) = properties.get("iconUrl") {
|
|
let icon = icon.as_str().ok_or_else(|| {
|
|
"surfaceProperties.iconUrl must be a URI string".to_owned()
|
|
})?;
|
|
url::Url::parse(icon)
|
|
.map_err(|error| format!("invalid surface iconUrl: {error}"))?;
|
|
}
|
|
let send_data_model = optional_bool(payload, "sendDataModel")?.unwrap_or(false);
|
|
self.surfaces.insert(
|
|
id.to_owned(),
|
|
Surface {
|
|
id: id.to_owned(),
|
|
catalog_id: catalog_id.to_owned(),
|
|
surface_properties,
|
|
send_data_model,
|
|
components: BTreeMap::new(),
|
|
data: json!({}),
|
|
owner_message_id,
|
|
},
|
|
);
|
|
self.surface_order.push(id.to_owned());
|
|
let surface = self.surfaces.get_mut(id).unwrap();
|
|
if let Some(data) = payload.get("dataModel") {
|
|
if !data.is_object() {
|
|
return Err("createSurface.dataModel must be an object".into());
|
|
}
|
|
surface.data = data.clone();
|
|
}
|
|
if let Some(components) = payload.get("components") {
|
|
let components = components
|
|
.as_array()
|
|
.filter(|components| !components.is_empty())
|
|
.ok_or_else(|| {
|
|
"createSurface.components must be a non-empty array".to_owned()
|
|
})?;
|
|
for component in components {
|
|
let component = component
|
|
.as_object()
|
|
.ok_or_else(|| "every component must be an object".to_owned())?;
|
|
validate_component(component, &surface.catalog_id)?;
|
|
surface.components.insert(
|
|
required_string(component, "id")?.to_owned(),
|
|
Value::Object(component.clone()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
"updateComponents" => {
|
|
reject_unknown(payload, &["surfaceId", "components"], "updateComponents")?;
|
|
let id = required_string(payload, "surfaceId")?;
|
|
let surface = self
|
|
.surfaces
|
|
.get_mut(id)
|
|
.ok_or_else(|| format!("surface `{id}` has not been created"))?;
|
|
let components = payload
|
|
.get("components")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| "updateComponents.components must be an array".to_owned())?;
|
|
if components.is_empty() {
|
|
return Err("updateComponents.components cannot be empty".into());
|
|
}
|
|
let mut ids = BTreeSet::new();
|
|
for component in components {
|
|
let component = component
|
|
.as_object()
|
|
.ok_or_else(|| "every component must be an object".to_owned())?;
|
|
validate_component(component, &surface.catalog_id)?;
|
|
let component_id = required_string(component, "id")?;
|
|
if !ids.insert(component_id) {
|
|
return Err(format!(
|
|
"component `{component_id}` occurs twice in one update"
|
|
));
|
|
}
|
|
}
|
|
for component in components {
|
|
let component_id = component["id"].as_str().unwrap().to_owned();
|
|
surface.components.insert(component_id, component.clone());
|
|
}
|
|
}
|
|
"updateDataModel" => {
|
|
reject_unknown(payload, &["surfaceId", "path", "value"], "updateDataModel")?;
|
|
let id = required_string(payload, "surfaceId")?;
|
|
let surface = self
|
|
.surfaces
|
|
.get_mut(id)
|
|
.ok_or_else(|| format!("surface `{id}` has not been created"))?;
|
|
let path = payload.get("path").and_then(Value::as_str).unwrap_or("/");
|
|
if path != "/" && !path.starts_with('/') {
|
|
return Err("updateDataModel.path must be a JSON Pointer".into());
|
|
}
|
|
set_pointer(&mut surface.data, path, payload.get("value").cloned())?;
|
|
}
|
|
"deleteSurface" => {
|
|
reject_unknown(payload, &["surfaceId"], "deleteSurface")?;
|
|
let id = required_string(payload, "surfaceId")?;
|
|
if self.surfaces.remove(id).is_none() {
|
|
return Err(format!("surface `{id}` has not been created"));
|
|
}
|
|
self.surface_order.retain(|surface_id| surface_id != id);
|
|
}
|
|
"callFunction" => {
|
|
let call_id = required_string(envelope, "functionCallId")?;
|
|
reject_unknown(payload, &["call", "args"], "callFunction")?;
|
|
let call = required_string(payload, "call")?;
|
|
validate_function(&Value::Object(payload.clone()))?;
|
|
let value = if call == "openUrl" {
|
|
let url = payload
|
|
.get("args")
|
|
.and_then(|args| args.get("url"))
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| "openUrl requires string `args.url`".to_owned())?;
|
|
let parsed = url::Url::parse(url).map_err(|error| error.to_string())?;
|
|
if !matches!(parsed.scheme(), "http" | "https") {
|
|
return Err("openUrl only supports HTTP(S) URLs".into());
|
|
}
|
|
open_url = Some(parsed.to_string());
|
|
Value::Null
|
|
} else {
|
|
evaluate(
|
|
call,
|
|
payload.get("args").unwrap_or(&json!({})),
|
|
&json!({}),
|
|
&json!({}),
|
|
)?
|
|
};
|
|
if optional_bool(envelope, "wantResponse")?.unwrap_or(false) {
|
|
reply = Some(json!({
|
|
"version": VERSION,
|
|
"functionResponse": {
|
|
"functionCallId": call_id,
|
|
"call": call,
|
|
"value": value
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
"actionResponse" => {
|
|
let action_id = required_string(envelope, "actionId")?;
|
|
let response = payload
|
|
.get("value")
|
|
.or_else(|| payload.get("error"))
|
|
.cloned()
|
|
.ok_or_else(|| "actionResponse requires value or error".to_owned())?;
|
|
reject_unknown(payload, &["value", "error"], "actionResponse")?;
|
|
if payload.contains_key("value") == payload.contains_key("error") {
|
|
return Err("actionResponse requires exactly one of value or error".into());
|
|
}
|
|
if let Some(error) = payload.get("error") {
|
|
let error = object(Some(error), "actionResponse.error")?;
|
|
reject_unknown(error, &["code", "message"], "actionResponse.error")?;
|
|
required_string(error, "code")?;
|
|
required_string(error, "message")?;
|
|
}
|
|
if let Some((surface_id, response_path)) = self.pending_actions.remove(action_id)
|
|
&& payload.contains_key("value")
|
|
&& let Some(path) = response_path
|
|
{
|
|
let surface = self.surfaces.get_mut(&surface_id).ok_or_else(|| {
|
|
format!("surface `{surface_id}` for action `{action_id}` no longer exists")
|
|
})?;
|
|
set_pointer(&mut surface.data, &path, Some(response.clone()))?;
|
|
raws.push(
|
|
serde_json::to_string(&json!({
|
|
"version": VERSION,
|
|
"updateDataModel": {
|
|
"surfaceId": surface_id,
|
|
"path": path,
|
|
"value": response
|
|
}
|
|
}))
|
|
.map_err(|error| error.to_string())?,
|
|
);
|
|
}
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
Ok(Applied {
|
|
raws,
|
|
reply,
|
|
open_url,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn local_update(
|
|
&mut self,
|
|
surface_id: &str,
|
|
path: &str,
|
|
value: Value,
|
|
) -> Result<String, String> {
|
|
let surface = self
|
|
.surfaces
|
|
.get_mut(surface_id)
|
|
.ok_or_else(|| format!("surface `{surface_id}` does not exist"))?;
|
|
set_pointer(&mut surface.data, path, Some(value.clone()))?;
|
|
serde_json::to_string(&json!({
|
|
"version": VERSION,
|
|
"updateDataModel": {"surfaceId": surface_id, "path": path, "value": value}
|
|
}))
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub(crate) fn action(
|
|
&mut self,
|
|
surface_id: &str,
|
|
component_id: &str,
|
|
context_path: Option<&str>,
|
|
) -> Result<Value, String> {
|
|
let surface = self
|
|
.surfaces
|
|
.get(surface_id)
|
|
.ok_or_else(|| format!("surface `{surface_id}` does not exist"))?;
|
|
let component = surface
|
|
.components
|
|
.get(component_id)
|
|
.and_then(Value::as_object)
|
|
.ok_or_else(|| format!("component `{component_id}` does not exist"))?;
|
|
if let Some(error) = first_failed_check(component, &surface.data) {
|
|
return Err(error);
|
|
}
|
|
let event = component
|
|
.get("action")
|
|
.and_then(Value::as_object)
|
|
.and_then(|action| action.get("event"))
|
|
.and_then(Value::as_object)
|
|
.ok_or_else(|| format!("component `{component_id}` has no agent event"))?;
|
|
let name = required_string(event, "name")?;
|
|
let context_data = context_path
|
|
.and_then(|path| surface.data.pointer(path))
|
|
.unwrap_or(&surface.data);
|
|
let context = resolve_at(
|
|
event.get("context").unwrap_or(&Value::Object(Map::new())),
|
|
&surface.data,
|
|
context_data,
|
|
)?;
|
|
self.next_action_id += 1;
|
|
let action_id = format!("ds4-action-{}", self.next_action_id);
|
|
let want_response = event
|
|
.get("wantResponse")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
let response_path = event
|
|
.get("responsePath")
|
|
.map(|value| {
|
|
value
|
|
.as_str()
|
|
.filter(|path| path.starts_with('/'))
|
|
.map(str::to_owned)
|
|
.ok_or_else(|| "action responsePath must be a JSON Pointer".to_owned())
|
|
})
|
|
.transpose()?;
|
|
let timestamp = time::OffsetDateTime::now_utc()
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.map_err(|error| error.to_string())?;
|
|
let mut event_payload = json!({
|
|
"name": name,
|
|
"surfaceId": surface_id,
|
|
"sourceComponentId": component_id,
|
|
"timestamp": timestamp,
|
|
"context": context,
|
|
"wantResponse": want_response
|
|
});
|
|
if want_response {
|
|
event_payload["actionId"] = Value::String(action_id.clone());
|
|
self.pending_actions
|
|
.insert(action_id, (surface_id.to_owned(), response_path));
|
|
}
|
|
let action = json!({
|
|
"version": VERSION,
|
|
"action": event_payload
|
|
});
|
|
Ok(action)
|
|
}
|
|
|
|
pub(crate) fn client_metadata(&self) -> Value {
|
|
let surfaces = self
|
|
.active_surface()
|
|
.into_iter()
|
|
.filter(|surface| surface.send_data_model)
|
|
.map(|surface| (surface.id.clone(), surface.data.clone()))
|
|
.collect::<Map<_, _>>();
|
|
let mut metadata = json!({
|
|
"a2uiClientCapabilities": {
|
|
(VERSION): {
|
|
"supportedCatalogIds": [CATALOG_ID, BASIC_CATALOG_ID],
|
|
"inlineCatalogs": []
|
|
}
|
|
}
|
|
});
|
|
metadata["a2uiClientDataModel"] = json!({
|
|
"version": VERSION,
|
|
"surfaces": surfaces,
|
|
});
|
|
metadata
|
|
}
|
|
}
|
|
|
|
pub(crate) fn extract_lines(content: &str) -> Vec<ExtractedLine> {
|
|
let mut lines = Vec::new();
|
|
let mut in_block = false;
|
|
for part in content.split_inclusive('\n') {
|
|
let complete = part.ends_with('\n');
|
|
let line = part.trim();
|
|
if !in_block && line.eq_ignore_ascii_case("```a2ui") {
|
|
in_block = true;
|
|
} else if in_block && line.starts_with("```") {
|
|
in_block = false;
|
|
} else if in_block && complete && !line.is_empty() {
|
|
lines.push(ExtractedLine {
|
|
raw: line.to_owned(),
|
|
value: serde_json::from_str(line).map_err(|error| error.to_string()),
|
|
});
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
pub(crate) fn message_surface_id(value: &Value) -> Option<&str> {
|
|
value
|
|
.as_object()?
|
|
.values()
|
|
.filter_map(Value::as_object)
|
|
.find_map(|payload| payload.get("surfaceId").and_then(Value::as_str))
|
|
}
|
|
|
|
pub(crate) fn transcript_fallback(content: &str) -> String {
|
|
let mut output = String::new();
|
|
let mut in_block = false;
|
|
let mut fallback = Vec::new();
|
|
for part in content.split_inclusive('\n') {
|
|
let line = part.trim();
|
|
if !in_block && line.eq_ignore_ascii_case("```a2ui") {
|
|
in_block = true;
|
|
continue;
|
|
}
|
|
if in_block && line.starts_with("```") {
|
|
in_block = false;
|
|
if !fallback.is_empty() {
|
|
output.push_str("\n> ");
|
|
output.push_str(&fallback.join(" \n> "));
|
|
output.push('\n');
|
|
fallback.clear();
|
|
}
|
|
continue;
|
|
}
|
|
if in_block {
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
fallback.push(match serde_json::from_str::<Value>(line) {
|
|
Ok(value) => message_summary(&value),
|
|
Err(error) => format!("A2UI message could not be read: {error}"),
|
|
});
|
|
} else {
|
|
output.push_str(part);
|
|
}
|
|
}
|
|
if !fallback.is_empty() {
|
|
output.push_str("\n> ");
|
|
output.push_str(&fallback.join(" \n> "));
|
|
}
|
|
output
|
|
}
|
|
|
|
pub(crate) fn bound_value_at(value: Option<&Value>, data: &Value, context: &Value) -> Value {
|
|
value
|
|
.map(|value| resolve_at(value, data, context).unwrap_or(Value::Null))
|
|
.unwrap_or(Value::Null)
|
|
}
|
|
|
|
pub(crate) fn binding_path(value: Option<&Value>) -> Option<&str> {
|
|
value?.as_object()?.get("path")?.as_str()
|
|
}
|
|
|
|
pub(crate) fn with_template_index<T>(index: usize, render: impl FnOnce() -> T) -> T {
|
|
TEMPLATE_INDEX.with(|current| {
|
|
let previous = current.replace(Some(index));
|
|
let rendered = render();
|
|
current.set(previous);
|
|
rendered
|
|
})
|
|
}
|
|
|
|
pub(crate) fn first_failed_check(component: &Map<String, Value>, data: &Value) -> Option<String> {
|
|
component
|
|
.get("checks")
|
|
.and_then(Value::as_array)
|
|
.and_then(|checks| {
|
|
checks.iter().find_map(|check| {
|
|
let check = check.as_object()?;
|
|
let fallback = Value::Object(check.clone());
|
|
let condition = check.get("condition").unwrap_or(&fallback);
|
|
match resolve(condition, data) {
|
|
Ok(Value::Bool(true)) => None,
|
|
Ok(_) => Some(
|
|
check
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("This value is invalid")
|
|
.to_owned(),
|
|
),
|
|
Err(error) => Some(error),
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
fn validate_component(component: &Map<String, Value>, catalog_id: &str) -> Result<(), String> {
|
|
let id = required_string(component, "id")?;
|
|
let kind = required_string(component, "component")?;
|
|
let allowed = if catalog_id == CATALOG_ID {
|
|
ds4_catalog()
|
|
.pointer("/components")
|
|
.and_then(Value::as_object)
|
|
.is_some_and(|components| components.contains_key(kind))
|
|
} else {
|
|
BASIC_COMPONENTS.contains(&kind)
|
|
};
|
|
if !allowed {
|
|
return Err(format!(
|
|
"component `{id}` uses `{kind}`, which is not in catalog `{catalog_id}`"
|
|
));
|
|
}
|
|
let required: &[&str] = match kind {
|
|
"Text" => &["text"],
|
|
"Image" => &["url"],
|
|
"Icon" => &["name"],
|
|
"Video" | "AudioPlayer" => &["url"],
|
|
"Row" | "Column" | "List" => &["children"],
|
|
"Card" => &["child"],
|
|
"Modal" => &["trigger", "content"],
|
|
"Tabs" => &["tabs"],
|
|
"Button" => &["child", "action"],
|
|
"TextField" => &["label"],
|
|
"CheckBox" => &["label", "value"],
|
|
"Slider" => &["value", "max"],
|
|
"DateTimeInput" => &["value"],
|
|
"ChoicePicker" => &["options", "value"],
|
|
"Chart" => &["chartType", "series"],
|
|
"Table" => &["columns", "rows"],
|
|
"Metric" => &["label", "value"],
|
|
"Timeline" => &["events"],
|
|
"Map" => &["locations"],
|
|
"MindMap" => &["nodes"],
|
|
"Form" => &["children"],
|
|
_ => &[],
|
|
};
|
|
if let Some(field) = required
|
|
.iter()
|
|
.find(|field| !component.contains_key(**field))
|
|
{
|
|
return Err(format!("component `{id}` ({kind}) requires `{field}`"));
|
|
}
|
|
let common = ["id", "component", "accessibility", "weight", "checks"];
|
|
let specific: &[&str] = match kind {
|
|
"Text" => &["text", "variant"],
|
|
"Image" => &["url", "description", "fit", "variant"],
|
|
"Icon" => &["name"],
|
|
"Video" => &["url", "posterUrl"],
|
|
"AudioPlayer" => &["url", "description"],
|
|
"Divider" => &["axis"],
|
|
"Row" | "Column" => &["children", "justify", "align"],
|
|
"List" => &["children", "direction", "align"],
|
|
"Card" => &["child"],
|
|
"Modal" => &["trigger", "content"],
|
|
"Tabs" => &["tabs"],
|
|
"Button" => &["child", "variant", "action"],
|
|
"TextField" => &["label", "value", "placeholder", "variant"],
|
|
"CheckBox" => &["label", "value"],
|
|
"Slider" => &["label", "min", "max", "value", "steps"],
|
|
"DateTimeInput" => &["label", "value", "enableDate", "enableTime", "min", "max"],
|
|
"ChoicePicker" => &[
|
|
"label",
|
|
"variant",
|
|
"options",
|
|
"value",
|
|
"displayStyle",
|
|
"filterable",
|
|
],
|
|
"Chart" => &["title", "chartType", "series"],
|
|
"Table" => &["title", "columns", "rows"],
|
|
"Metric" => &["label", "value", "detail", "trend"],
|
|
"Timeline" => &["title", "events"],
|
|
"Map" => &["title", "locations"],
|
|
"MindMap" => &["title", "nodes"],
|
|
"Form" => &["title", "children", "submitLabel", "action"],
|
|
_ => &[],
|
|
};
|
|
if let Some(field) = component
|
|
.keys()
|
|
.find(|field| !common.contains(&field.as_str()) && !specific.contains(&field.as_str()))
|
|
{
|
|
return Err(format!(
|
|
"component `{id}` ({kind}) contains unknown property `{field}`"
|
|
));
|
|
}
|
|
if let Some(accessibility) = component.get("accessibility") {
|
|
let accessibility = object(Some(accessibility), "accessibility")?;
|
|
for field in ["label", "description"] {
|
|
validate_optional_dynamic(accessibility, field, Value::is_string, id)?;
|
|
}
|
|
}
|
|
if component
|
|
.get("weight")
|
|
.is_some_and(|value| !value.is_number())
|
|
{
|
|
return Err(format!("component `{id}` weight must be a number"));
|
|
}
|
|
if component
|
|
.get("title")
|
|
.is_some_and(|value| !value.is_string())
|
|
{
|
|
return Err(format!("component `{id}` title must be a string"));
|
|
}
|
|
if catalog_id != CATALOG_ID
|
|
&& component.contains_key("checks")
|
|
&& !matches!(
|
|
kind,
|
|
"Button" | "TextField" | "CheckBox" | "ChoicePicker" | "Slider" | "DateTimeInput"
|
|
)
|
|
{
|
|
return Err(format!("component `{id}` ({kind}) does not support checks"));
|
|
}
|
|
match kind {
|
|
"Row" | "Column" | "List" | "Form" => {
|
|
validate_children(component.get("children").unwrap(), id)?
|
|
}
|
|
"Card" | "Button" => expect_string(component, "child", id)?,
|
|
"Modal" => {
|
|
expect_string(component, "trigger", id)?;
|
|
expect_string(component, "content", id)?;
|
|
}
|
|
"Tabs" => validate_tabs(component.get("tabs").unwrap(), id)?,
|
|
"ChoicePicker" => validate_options(component.get("options").unwrap(), id)?,
|
|
"Slider" => {
|
|
expect_number(component, "max", id)?;
|
|
if let Some(min) = component.get("min")
|
|
&& !min.is_number()
|
|
{
|
|
return Err(format!("component `{id}` min must be a number"));
|
|
}
|
|
if let Some(steps) = component.get("steps")
|
|
&& !steps.as_u64().is_some_and(|steps| steps > 0)
|
|
{
|
|
return Err(format!("component `{id}` steps must be a positive integer"));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
match kind {
|
|
"Text" => validate_dynamic_field(component, "text", Value::is_string, id)?,
|
|
"Image" | "Video" | "AudioPlayer" => {
|
|
validate_dynamic_field(component, "url", Value::is_string, id)?;
|
|
validate_optional_dynamic(component, "description", Value::is_string, id)?;
|
|
validate_optional_dynamic(component, "posterUrl", Value::is_string, id)?;
|
|
}
|
|
"Icon" => validate_dynamic_field(component, "name", Value::is_string, id)?,
|
|
"TextField" | "DateTimeInput" => {
|
|
validate_optional_dynamic(component, "label", Value::is_string, id)?;
|
|
validate_optional_dynamic(component, "value", Value::is_string, id)?;
|
|
}
|
|
"CheckBox" => {
|
|
validate_dynamic_field(component, "label", Value::is_string, id)?;
|
|
validate_dynamic_field(component, "value", Value::is_boolean, id)?;
|
|
}
|
|
"Slider" => validate_dynamic_field(component, "value", Value::is_number, id)?,
|
|
"ChoicePicker" => {
|
|
validate_optional_dynamic(component, "label", Value::is_string, id)?;
|
|
validate_dynamic_field(
|
|
component,
|
|
"value",
|
|
|value| {
|
|
value
|
|
.as_array()
|
|
.is_some_and(|values| values.iter().all(Value::is_string))
|
|
},
|
|
id,
|
|
)?;
|
|
}
|
|
"Chart" | "Table" | "Timeline" | "Map" | "MindMap" => {
|
|
for field in required {
|
|
if *field != "chartType" {
|
|
validate_dynamic_field(component, field, Value::is_array, id)?;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
match kind {
|
|
"Text" => validate_enum(component, "variant", &["caption", "body"], id)?,
|
|
"Image" => {
|
|
validate_enum(
|
|
component,
|
|
"fit",
|
|
&["contain", "cover", "fill", "none", "scaleDown"],
|
|
id,
|
|
)?;
|
|
validate_enum(
|
|
component,
|
|
"variant",
|
|
&[
|
|
"icon",
|
|
"avatar",
|
|
"smallFeature",
|
|
"mediumFeature",
|
|
"largeFeature",
|
|
"header",
|
|
],
|
|
id,
|
|
)?;
|
|
}
|
|
"Icon" if component.get("name").is_some_and(Value::is_string) => validate_enum(
|
|
component,
|
|
"name",
|
|
&[
|
|
"accountCircle",
|
|
"add",
|
|
"arrowBack",
|
|
"arrowForward",
|
|
"attachFile",
|
|
"calendarToday",
|
|
"call",
|
|
"camera",
|
|
"check",
|
|
"close",
|
|
"delete",
|
|
"download",
|
|
"edit",
|
|
"event",
|
|
"error",
|
|
"fastForward",
|
|
"favorite",
|
|
"favoriteOff",
|
|
"folder",
|
|
"help",
|
|
"home",
|
|
"info",
|
|
"locationOn",
|
|
"lock",
|
|
"lockOpen",
|
|
"mail",
|
|
"menu",
|
|
"moreVert",
|
|
"moreHoriz",
|
|
"notificationsOff",
|
|
"notifications",
|
|
"pause",
|
|
"payment",
|
|
"person",
|
|
"phone",
|
|
"photo",
|
|
"play",
|
|
"print",
|
|
"refresh",
|
|
"rewind",
|
|
"search",
|
|
"send",
|
|
"settings",
|
|
"share",
|
|
"shoppingCart",
|
|
"skipNext",
|
|
"skipPrevious",
|
|
"star",
|
|
"starHalf",
|
|
"starOff",
|
|
"stop",
|
|
"upload",
|
|
"visibility",
|
|
"visibilityOff",
|
|
"volumeDown",
|
|
"volumeMute",
|
|
"volumeOff",
|
|
"volumeUp",
|
|
"warning",
|
|
],
|
|
id,
|
|
)?,
|
|
"Divider" => validate_enum(component, "axis", &["horizontal", "vertical"], id)?,
|
|
"Row" | "Column" => {
|
|
validate_enum(
|
|
component,
|
|
"justify",
|
|
&[
|
|
"start",
|
|
"center",
|
|
"end",
|
|
"spaceBetween",
|
|
"spaceAround",
|
|
"spaceEvenly",
|
|
"stretch",
|
|
],
|
|
id,
|
|
)?;
|
|
validate_enum(
|
|
component,
|
|
"align",
|
|
&["start", "center", "end", "stretch"],
|
|
id,
|
|
)?;
|
|
}
|
|
"List" => {
|
|
validate_enum(component, "direction", &["vertical", "horizontal"], id)?;
|
|
validate_enum(
|
|
component,
|
|
"align",
|
|
&["start", "center", "end", "stretch"],
|
|
id,
|
|
)?;
|
|
}
|
|
"Button" => validate_enum(
|
|
component,
|
|
"variant",
|
|
&["default", "primary", "borderless"],
|
|
id,
|
|
)?,
|
|
"TextField" => validate_enum(
|
|
component,
|
|
"variant",
|
|
&["longText", "number", "shortText", "obscured"],
|
|
id,
|
|
)?,
|
|
"ChoicePicker" => {
|
|
validate_enum(
|
|
component,
|
|
"variant",
|
|
&["multipleSelection", "mutuallyExclusive"],
|
|
id,
|
|
)?;
|
|
validate_enum(component, "displayStyle", &["checkbox", "chips"], id)?;
|
|
optional_bool(component, "filterable")?;
|
|
}
|
|
"DateTimeInput" => {
|
|
optional_bool(component, "enableDate")?;
|
|
optional_bool(component, "enableTime")?;
|
|
validate_optional_dynamic(component, "min", Value::is_string, id)?;
|
|
validate_optional_dynamic(component, "max", Value::is_string, id)?;
|
|
}
|
|
"Chart" => validate_enum(
|
|
component,
|
|
"chartType",
|
|
&[
|
|
"bar",
|
|
"line",
|
|
"area",
|
|
"stackedBar",
|
|
"pie",
|
|
"donut",
|
|
"heatmap",
|
|
],
|
|
id,
|
|
)?,
|
|
_ => {}
|
|
}
|
|
if let Some(action) = component.get("action") {
|
|
validate_action(action, id)?;
|
|
}
|
|
if let Some(checks) = component.get("checks") {
|
|
let checks = checks
|
|
.as_array()
|
|
.ok_or_else(|| format!("component `{id}` checks must be an array"))?;
|
|
for check in checks {
|
|
let check = object(Some(check), "check")?;
|
|
reject_unknown(check, &["condition", "message"], "check")?;
|
|
let condition = check
|
|
.get("condition")
|
|
.ok_or_else(|| format!("component `{id}` check requires condition"))?;
|
|
required_string(check, "message")?;
|
|
validate_function(condition)?;
|
|
}
|
|
}
|
|
validate_dynamic_values(component)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_dynamic_field(
|
|
object: &Map<String, Value>,
|
|
field: &str,
|
|
literal: impl Fn(&Value) -> bool,
|
|
id: &str,
|
|
) -> Result<(), String> {
|
|
let value = object
|
|
.get(field)
|
|
.ok_or_else(|| format!("component `{id}` requires `{field}`"))?;
|
|
if literal(value) {
|
|
return Ok(());
|
|
}
|
|
let dynamic = value
|
|
.as_object()
|
|
.ok_or_else(|| format!("component `{id}` {field} has the wrong literal or dynamic type"))?;
|
|
if let Some(path) = dynamic.get("path") {
|
|
if dynamic.len() == 1 && path.is_string() {
|
|
return Ok(());
|
|
}
|
|
return Err(format!(
|
|
"component `{id}` {field} has an invalid data binding"
|
|
));
|
|
}
|
|
validate_function(value).map_err(|error| format!("component `{id}` {field}: {error}"))
|
|
}
|
|
|
|
fn validate_optional_dynamic(
|
|
object: &Map<String, Value>,
|
|
field: &str,
|
|
literal: impl Fn(&Value) -> bool,
|
|
id: &str,
|
|
) -> Result<(), String> {
|
|
if object.contains_key(field) {
|
|
validate_dynamic_field(object, field, literal, id)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn ds4_catalog() -> &'static Value {
|
|
static CATALOG: OnceLock<Value> = OnceLock::new();
|
|
CATALOG.get_or_init(|| {
|
|
serde_json::from_str(CATALOG_JSON).expect("embedded DS4Server A2UI catalog must be valid")
|
|
})
|
|
}
|
|
|
|
fn expect_string(object: &Map<String, Value>, field: &str, id: &str) -> Result<(), String> {
|
|
if !object.get(field).is_some_and(Value::is_string) {
|
|
return Err(format!("component `{id}` {field} must be a string"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn expect_number(object: &Map<String, Value>, field: &str, id: &str) -> Result<(), String> {
|
|
if !object.get(field).is_some_and(Value::is_number) {
|
|
return Err(format!("component `{id}` {field} must be a number"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_enum(
|
|
object: &Map<String, Value>,
|
|
field: &str,
|
|
allowed: &[&str],
|
|
id: &str,
|
|
) -> Result<(), String> {
|
|
if let Some(value) = object.get(field) {
|
|
let value = value
|
|
.as_str()
|
|
.ok_or_else(|| format!("component `{id}` {field} must be a string"))?;
|
|
if !allowed.contains(&value) {
|
|
return Err(format!(
|
|
"component `{id}` has invalid {field} `{value}`; expected one of: {}",
|
|
allowed.join(", ")
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_children(value: &Value, id: &str) -> Result<(), String> {
|
|
if value
|
|
.as_array()
|
|
.is_some_and(|children| children.iter().all(Value::is_string))
|
|
{
|
|
return Ok(());
|
|
}
|
|
let template = object(Some(value), "children")?;
|
|
reject_unknown(template, &["componentId", "path"], "children template")?;
|
|
required_string(template, "componentId")?;
|
|
required_string(template, "path")?;
|
|
if !required_string(template, "path")?.starts_with('/') {
|
|
return Err(format!(
|
|
"component `{id}` child template path must be a JSON Pointer"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_tabs(value: &Value, id: &str) -> Result<(), String> {
|
|
let tabs = value
|
|
.as_array()
|
|
.filter(|tabs| !tabs.is_empty())
|
|
.ok_or_else(|| format!("component `{id}` tabs must be a non-empty array"))?;
|
|
for tab in tabs {
|
|
let tab = object(Some(tab), "tab")?;
|
|
reject_unknown(tab, &["title", "child"], "tab")?;
|
|
validate_dynamic_field(tab, "title", Value::is_string, id)?;
|
|
required_string(tab, "child")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_options(value: &Value, id: &str) -> Result<(), String> {
|
|
let options = value
|
|
.as_array()
|
|
.ok_or_else(|| format!("component `{id}` options must be an array"))?;
|
|
for option in options {
|
|
let option = object(Some(option), "choice option")?;
|
|
reject_unknown(option, &["label", "value"], "choice option")?;
|
|
validate_dynamic_field(option, "label", Value::is_string, id)?;
|
|
required_string(option, "value")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_action(value: &Value, id: &str) -> Result<(), String> {
|
|
let action = object(Some(value), "action")?;
|
|
if let Some(event) = action.get("event") {
|
|
reject_unknown(action, &["event"], "action")?;
|
|
let event = object(Some(event), "action.event")?;
|
|
reject_unknown(
|
|
event,
|
|
&["name", "context", "wantResponse", "responsePath"],
|
|
"action.event",
|
|
)?;
|
|
required_string(event, "name")?;
|
|
if let Some(context) = event.get("context")
|
|
&& !context.is_object()
|
|
{
|
|
return Err(format!("component `{id}` action context must be an object"));
|
|
}
|
|
optional_bool(event, "wantResponse")?;
|
|
if let Some(path) = event.get("responsePath")
|
|
&& !path.as_str().is_some_and(|path| path.starts_with('/'))
|
|
{
|
|
return Err(format!(
|
|
"component `{id}` responsePath must be a JSON Pointer"
|
|
));
|
|
}
|
|
return Ok(());
|
|
}
|
|
reject_unknown(action, &["functionCall"], "action")?;
|
|
validate_function(
|
|
action
|
|
.get("functionCall")
|
|
.ok_or_else(|| format!("component `{id}` action requires event or functionCall"))?,
|
|
)
|
|
}
|
|
|
|
fn validate_dynamic_values(value: &Map<String, Value>) -> Result<(), String> {
|
|
for value in value.values() {
|
|
if value.get("call").is_some() {
|
|
validate_function(value)?;
|
|
}
|
|
match value {
|
|
Value::Object(object) => validate_dynamic_values(object)?,
|
|
Value::Array(values) => {
|
|
for value in values {
|
|
if let Value::Object(object) = value {
|
|
validate_dynamic_values(object)?;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_function(value: &Value) -> Result<(), String> {
|
|
let function = object(Some(value), "function call")?;
|
|
reject_unknown(function, &["call", "args"], "function call")?;
|
|
let call = value
|
|
.get("call")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| "function call requires `call`".to_owned())?;
|
|
if !FUNCTIONS.contains(&call) {
|
|
return Err(format!("function `{call}` is not declared by the catalog"));
|
|
}
|
|
if value.get("args").is_some_and(|args| !args.is_object()) {
|
|
return Err(format!("function `{call}` requires object `args`"));
|
|
}
|
|
let args = value
|
|
.get("args")
|
|
.and_then(Value::as_object)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let allowed: &[&str] = match call {
|
|
"required" | "email" | "formatString" | "not" => &["value"],
|
|
"regex" => &["value", "pattern"],
|
|
"length" | "numeric" => &["value", "min", "max"],
|
|
"formatNumber" => &["value", "decimals", "grouping"],
|
|
"formatCurrency" => &["value", "currency", "decimals", "grouping"],
|
|
"formatDate" => &["value", "format"],
|
|
"pluralize" => &["value", "zero", "one", "two", "few", "many", "other"],
|
|
"openUrl" => &["url"],
|
|
"and" | "or" => &["values"],
|
|
"@index" => &["offset"],
|
|
_ => &[],
|
|
};
|
|
reject_unknown(&args, allowed, &format!("function `{call}` args"))?;
|
|
let required: &[&str] = match call {
|
|
"required" | "regex" | "length" | "numeric" | "email" | "formatString" | "formatNumber"
|
|
| "not" | "pluralize" => &["value"],
|
|
"formatCurrency" => &["value", "currency"],
|
|
"formatDate" => &["value", "format"],
|
|
"openUrl" => &["url"],
|
|
"and" | "or" => &["values"],
|
|
"@index" => &[],
|
|
_ => &[],
|
|
};
|
|
if let Some(field) = required.iter().find(|field| !args.contains_key(**field)) {
|
|
return Err(format!("function `{call}` requires argument `{field}`"));
|
|
}
|
|
if call == "regex" && !args.get("pattern").is_some_and(Value::is_string) {
|
|
return Err("function `regex` requires string argument `pattern`".into());
|
|
}
|
|
if matches!(call, "length" | "numeric")
|
|
&& !args.contains_key("min")
|
|
&& !args.contains_key("max")
|
|
{
|
|
return Err(format!("function `{call}` requires `min` or `max`"));
|
|
}
|
|
if call == "pluralize" && !args.contains_key("other") {
|
|
return Err("function `pluralize` requires argument `other`".into());
|
|
}
|
|
if matches!(call, "and" | "or")
|
|
&& !args
|
|
.get("values")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|values| values.len() >= 2)
|
|
{
|
|
return Err(format!("function `{call}` requires at least two values"));
|
|
}
|
|
match call {
|
|
"regex" | "length" | "email" | "formatString" => {
|
|
validate_dynamic_field(&args, "value", Value::is_string, call)?;
|
|
}
|
|
"numeric" | "formatNumber" | "formatCurrency" | "pluralize" => {
|
|
validate_dynamic_field(&args, "value", Value::is_number, call)?;
|
|
}
|
|
"not" => validate_dynamic_field(&args, "value", Value::is_boolean, call)?,
|
|
"formatDate" => validate_dynamic_field(&args, "format", Value::is_string, call)?,
|
|
"openUrl" => {
|
|
let url = required_string(&args, "url")?;
|
|
url::Url::parse(url).map_err(|error| format!("invalid openUrl URL: {error}"))?;
|
|
}
|
|
"and" | "or" => {
|
|
for item in args["values"].as_array().unwrap() {
|
|
if !item.is_boolean() {
|
|
let item = item.as_object().ok_or_else(|| {
|
|
format!("function `{call}` values must be dynamic booleans")
|
|
})?;
|
|
if !item.contains_key("path") && !item.contains_key("call") {
|
|
return Err(format!("function `{call}` values must be dynamic booleans"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
for field in ["min", "max", "decimals", "offset"] {
|
|
if args.contains_key(field) {
|
|
validate_dynamic_field(&args, field, Value::is_number, call)?;
|
|
}
|
|
}
|
|
if args.contains_key("grouping") {
|
|
validate_dynamic_field(&args, "grouping", Value::is_boolean, call)?;
|
|
}
|
|
for field in ["currency", "zero", "one", "two", "few", "many", "other"] {
|
|
if args.contains_key(field) {
|
|
validate_dynamic_field(&args, field, Value::is_string, call)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve(value: &Value, data: &Value) -> Result<Value, String> {
|
|
resolve_at(value, data, data)
|
|
}
|
|
|
|
fn resolve_at(value: &Value, data: &Value, context: &Value) -> Result<Value, String> {
|
|
if let Some(path) = value
|
|
.as_object()
|
|
.and_then(|value| value.get("path"))
|
|
.and_then(Value::as_str)
|
|
{
|
|
let source = if path.starts_with('/') { data } else { context };
|
|
let pointer = if path.starts_with('/') {
|
|
normalize_pointer(path).to_owned()
|
|
} else {
|
|
format!("/{path}")
|
|
};
|
|
return Ok(source.pointer(&pointer).cloned().unwrap_or(Value::Null));
|
|
}
|
|
if let Some(call) = value
|
|
.as_object()
|
|
.and_then(|value| value.get("call"))
|
|
.and_then(Value::as_str)
|
|
{
|
|
return evaluate(
|
|
call,
|
|
value.get("args").unwrap_or(&Value::Null),
|
|
data,
|
|
context,
|
|
);
|
|
}
|
|
match value {
|
|
Value::Array(values) => values
|
|
.iter()
|
|
.map(|value| resolve_at(value, data, context))
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map(Value::Array),
|
|
Value::Object(values) => values
|
|
.iter()
|
|
.map(|(key, value)| Ok((key.clone(), resolve_at(value, data, context)?)))
|
|
.collect::<Result<Map<_, _>, String>>()
|
|
.map(Value::Object),
|
|
_ => Ok(value.clone()),
|
|
}
|
|
}
|
|
|
|
fn evaluate(call: &str, args: &Value, data: &Value, context: &Value) -> Result<Value, String> {
|
|
if !FUNCTIONS.contains(&call) {
|
|
return Err(format!("function `{call}` is not declared by the catalog"));
|
|
}
|
|
let args = args
|
|
.as_object()
|
|
.ok_or_else(|| format!("function `{call}` requires object args"))?;
|
|
let value = || resolve_at(args.get("value").unwrap_or(&Value::Null), data, context);
|
|
match call {
|
|
"required" => Ok(Value::Bool(match value()? {
|
|
Value::Null => false,
|
|
Value::String(value) => !value.is_empty(),
|
|
Value::Array(value) => !value.is_empty(),
|
|
Value::Object(value) => !value.is_empty(),
|
|
_ => true,
|
|
})),
|
|
"regex" => {
|
|
let pattern = args
|
|
.get("pattern")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| "regex requires a pattern".to_owned())?;
|
|
let regex = Regex::new(pattern).map_err(|error| format!("invalid regex: {error}"))?;
|
|
Ok(Value::Bool(regex.is_match(&display_value(&value()?))))
|
|
}
|
|
"length" => {
|
|
let length = display_value(&value()?).chars().count() as u64;
|
|
let min = args.get("min").and_then(Value::as_u64).unwrap_or(0);
|
|
let max = args.get("max").and_then(Value::as_u64).unwrap_or(u64::MAX);
|
|
Ok(Value::Bool((min..=max).contains(&length)))
|
|
}
|
|
"numeric" => {
|
|
let resolved = value()?;
|
|
let number = resolved
|
|
.as_f64()
|
|
.or_else(|| resolved.as_str().and_then(|value| value.parse().ok()));
|
|
let min = args
|
|
.get("min")
|
|
.and_then(Value::as_f64)
|
|
.unwrap_or(f64::NEG_INFINITY);
|
|
let max = args
|
|
.get("max")
|
|
.and_then(Value::as_f64)
|
|
.unwrap_or(f64::INFINITY);
|
|
Ok(Value::Bool(
|
|
number.is_some_and(|number| (min..=max).contains(&number)),
|
|
))
|
|
}
|
|
"email" => {
|
|
let email = display_value(&value()?);
|
|
let valid = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
|
.unwrap()
|
|
.is_match(&email);
|
|
Ok(Value::Bool(valid))
|
|
}
|
|
"formatString" => Ok(Value::String(interpolate(
|
|
&display_value(&value()?),
|
|
data,
|
|
context,
|
|
)?)),
|
|
"formatNumber" => {
|
|
let number = value()?.as_f64().unwrap_or(0.0);
|
|
let digits = resolve_at(args.get("decimals").unwrap_or(&json!(2)), data, context)?
|
|
.as_f64()
|
|
.map(|value| value.max(0.0) as u64)
|
|
.unwrap_or(2)
|
|
.min(12) as usize;
|
|
let formatted = format!("{number:.digits$}");
|
|
Ok(Value::String(
|
|
if resolve_at(
|
|
args.get("grouping").unwrap_or(&Value::Bool(true)),
|
|
data,
|
|
context,
|
|
)?
|
|
.as_bool()
|
|
.unwrap_or(true)
|
|
{
|
|
group_number(&formatted)
|
|
} else {
|
|
formatted
|
|
},
|
|
))
|
|
}
|
|
"formatCurrency" => {
|
|
let number = value()?.as_f64().unwrap_or(0.0);
|
|
let currency = display_value(&resolve_at(
|
|
args.get("currency").unwrap_or(&Value::Null),
|
|
data,
|
|
context,
|
|
)?);
|
|
let digits = resolve_at(args.get("decimals").unwrap_or(&json!(2)), data, context)?
|
|
.as_f64()
|
|
.map(|value| value.max(0.0) as u64)
|
|
.unwrap_or(2)
|
|
.min(12) as usize;
|
|
let formatted = format!("{number:.digits$}");
|
|
let number = if resolve_at(
|
|
args.get("grouping").unwrap_or(&Value::Bool(true)),
|
|
data,
|
|
context,
|
|
)?
|
|
.as_bool()
|
|
.unwrap_or(true)
|
|
{
|
|
group_number(&formatted)
|
|
} else {
|
|
formatted
|
|
};
|
|
Ok(Value::String(format!("{currency} {number}")))
|
|
}
|
|
"formatDate" => {
|
|
let input = display_value(&value()?);
|
|
let date =
|
|
time::OffsetDateTime::parse(&input, &time::format_description::well_known::Rfc3339)
|
|
.map_err(|error| format!("formatDate requires an RFC 3339 value: {error}"))?;
|
|
let pattern = display_value(&resolve_at(
|
|
args.get("format").unwrap_or(&Value::Null),
|
|
data,
|
|
context,
|
|
)?);
|
|
Ok(Value::String(format_date(date, &pattern)))
|
|
}
|
|
"pluralize" => {
|
|
let number = value()?.as_f64().unwrap_or(0.0);
|
|
let key = if number == 0.0 && args.contains_key("zero") {
|
|
"zero"
|
|
} else if number == 1.0 && args.contains_key("one") {
|
|
"one"
|
|
} else if number == 2.0 && args.contains_key("two") {
|
|
"two"
|
|
} else {
|
|
"other"
|
|
};
|
|
resolve_at(args.get(key).unwrap_or(&Value::Null), data, context)
|
|
}
|
|
"and" | "or" => {
|
|
let values = args
|
|
.get("values")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| format!("{call} requires array `values`"))?;
|
|
let values = values
|
|
.iter()
|
|
.map(|value| {
|
|
resolve_at(value, data, context)?
|
|
.as_bool()
|
|
.ok_or_else(|| format!("{call} requires boolean values"))
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
Ok(Value::Bool(if call == "and" {
|
|
values.into_iter().all(|value| value)
|
|
} else {
|
|
values.into_iter().any(|value| value)
|
|
}))
|
|
}
|
|
"not" => {
|
|
Ok(Value::Bool(!value()?.as_bool().ok_or_else(|| {
|
|
"not requires a boolean value".to_owned()
|
|
})?))
|
|
}
|
|
"openUrl" => Ok(Value::Null),
|
|
"@index" => {
|
|
let index = TEMPLATE_INDEX
|
|
.with(Cell::get)
|
|
.ok_or_else(|| "@index is only available in a list template".to_owned())?;
|
|
let offset = args
|
|
.get("offset")
|
|
.map(|value| resolve_at(value, data, context))
|
|
.transpose()?
|
|
.and_then(|value| value.as_i64())
|
|
.unwrap_or(0);
|
|
Ok(json!(index as i64 + offset))
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
fn interpolate(template: &str, data: &Value, context: &Value) -> Result<String, String> {
|
|
let mut output = String::new();
|
|
let mut offset = 0;
|
|
while let Some(relative) = template[offset..].find("${") {
|
|
let start = offset + relative;
|
|
if start > offset && template.as_bytes()[start - 1] == b'\\' {
|
|
output.push_str(&template[offset..start - 1]);
|
|
output.push_str("${");
|
|
offset = start + 2;
|
|
continue;
|
|
}
|
|
output.push_str(&template[offset..start]);
|
|
let end = expression_end(template, start + 2)
|
|
.ok_or_else(|| "formatString contains an unclosed expression".to_owned())?;
|
|
output.push_str(&display_value(&evaluate_expression(
|
|
&template[start + 2..end],
|
|
data,
|
|
context,
|
|
)?));
|
|
offset = end + 1;
|
|
}
|
|
output.push_str(&template[offset..]);
|
|
Ok(output)
|
|
}
|
|
|
|
fn expression_end(text: &str, mut offset: usize) -> Option<usize> {
|
|
let mut depth = 1;
|
|
let mut quote = None;
|
|
while offset < text.len() {
|
|
let character = text[offset..].chars().next()?;
|
|
if let Some(current) = quote {
|
|
if character == current && text.as_bytes().get(offset.wrapping_sub(1)) != Some(&b'\\') {
|
|
quote = None;
|
|
}
|
|
} else if matches!(character, '\'' | '"') {
|
|
quote = Some(character);
|
|
} else if text[offset..].starts_with("${") {
|
|
depth += 1;
|
|
offset += 2;
|
|
continue;
|
|
} else if character == '}' {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
return Some(offset);
|
|
}
|
|
}
|
|
offset += character.len_utf8();
|
|
}
|
|
None
|
|
}
|
|
|
|
fn evaluate_expression(expression: &str, data: &Value, context: &Value) -> Result<Value, String> {
|
|
let expression = expression.trim();
|
|
if let Some(open) = expression.find('(')
|
|
&& expression.ends_with(')')
|
|
{
|
|
let call = expression[..open].trim();
|
|
let mut args = Map::new();
|
|
for argument in split_expression_args(&expression[open + 1..expression.len() - 1]) {
|
|
let colon = top_level_separator(argument, ':')
|
|
.ok_or_else(|| format!("formatString argument `{argument}` must be named"))?;
|
|
let name = argument[..colon].trim();
|
|
if name.is_empty() {
|
|
return Err("formatString contains an empty argument name".into());
|
|
}
|
|
args.insert(
|
|
name.to_owned(),
|
|
expression_value(argument[colon + 1..].trim(), data, context)?,
|
|
);
|
|
}
|
|
return evaluate(call, &Value::Object(args), data, context);
|
|
}
|
|
let (source, pointer) = if expression.starts_with('/') {
|
|
(data, normalize_pointer(expression).to_owned())
|
|
} else {
|
|
(context, format!("/{expression}"))
|
|
};
|
|
Ok(source.pointer(&pointer).cloned().unwrap_or(Value::Null))
|
|
}
|
|
|
|
fn split_expression_args(input: &str) -> Vec<&str> {
|
|
let mut arguments = Vec::new();
|
|
let mut start = 0;
|
|
while let Some(relative) = top_level_separator(&input[start..], ',') {
|
|
arguments.push(input[start..start + relative].trim());
|
|
start += relative + 1;
|
|
}
|
|
if !input[start..].trim().is_empty() {
|
|
arguments.push(input[start..].trim());
|
|
}
|
|
arguments
|
|
}
|
|
|
|
fn top_level_separator(input: &str, separator: char) -> Option<usize> {
|
|
let mut round = 0;
|
|
let mut braces = 0;
|
|
let mut quote = None;
|
|
for (index, character) in input.char_indices() {
|
|
if let Some(current) = quote {
|
|
if character == current && input.as_bytes().get(index.wrapping_sub(1)) != Some(&b'\\') {
|
|
quote = None;
|
|
}
|
|
continue;
|
|
}
|
|
match character {
|
|
'\'' | '"' => quote = Some(character),
|
|
'(' => round += 1,
|
|
')' => round -= 1,
|
|
'{' => braces += 1,
|
|
'}' => braces -= 1,
|
|
_ if character == separator && round == 0 && braces == 0 => return Some(index),
|
|
_ => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn expression_value(expression: &str, data: &Value, context: &Value) -> Result<Value, String> {
|
|
if expression.starts_with("${") && expression.ends_with('}') {
|
|
return evaluate_expression(&expression[2..expression.len() - 1], data, context);
|
|
}
|
|
if expression.starts_with('\'') && expression.ends_with('\'') && expression.len() >= 2 {
|
|
return Ok(Value::String(
|
|
expression[1..expression.len() - 1].to_owned(),
|
|
));
|
|
}
|
|
if let Ok(value) = serde_json::from_str(expression) {
|
|
return Ok(value);
|
|
}
|
|
if expression.starts_with('/') {
|
|
return Ok(data
|
|
.pointer(normalize_pointer(expression))
|
|
.cloned()
|
|
.unwrap_or(Value::Null));
|
|
}
|
|
Ok(Value::String(expression.to_owned()))
|
|
}
|
|
|
|
fn group_number(number: &str) -> String {
|
|
let (whole, fraction) = number.split_once('.').unwrap_or((number, ""));
|
|
let (sign, digits) = whole
|
|
.strip_prefix('-')
|
|
.map_or(("", whole), |digits| ("-", digits));
|
|
let mut grouped = String::with_capacity(number.len() + number.len() / 3);
|
|
grouped.push_str(sign);
|
|
for (index, digit) in digits.chars().enumerate() {
|
|
if index > 0 && (digits.len() - index).is_multiple_of(3) {
|
|
grouped.push(',');
|
|
}
|
|
grouped.push(digit);
|
|
}
|
|
if !fraction.is_empty() {
|
|
grouped.push('.');
|
|
grouped.push_str(fraction);
|
|
}
|
|
grouped
|
|
}
|
|
|
|
fn format_date(date: time::OffsetDateTime, pattern: &str) -> String {
|
|
const MONTHS: [&str; 12] = [
|
|
"January",
|
|
"February",
|
|
"March",
|
|
"April",
|
|
"May",
|
|
"June",
|
|
"July",
|
|
"August",
|
|
"September",
|
|
"October",
|
|
"November",
|
|
"December",
|
|
];
|
|
const DAYS: [&str; 7] = [
|
|
"Monday",
|
|
"Tuesday",
|
|
"Wednesday",
|
|
"Thursday",
|
|
"Friday",
|
|
"Saturday",
|
|
"Sunday",
|
|
];
|
|
let month = MONTHS[date.month() as usize - 1];
|
|
let day = DAYS[date.weekday().number_days_from_monday() as usize];
|
|
let hour_12 = match date.hour() % 12 {
|
|
0 => 12,
|
|
hour => hour,
|
|
};
|
|
let replacements = [
|
|
("EEEE", day.to_owned()),
|
|
("MMMM", month.to_owned()),
|
|
("yyyy", format!("{:04}", date.year())),
|
|
("MMM", month[..3].to_owned()),
|
|
("yy", format!("{:02}", date.year().rem_euclid(100))),
|
|
("MM", format!("{:02}", date.month() as u8)),
|
|
("dd", format!("{:02}", date.day())),
|
|
("HH", format!("{:02}", date.hour())),
|
|
("hh", format!("{hour_12:02}")),
|
|
("mm", format!("{:02}", date.minute())),
|
|
("ss", format!("{:02}", date.second())),
|
|
("E", day[..3].to_owned()),
|
|
("M", (date.month() as u8).to_string()),
|
|
("d", date.day().to_string()),
|
|
("H", date.hour().to_string()),
|
|
("h", hour_12.to_string()),
|
|
("a", if date.hour() < 12 { "AM" } else { "PM" }.to_owned()),
|
|
];
|
|
let mut output = String::new();
|
|
let mut remaining = pattern;
|
|
while !remaining.is_empty() {
|
|
if let Some((token, replacement)) = replacements
|
|
.iter()
|
|
.find(|(token, _)| remaining.starts_with(token))
|
|
{
|
|
output.push_str(replacement);
|
|
remaining = &remaining[token.len()..];
|
|
} else {
|
|
let character = remaining.chars().next().unwrap();
|
|
output.push(character);
|
|
remaining = &remaining[character.len_utf8()..];
|
|
}
|
|
}
|
|
output
|
|
}
|
|
|
|
fn set_pointer(root: &mut Value, path: &str, value: Option<Value>) -> Result<(), String> {
|
|
if path.is_empty() || path == "/" {
|
|
*root = value.unwrap_or(Value::Null);
|
|
return Ok(());
|
|
}
|
|
let segments = path[1..]
|
|
.split('/')
|
|
.map(|segment| segment.replace("~1", "/").replace("~0", "~"))
|
|
.collect::<Vec<_>>();
|
|
let (last, parents) = segments.split_last().unwrap();
|
|
let mut current = root;
|
|
for segment in parents {
|
|
match current {
|
|
Value::Object(object) => {
|
|
current = object
|
|
.entry(segment.clone())
|
|
.or_insert_with(|| Value::Object(Map::new()));
|
|
}
|
|
Value::Array(array) => {
|
|
let index = segment
|
|
.parse::<usize>()
|
|
.map_err(|_| format!("`{segment}` is not an array index"))?;
|
|
while array.len() <= index {
|
|
array.push(Value::Object(Map::new()));
|
|
}
|
|
current = &mut array[index];
|
|
}
|
|
_ => return Err(format!("cannot traverse through `{segment}`")),
|
|
}
|
|
}
|
|
match current {
|
|
Value::Object(object) => match value {
|
|
Some(value) => {
|
|
object.insert(last.clone(), value);
|
|
}
|
|
None => {
|
|
object.remove(last);
|
|
}
|
|
},
|
|
Value::Array(array) => {
|
|
let index = last
|
|
.parse::<usize>()
|
|
.map_err(|_| format!("`{last}` is not an array index"))?;
|
|
if let Some(value) = value {
|
|
while array.len() <= index {
|
|
array.push(Value::Null);
|
|
}
|
|
array[index] = value;
|
|
} else if index < array.len() {
|
|
array.remove(index);
|
|
}
|
|
}
|
|
_ => return Err(format!("cannot update `{path}`")),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn message_summary(value: &Value) -> String {
|
|
for (key, verb) in [
|
|
("createSurface", "Created"),
|
|
("updateComponents", "Updated components on"),
|
|
("updateDataModel", "Updated data on"),
|
|
("deleteSurface", "Deleted"),
|
|
("actionResponse", "Responded to action on"),
|
|
] {
|
|
if let Some(id) = value
|
|
.get(key)
|
|
.and_then(|value| value.get("surfaceId"))
|
|
.and_then(Value::as_str)
|
|
{
|
|
return format!("{verb} interactive surface `{id}`.");
|
|
}
|
|
}
|
|
"A2UI interactive update.".into()
|
|
}
|
|
|
|
pub(crate) fn display_value(value: &Value) -> String {
|
|
match value {
|
|
Value::Null => String::new(),
|
|
Value::String(value) => value.clone(),
|
|
Value::Bool(value) => value.to_string(),
|
|
Value::Number(value) => value.to_string(),
|
|
value => serde_json::to_string(value).unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
fn normalize_pointer(path: &str) -> &str {
|
|
if path == "/" { "" } else { path }
|
|
}
|
|
|
|
fn object<'a>(value: Option<&'a Value>, name: &str) -> Result<&'a Map<String, Value>, String> {
|
|
value
|
|
.and_then(Value::as_object)
|
|
.ok_or_else(|| format!("{name} must be an object"))
|
|
}
|
|
|
|
fn reject_unknown(object: &Map<String, Value>, allowed: &[&str], name: &str) -> Result<(), String> {
|
|
if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
|
|
return Err(format!("{name} contains unknown property `{key}`"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn required_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a str, String> {
|
|
object
|
|
.get(key)
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| format!("`{key}` must be a non-empty string"))
|
|
}
|
|
|
|
fn optional_bool(object: &Map<String, Value>, key: &str) -> Result<Option<bool>, String> {
|
|
object
|
|
.get(key)
|
|
.map(|value| {
|
|
value
|
|
.as_bool()
|
|
.ok_or_else(|| format!("`{key}` must be a boolean"))
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn apply(store: &mut Store, value: Value) -> Result<Applied, String> {
|
|
store.apply(value.clone(), value.to_string(), 7)
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_updates_incrementally_and_json_pointer_upserts() {
|
|
let mut store = Store::default();
|
|
apply(
|
|
&mut store,
|
|
json!({"version":VERSION,"createSurface":{"surfaceId":"s","catalogId":CATALOG_ID}}),
|
|
)
|
|
.unwrap();
|
|
apply(&mut store, json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Text","text":{"path":"/user/name"}}]}})).unwrap();
|
|
apply(&mut store, json!({"version":VERSION,"updateDataModel":{"surfaceId":"s","path":"/user/name","value":"Ada"}})).unwrap();
|
|
assert_eq!(
|
|
store.surface("s").unwrap().data.pointer("/user/name"),
|
|
Some(&json!("Ada"))
|
|
);
|
|
apply(&mut store, json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Metric","label":"Name","value":{"path":"/user/name"}}]}})).unwrap();
|
|
assert_eq!(
|
|
store.surface("s").unwrap().components["root"]["component"],
|
|
"Metric"
|
|
);
|
|
apply(&mut store, json!({"version":VERSION,"updateDataModel":{"surfaceId":"s","path":"/items","value":["first","second"]}})).unwrap();
|
|
apply(
|
|
&mut store,
|
|
json!({"version":VERSION,"updateDataModel":{"surfaceId":"s","path":"/items/0"}}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(store.surface("s").unwrap().data["items"], json!(["second"]));
|
|
apply(
|
|
&mut store,
|
|
json!({"version":VERSION,"deleteSurface":{"surfaceId":"s"}}),
|
|
)
|
|
.unwrap();
|
|
assert!(store.surface("s").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn latest_created_surface_is_the_active_surface() {
|
|
let mut store = Store::default();
|
|
for id in ["first", "second"] {
|
|
apply(
|
|
&mut store,
|
|
json!({"version":VERSION,"createSurface":{"surfaceId":id,"catalogId":CATALOG_ID,"sendDataModel":true,"dataModel":{"id":id}}}),
|
|
)
|
|
.unwrap();
|
|
}
|
|
assert_eq!(store.active_surface().unwrap().id, "second");
|
|
let metadata = store.client_metadata();
|
|
assert_eq!(
|
|
metadata.pointer("/a2uiClientDataModel/surfaces/second/id"),
|
|
Some(&json!("second"))
|
|
);
|
|
assert!(
|
|
metadata
|
|
.pointer("/a2uiClientDataModel/surfaces/first")
|
|
.is_none()
|
|
);
|
|
apply(
|
|
&mut store,
|
|
json!({"version":VERSION,"deleteSurface":{"surfaceId":"second"}}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(store.active_surface().unwrap().id, "first");
|
|
}
|
|
|
|
#[test]
|
|
fn dismissal_boundaries_replay_history_and_a_fresh_active_epoch() {
|
|
let first = json!({"version":VERSION,"createSurface":{"surfaceId":"first","catalogId":CATALOG_ID,"components":[{"id":"root","component":"Text","text":"First"}]}}).to_string();
|
|
let update = json!({"version":VERSION,"updateComponents":{"surfaceId":"first","components":[{"id":"root","component":"Text","text":"First final"}]}}).to_string();
|
|
let second = json!({"version":VERSION,"createSurface":{"surfaceId":"second","catalogId":CATALOG_ID,"components":[{"id":"root","component":"Text","text":"Second"}]}}).to_string();
|
|
let records = [
|
|
(1, 10, false, first.as_str()),
|
|
(2, 11, false, update.as_str()),
|
|
(3, 12, true, "{}"),
|
|
(4, 13, false, second.as_str()),
|
|
];
|
|
let (history, active, errors) = replay_epochs(records);
|
|
assert!(errors.is_empty());
|
|
assert_eq!(history.len(), 1);
|
|
assert_eq!(history[0].active_surface().unwrap().id, "first");
|
|
assert_eq!(
|
|
history[0].active_surface().unwrap().components["root"]["text"],
|
|
"First final"
|
|
);
|
|
assert_eq!(active.active_surface().unwrap().id, "second");
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_validation_and_actions_use_current_local_data() {
|
|
let mut store = Store::default();
|
|
apply(&mut store, json!({"version":VERSION,"createSurface":{"surfaceId":"s","catalogId":CATALOG_ID,"sendDataModel":true}})).unwrap();
|
|
apply(&mut store, json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"label","component":"Text","text":"Send"},{"id":"root","component":"Button","child":"label","checks":[{"condition":{"call":"required","args":{"value":{"path":"/name"}}},"message":"Name required"}],"action":{"event":{"name":"submit","context":{"name":{"path":"/name"}}}}}]}})).unwrap();
|
|
assert_eq!(
|
|
store.action("s", "root", None).unwrap_err(),
|
|
"Name required"
|
|
);
|
|
let raw = store.local_update("s", "/name", json!("Ada")).unwrap();
|
|
assert!(raw.contains("updateDataModel"));
|
|
let action = store.action("s", "root", None).unwrap();
|
|
assert_eq!(action.pointer("/action/context/name"), Some(&json!("Ada")));
|
|
let metadata = store.client_metadata();
|
|
assert_eq!(
|
|
metadata.pointer("/a2uiClientDataModel/surfaces/s/name"),
|
|
Some(&json!("Ada"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_parser_only_accepts_complete_a2ui_jsonl_lines() {
|
|
let content = "Before\n```a2ui\n{\"version\":\"v1.0\",\"deleteSurface\":{\"surfaceId\":\"s\"}}\n{\"version\":";
|
|
let lines = extract_lines(content);
|
|
assert_eq!(lines.len(), 1);
|
|
assert!(lines[0].value.is_ok());
|
|
assert!(transcript_fallback(content).contains("Deleted interactive surface `s`"));
|
|
}
|
|
|
|
#[test]
|
|
fn v1_action_and_function_round_trips_follow_current_schema() {
|
|
let mut store = Store::default();
|
|
apply(
|
|
&mut store,
|
|
json!({
|
|
"version": VERSION,
|
|
"createSurface": {
|
|
"surfaceId": "s",
|
|
"catalogId": CATALOG_ID,
|
|
"dataModel": {"result": null},
|
|
"components": [
|
|
{"id":"label","component":"Text","text":"Run"},
|
|
{"id":"root","component":"Button","child":"label","action":{"event":{
|
|
"name":"run","wantResponse":true,"responsePath":"/result"
|
|
}}}
|
|
]
|
|
}
|
|
}),
|
|
)
|
|
.unwrap();
|
|
let action = store.action("s", "root", None).unwrap();
|
|
assert!(action.pointer("/action/timestamp").is_some());
|
|
let action_id = action["action"]["actionId"].as_str().unwrap();
|
|
let response = apply(
|
|
&mut store,
|
|
json!({
|
|
"version": VERSION,
|
|
"actionId": action_id,
|
|
"actionResponse": {"value": "done"}
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(response.raws.len(), 2);
|
|
assert_eq!(store.surface("s").unwrap().data["result"], "done");
|
|
|
|
let applied = apply(
|
|
&mut store,
|
|
json!({
|
|
"version": VERSION,
|
|
"functionCallId": "f1",
|
|
"wantResponse": true,
|
|
"callFunction": {"call":"formatNumber","args":{"value":1234.5,"decimals":1}}
|
|
}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
applied.reply.unwrap().pointer("/functionResponse/value"),
|
|
Some(&json!("1,234.5"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_catalog_and_validator_cover_the_same_components() {
|
|
let catalog = ds4_catalog();
|
|
assert_eq!(catalog["catalogId"], CATALOG_ID);
|
|
let components = catalog["components"].as_object().unwrap();
|
|
for component in BASIC_COMPONENTS {
|
|
assert!(components.contains_key(*component));
|
|
}
|
|
for component in [
|
|
"Chart", "Table", "Metric", "Timeline", "Map", "MindMap", "Form",
|
|
] {
|
|
assert!(components.contains_key(component));
|
|
}
|
|
let functions = catalog["functions"].as_object().unwrap();
|
|
for function in FUNCTIONS.iter().filter(|function| **function != "@index") {
|
|
assert!(functions.contains_key(*function));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn all_basics_compose_into_one_surface() {
|
|
let mut store = Store::default();
|
|
apply(
|
|
&mut store,
|
|
json!({
|
|
"version": VERSION,
|
|
"createSurface": {
|
|
"surfaceId": "composed",
|
|
"catalogId": CATALOG_ID,
|
|
"sendDataModel": true,
|
|
"dataModel": {
|
|
"name": "Ada",
|
|
"active": true,
|
|
"priority": 3,
|
|
"due": "2026-08-01",
|
|
"language": ["rust"],
|
|
"items": [{"label": "Renderer"}, {"label": "Validator"}]
|
|
},
|
|
"components": [
|
|
{"id":"root","component":"Card","child":"layout"},
|
|
{"id":"layout","component":"Column","children":["title","identity","divider","controls","tabs","items","media","modal"]},
|
|
{"id":"title","component":"Text","text":"**Project dashboard**"},
|
|
{"id":"identity","component":"Row","children":["icon","avatar"],"align":"center"},
|
|
{"id":"icon","component":"Icon","name":"accountCircle"},
|
|
{"id":"avatar","component":"Image","url":"https://example.com/avatar.png","description":"Project owner","variant":"avatar"},
|
|
{"id":"divider","component":"Divider","axis":"horizontal"},
|
|
{"id":"controls","component":"Row","children":["name","active","priority","due","language"]},
|
|
{"id":"name","component":"TextField","label":"Name","value":{"path":"/name"},"weight":2},
|
|
{"id":"active","component":"CheckBox","label":"Active","value":{"path":"/active"}},
|
|
{"id":"priority","component":"Slider","label":"Priority","value":{"path":"/priority"},"min":1,"max":5},
|
|
{"id":"due","component":"DateTimeInput","label":"Due","value":{"path":"/due"},"enableDate":true},
|
|
{"id":"language","component":"ChoicePicker","label":"Language","options":[{"label":"Rust","value":"rust"},{"label":"Python","value":"python"}],"value":{"path":"/language"},"variant":"mutuallyExclusive","displayStyle":"chips","filterable":true},
|
|
{"id":"tabs","component":"Tabs","tabs":[{"title":"Overview","child":"chart"},{"title":"Details","child":"details"}]},
|
|
{"id":"chart","component":"Chart","chartType":"donut","series":[{"label":"Done","value":2},{"label":"Open","value":1}]},
|
|
{"id":"details","component":"Card","child":"details-text"},
|
|
{"id":"details-text","component":"Text","text":"All systems operational."},
|
|
{"id":"items","component":"List","children":{"path":"/items","componentId":"item"}},
|
|
{"id":"item","component":"Text","text":{"path":"label"}},
|
|
{"id":"media","component":"Row","children":["video","audio"]},
|
|
{"id":"video","component":"Video","url":"https://example.com/demo.mp4","posterUrl":"https://example.com/poster.jpg"},
|
|
{"id":"audio","component":"AudioPlayer","url":"https://example.com/demo.mp3","description":"Project update"},
|
|
{"id":"modal","component":"Modal","trigger":"open","content":"dialog"},
|
|
{"id":"open","component":"Button","child":"open-label","variant":"primary","action":{"event":{"name":"openDetails","context":{"name":{"path":"/name"}}}}},
|
|
{"id":"open-label","component":"Text","text":"Open details"},
|
|
{"id":"dialog","component":"Card","child":"dialog-body"},
|
|
{"id":"dialog-body","component":"Text","text":"Composed modal content"}
|
|
]
|
|
}
|
|
}),
|
|
)
|
|
.unwrap();
|
|
|
|
let surface = store.surface("composed").unwrap();
|
|
let kinds = validate_surface_composition(surface).unwrap();
|
|
for component in BASIC_COMPONENTS {
|
|
assert!(kinds.contains(*component), "missing {component}");
|
|
}
|
|
assert!(kinds.contains("Chart"));
|
|
assert!(
|
|
store
|
|
.image_urls()
|
|
.any(|url| url == "https://example.com/poster.jpg")
|
|
);
|
|
|
|
let mut broken = surface.clone();
|
|
broken.components.insert(
|
|
"root".into(),
|
|
json!({"id":"root","component":"Card","child":"missing"}),
|
|
);
|
|
assert_eq!(
|
|
validate_surface_composition(&broken).unwrap_err(),
|
|
"referenced component `missing` is missing"
|
|
);
|
|
broken.components.insert(
|
|
"root".into(),
|
|
json!({"id":"root","component":"Card","child":"root"}),
|
|
);
|
|
assert_eq!(
|
|
validate_surface_composition(&broken).unwrap_err(),
|
|
"cyclic component reference at `root`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_validation_rejects_invalid_variants_and_function_arguments() {
|
|
let mut store = Store::default();
|
|
apply(
|
|
&mut store,
|
|
json!({"version":VERSION,"createSurface":{"surfaceId":"s","catalogId":CATALOG_ID}}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
apply(&mut store, json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Text","text":"x","variant":"h1"}]}})).unwrap_err(),
|
|
"component `root` has invalid variant `h1`; expected one of: caption, body"
|
|
);
|
|
assert!(apply(&mut store, json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Text","text":{"call":"formatDate","args":{"value":"2026-01-01T00:00:00Z"}}}]}})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_accepts_pie_donut_and_heatmap_charts() {
|
|
let mut store = Store::default();
|
|
apply(
|
|
&mut store,
|
|
json!({"version":VERSION,"createSurface":{"surfaceId":"s","catalogId":CATALOG_ID}}),
|
|
)
|
|
.unwrap();
|
|
for chart_type in ["pie", "donut", "heatmap"] {
|
|
apply(
|
|
&mut store,
|
|
json!({"version":VERSION,"updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Chart","chartType":chart_type,"series":[{"label":"rs","value":41}]}]}}),
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn basic_formatters_follow_catalog_examples() {
|
|
let date = time::OffsetDateTime::parse(
|
|
"2026-03-16T14:30:00Z",
|
|
&time::format_description::well_known::Rfc3339,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(format_date(date, "EEEE, d MMMM"), "Monday, 16 March");
|
|
let data = json!({"name":"Ada","currentDate":"2026-03-16T14:30:00Z"});
|
|
assert_eq!(
|
|
evaluate(
|
|
"formatString",
|
|
&json!({"value":"Hello ${/name}: ${formatDate(value:${/currentDate}, format:'MMM dd, yyyy')} \\${literal}"}),
|
|
&data,
|
|
&data,
|
|
)
|
|
.unwrap(),
|
|
json!("Hello Ada: Mar 16, 2026 ${literal}")
|
|
);
|
|
}
|
|
}
|