gsm_core/adaptivecards/
canonical.rs1use serde_json::{Map, Value};
2use std::collections::BTreeMap;
3use thiserror::Error;
4
5#[derive(Debug, Clone, PartialEq)]
7pub struct CanonicalCard {
8 pub version: String,
9 pub content: Value,
10}
11
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum CanonicalizeError {
14 #[error("adaptive card payload must be an object")]
15 NotObject,
16 #[error("adaptive card missing required 'type' field")]
17 MissingType,
18 #[error("adaptive card type must be 'AdaptiveCard'")]
19 InvalidType,
20 #[error("adaptive card version missing or empty")]
21 MissingVersion,
22 #[error("adaptive card missing body array")]
23 MissingBody,
24 #[error("body must be an array when present")]
25 BodyNotArray,
26 #[error("actions must be an array when present")]
27 ActionsNotArray,
28 #[error("columns must be an array when present")]
29 ColumnsNotArray,
30}
31
32#[derive(Clone, Copy, PartialEq, Eq)]
33enum ArrayKind {
34 Generic,
35 Body,
36 Actions,
37 Columns,
38}
39
40impl CanonicalCard {
41 pub fn as_value(&self) -> Value {
42 self.content.clone()
43 }
44}
45
46pub fn canonicalize(value: Value) -> Result<CanonicalCard, CanonicalizeError> {
47 let obj = value.as_object().ok_or(CanonicalizeError::NotObject)?;
48 let type_field = obj
49 .get("type")
50 .and_then(|v| v.as_str())
51 .ok_or(CanonicalizeError::MissingType)?;
52 if !type_field.eq_ignore_ascii_case("adaptivecard") {
53 return Err(CanonicalizeError::InvalidType);
54 }
55 let version = obj
56 .get("version")
57 .and_then(|v| v.as_str())
58 .map(str::trim)
59 .filter(|v| !v.is_empty())
60 .map(str::to_string)
61 .unwrap_or_else(|| "1.6".to_string());
62 if version.trim().is_empty() {
63 return Err(CanonicalizeError::MissingVersion);
64 }
65
66 let mut normalized = canonicalize_object(obj)?;
67 normalized.insert("type".to_string(), Value::String("AdaptiveCard".into()));
69 normalized.insert("version".to_string(), Value::String(version.clone()));
70
71 Ok(CanonicalCard {
72 version,
73 content: Value::Object(normalized),
74 })
75}
76
77pub fn stable_json(card: &CanonicalCard) -> Value {
78 match canonicalize_value(&card.content, ArrayKind::Generic) {
81 Ok(value) => value,
82 Err(_) => card.content.clone(),
83 }
84}
85
86fn canonicalize_value(value: &Value, array_hint: ArrayKind) -> Result<Value, CanonicalizeError> {
87 match value {
88 Value::Object(map) => Ok(Value::Object(canonicalize_object(map)?)),
89 Value::Array(items) => canonicalize_array(items, array_hint),
90 other => {
91 if array_hint == ArrayKind::Generic {
92 Ok(other.clone())
93 } else {
94 Err(array_error(array_hint))
95 }
96 }
97 }
98}
99
100fn canonicalize_object(map: &Map<String, Value>) -> Result<Map<String, Value>, CanonicalizeError> {
101 let element_type = map.get("type").and_then(|v| v.as_str()).map(str::to_string);
102 let mut out: BTreeMap<String, Value> = BTreeMap::new();
103
104 for (key, value) in map {
105 let hint = match key.as_str() {
106 "body" => ArrayKind::Body,
107 "actions" => ArrayKind::Actions,
108 "columns" => ArrayKind::Columns,
109 _ => ArrayKind::Generic,
110 };
111 let mut canonical = canonicalize_value(value, hint)?;
112 if matches!(element_type.as_deref(), Some("TextBlock"))
113 && key == "text"
114 && let Some(text) = canonical.as_str()
115 {
116 canonical = Value::String(text.trim().to_string());
117 }
118 out.insert(key.clone(), canonical);
119 }
120
121 if matches!(element_type.as_deref(), Some("TextBlock")) && !out.contains_key("wrap") {
122 out.insert("wrap".into(), Value::Bool(true));
123 }
124
125 Ok(out.into_iter().collect())
126}
127
128fn canonicalize_array(items: &[Value], kind: ArrayKind) -> Result<Value, CanonicalizeError> {
129 let mut normalized: Vec<Value> = Vec::with_capacity(items.len());
130 for value in items {
131 normalized.push(canonicalize_value(value, ArrayKind::Generic)?);
132 }
133
134 if matches!(
135 kind,
136 ArrayKind::Body | ArrayKind::Actions | ArrayKind::Columns
137 ) {
138 normalized.sort_by_key(stable_value_key);
139 }
140
141 Ok(Value::Array(normalized))
142}
143
144fn stable_value_key(value: &Value) -> String {
145 match value {
146 Value::Object(map) => {
147 let type_hint = map.get("type").and_then(|v| v.as_str()).unwrap_or_default();
148 let label = map
149 .get("id")
150 .or_else(|| map.get("title"))
151 .or_else(|| map.get("text"))
152 .and_then(|v| v.as_str())
153 .unwrap_or_default();
154 let serialized = serde_json::to_string(value).unwrap_or_default();
155 format!("{type_hint}:{label}:{serialized}")
156 }
157 other => other.to_string(),
158 }
159}
160
161fn array_error(kind: ArrayKind) -> CanonicalizeError {
162 match kind {
163 ArrayKind::Body => CanonicalizeError::BodyNotArray,
164 ArrayKind::Actions => CanonicalizeError::ActionsNotArray,
165 ArrayKind::Columns => CanonicalizeError::ColumnsNotArray,
166 ArrayKind::Generic => CanonicalizeError::NotObject,
167 }
168}