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> = 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, pub(crate) data: Value, pub(crate) owner_message_id: i32, } #[derive(Clone, Default)] pub(crate) struct Store { surfaces: BTreeMap, surface_order: Vec, pending_actions: BTreeMap)>, next_action_id: u64, } #[derive(Debug)] pub(crate) struct ExtractedLine { pub(crate) raw: String, pub(crate) value: Result, } #[derive(Debug)] pub(crate) struct Applied { pub(crate) raws: Vec, pub(crate) reply: Option, pub(crate) open_url: Option, } pub(crate) fn replay_epochs<'a>( records: impl IntoIterator, ) -> (Vec, Store, Vec) { 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, String> { fn visit( surface: &Surface, id: &str, ancestors: &mut BTreeSet, visited: &mut BTreeSet, kinds: &mut BTreeSet, ) -> 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 { 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 + '_ { 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 { 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 { 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 { 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::>(); 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 { 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 { 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::>(); 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 { 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::(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(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, data: &Value) -> Option { 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, 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, 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, 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 = 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, 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, 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, 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) -> 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 { resolve_at(value, data, data) } fn resolve_at(value: &Value, data: &Value, context: &Value) -> Result { 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::, _>>() .map(Value::Array), Value::Object(values) => values .iter() .map(|(key, value)| Ok((key.clone(), resolve_at(value, data, context)?))) .collect::, String>>() .map(Value::Object), _ => Ok(value.clone()), } } fn evaluate(call: &str, args: &Value, data: &Value, context: &Value) -> Result { 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::, _>>()?; 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 { 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 { 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 { 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 { 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 { 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) -> 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::>(); 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::() .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::() .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 .and_then(Value::as_object) .ok_or_else(|| format!("{name} must be an object")) } fn reject_unknown(object: &Map, 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, 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, key: &str) -> Result, 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 { 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}") ); } }