Skip to main content

gsm_core/adaptivecards/
canonical.rs

1use serde_json::{Map, Value};
2use std::collections::BTreeMap;
3use thiserror::Error;
4
5/// Canonical representation of an Adaptive Card payload used for deterministic comparisons.
6#[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    // Enforce canonical casing and explicit version.
68    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    // Re-run canonicalization on the stored content to guarantee ordering stability even if the
79    // caller mutated the value after canonicalize().
80    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}