Skip to main content

a2ui_base/model/
data_context.rs

1//! Scoped data access with dynamic value resolution.
2
3use std::collections::HashMap;
4
5use serde_json::Value;
6
7use super::data_model::DataModel;
8use crate::protocol::common_types::{
9    DynamicBoolean, DynamicBooleanCondition, DynamicNumber, DynamicString,
10    DynamicValue, FunctionCall,
11};
12
13/// A scoped window into the DataModel used during rendering.
14///
15/// Handles:
16/// - Absolute paths (starting with `/`)
17/// - Relative paths (no leading `/`, resolved against `base_path`)
18/// - Dynamic value resolution (literals, bindings, function calls)
19pub struct DataContext<'a> {
20    data_model: &'a DataModel,
21    base_path: String,
22    functions: &'a HashMap<String, Box<dyn crate::catalog::function_api::FunctionImplementation>>,
23    /// The 0-based index of the current item when this context was created for
24    /// a `ChildList::Template` iteration, or `None` outside a template list.
25    ///
26    /// Drives the `@index` system function (see
27    /// [`resolve_index`](Self::resolve_index)). Set by
28    /// [`ComponentContext::new`](crate::model::component_context::ComponentContext::new)
29    /// from the item's base path, and overridable via
30    /// [`with_template_index`](Self::with_template_index).
31    template_index: Option<usize>,
32}
33
34impl<'a> DataContext<'a> {
35    /// Create a new DataContext at the root scope.
36    pub fn new(
37        data_model: &'a DataModel,
38        functions: &'a HashMap<String, Box<dyn crate::catalog::function_api::FunctionImplementation>>,
39    ) -> Self {
40        Self {
41            data_model,
42            base_path: String::new(),
43            functions,
44            template_index: None,
45        }
46    }
47
48    /// Create a nested context for template iteration.
49    ///
50    /// The nested context starts with no template index; callers that need the
51    /// `@index` system function (e.g. when expanding a `ChildList::Template`)
52    /// set it via [`with_template_index`](Self::with_template_index).
53    pub fn nested(&self, relative_path: &str) -> DataContext<'a> {
54        let new_base = if self.base_path.is_empty() {
55            format!("/{}", relative_path)
56        } else {
57            format!("{}/{}", self.base_path, relative_path)
58        };
59        DataContext {
60            data_model: self.data_model,
61            base_path: new_base,
62            functions: self.functions,
63            template_index: None,
64        }
65    }
66
67    /// Return the template index for this context, if any.
68    pub fn template_index(&self) -> Option<usize> {
69        self.template_index
70    }
71
72    /// Set the template index (builder style). Returns `self` for chaining.
73    ///
74    /// `Some(i)` enables the `@index` system function for this context;
75    /// `None` disables it.
76    pub fn with_template_index(mut self, index: Option<usize>) -> Self {
77        self.template_index = index;
78        self
79    }
80
81    /// Set the template index in place.
82    pub fn set_template_index(&mut self, index: Option<usize>) {
83        self.template_index = index;
84    }
85
86    /// Resolve the `@index` system function against this context.
87    ///
88    /// Returns `None` when not inside a template iteration (the spec defines
89    /// `@index` as valid only within a list context, so callers treat `None` as
90    /// "no value"). The optional `offset` argument is a `DynamicNumber`
91    /// (literal / binding / function call) added to the 0-based index.
92    fn resolve_index(&self, args: &HashMap<String, Value>) -> Option<Value> {
93        self.template_index.map(|idx| {
94            let offset = args
95                .get("offset")
96                .map(|v| self.resolve_arg_value(v).as_f64().unwrap_or(0.0))
97                .unwrap_or(0.0);
98            serde_json::json!(idx as f64 + offset)
99        })
100    }
101
102    /// Get the current base path.
103    pub fn base_path(&self) -> &str {
104        &self.base_path
105    }
106
107    /// Resolve a possibly-relative pointer to an absolute JSON Pointer.
108    pub fn resolve_pointer(&self, path: &str) -> String {
109        if path.starts_with('/') {
110            path.to_string()
111        } else if path.is_empty() {
112            self.base_path.clone()
113        } else if self.base_path.is_empty() {
114            format!("/{}", path)
115        } else {
116            format!("{}/{}", self.base_path, path)
117        }
118    }
119
120    /// Get a value at a (possibly relative) path.
121    pub fn get(&self, path: &str) -> Option<Value> {
122        let pointer = self.resolve_pointer(path);
123        self.data_model.get(&pointer).cloned()
124    }
125
126    /// Resolve a DynamicString to its current string value.
127    pub fn resolve_dynamic_string(&self, ds: &DynamicString) -> String {
128        match ds {
129            DynamicString::Literal(s) => s.clone(),
130            DynamicString::Binding(b) => self.resolve_binding_to_string(&b.path),
131            DynamicString::Function(fc) => {
132                let result = self.execute_function(fc);
133                value_to_string(&result)
134            }
135        }
136    }
137
138    /// Resolve a DynamicNumber.
139    pub fn resolve_dynamic_number(&self, dn: &DynamicNumber) -> f64 {
140        match dn {
141            DynamicNumber::Literal(n) => *n,
142            DynamicNumber::Binding(b) => self
143                .resolve_binding(&b.path)
144                .and_then(|v| v.as_f64())
145                .unwrap_or(0.0),
146            DynamicNumber::Function(fc) => {
147                let result = self.execute_function(fc);
148                result.as_f64().unwrap_or(0.0)
149            }
150        }
151    }
152
153    /// Resolve a DynamicBoolean.
154    pub fn resolve_dynamic_boolean(&self, db: &DynamicBoolean) -> bool {
155        match db {
156            DynamicBoolean::Literal(b) => *b,
157            DynamicBoolean::Binding(b) => self
158                .resolve_binding(&b.path)
159                .and_then(|v| v.as_bool())
160                .unwrap_or(false),
161            DynamicBoolean::Function(fc) => {
162                let result = self.execute_function(fc);
163                result.as_bool().unwrap_or(false)
164            }
165        }
166    }
167
168    /// Resolve a DynamicBooleanCondition (same logic as DynamicBoolean).
169    pub fn resolve_dynamic_boolean_condition(&self, db: &DynamicBooleanCondition) -> bool {
170        match db {
171            DynamicBooleanCondition::Literal(b) => *b,
172            DynamicBooleanCondition::Binding(b) => self
173                .resolve_binding(&b.path)
174                .and_then(|v| v.as_bool())
175                .unwrap_or(false),
176            DynamicBooleanCondition::Function(fc) => {
177                let result = self.execute_function(fc);
178                result.as_bool().unwrap_or(false)
179            }
180        }
181    }
182
183    /// Resolve a DynamicValue to a serde_json::Value.
184    pub fn resolve_dynamic_value(&self, dv: &DynamicValue) -> Value {
185        match dv {
186            DynamicValue::String(s) => Value::String(s.clone()),
187            DynamicValue::Number(n) => serde_json::json!(*n),
188            DynamicValue::Boolean(b) => Value::Bool(*b),
189            DynamicValue::Array(arr) => Value::Array(arr.clone()),
190            DynamicValue::Binding(b) => self.resolve_binding(&b.path).unwrap_or(Value::Null),
191            DynamicValue::Function(fc) => self.execute_function(fc),
192        }
193    }
194
195    /// Resolve a binding path to a Value.
196    fn resolve_binding(&self, path: &str) -> Option<Value> {
197        let pointer = self.resolve_pointer(path);
198        self.data_model.get(&pointer).cloned()
199    }
200
201    /// Resolve a binding to a string (type coercion).
202    fn resolve_binding_to_string(&self, path: &str) -> String {
203        self.resolve_binding(path)
204            .map(|v| value_to_string(&v))
205            .unwrap_or_default()
206    }
207
208    /// Call a function by name with pre-resolved arguments.
209    ///
210    /// Returns `None` if the function is not found or execution fails.
211    pub fn call_function_by_name(
212        &self,
213        name: &str,
214        args: &HashMap<String, Value>,
215    ) -> Option<Value> {
216        // System function: @index — only meaningful inside a template iteration.
217        if name == "@index" {
218            return self.resolve_index(args);
219        }
220        let func = self.functions.get(name)?;
221        // Resolve each argument value (may contain bindings or nested calls).
222        let mut resolved_args = HashMap::new();
223        for (key, val) in args {
224            let resolved = self.resolve_arg_value(val);
225            resolved_args.insert(key.clone(), resolved);
226        }
227        func.execute(&resolved_args, self).ok()
228    }
229
230    /// Execute a function call.
231    fn execute_function(&self, fc: &FunctionCall) -> Value {
232        // System function: @index — only valid inside a template iteration.
233        if fc.call == "@index" {
234            return self.resolve_index(&fc.args).unwrap_or(Value::Null);
235        }
236        let Some(func) = self.functions.get(&fc.call) else {
237            return Value::Null;
238        };
239
240        // Resolve each argument (they can contain DynamicValues)
241        let mut resolved_args = HashMap::new();
242        for (key, val) in &fc.args {
243            // Try to resolve as a DynamicValue if it's an object with "path" or "call"
244            let resolved = self.resolve_arg_value(val);
245            resolved_args.insert(key.clone(), resolved);
246        }
247
248        match func.execute(&resolved_args, self) {
249            Ok(v) => v,
250            Err(_) => Value::Null,
251        }
252    }
253
254    /// Resolve an argument value that might be a DynamicValue.
255    fn resolve_arg_value(&self, val: &Value) -> Value {
256        if let Some(obj) = val.as_object() {
257            if let Some(path) = obj.get("path").and_then(|v| v.as_str()) {
258                return self.resolve_binding(path).unwrap_or(Value::Null);
259            }
260            if let Some(call) = obj.get("call").and_then(|v| v.as_str()) {
261                let args = obj
262                    .get("args")
263                    .and_then(|v| v.as_object())
264                    .map(|m| {
265                        m.iter()
266                            .map(|(k, v)| (k.clone(), self.resolve_arg_value(v)))
267                            .collect::<HashMap<_, _>>()
268                    })
269                    .unwrap_or_default();
270                let fc = FunctionCall {
271                    call: call.to_string(),
272                    args,
273                };
274                return self.execute_function(&fc);
275            }
276        }
277        val.clone()
278    }
279}
280
281/// Convert a serde_json::Value to a display string per A2UI type coercion rules.
282pub fn value_to_string(v: &Value) -> String {
283    match v {
284        Value::String(s) => s.clone(),
285        Value::Number(n) => n.to_string(),
286        Value::Bool(b) => b.to_string(),
287        Value::Null => String::new(),
288        Value::Array(_) | Value::Object(_) => v.to_string(),
289    }
290}
291
292#[cfg(test)]
293mod index_tests {
294    use super::*;
295    use crate::model::data_model::DataModel;
296    use crate::protocol::common_types::{DynamicNumber, FunctionCall};
297    use serde_json::json;
298
299    /// Build a 'static DataContext with the given template index and an empty
300    /// function table. (DataModel + HashMap are leaked — test-only.)
301    fn ctx(idx: Option<usize>) -> DataContext<'static> {
302        let dm = Box::leak(Box::new(DataModel::new()));
303        let fns = Box::leak(Box::new(HashMap::new()));
304        DataContext::new(dm, fns).with_template_index(idx)
305    }
306
307    fn index_call(args: &[(&str, Value)]) -> FunctionCall {
308        let mut map = HashMap::new();
309        for (k, v) in args {
310            map.insert((*k).to_string(), v.clone());
311        }
312        FunctionCall {
313            call: "@index".to_string(),
314            args: map,
315        }
316    }
317
318    #[test]
319    fn at_index_without_template_context_is_null() {
320        // Outside a template list the spec leaves @index undefined; the data
321        // context degrades to null → 0.0 for a numeric context.
322        let ctx = ctx(None);
323        let dn = DynamicNumber::Function(index_call(&[]));
324        assert_eq!(ctx.resolve_dynamic_number(&dn), 0.0);
325        // call_function_by_name returns None (no template context).
326        assert_eq!(ctx.call_function_by_name("@index", &HashMap::new()), None);
327    }
328
329    #[test]
330    fn at_index_returns_zero_based_index() {
331        let ctx = ctx(Some(2));
332        let dn = DynamicNumber::Function(index_call(&[]));
333        assert_eq!(ctx.resolve_dynamic_number(&dn), 2.0);
334    }
335
336    #[test]
337    fn at_index_with_literal_offset() {
338        let ctx = ctx(Some(2));
339        let dn = DynamicNumber::Function(index_call(&[("offset", json!(1))]));
340        assert_eq!(ctx.resolve_dynamic_number(&dn), 3.0);
341    }
342
343    #[test]
344    fn at_index_with_bound_offset() {
345        let dm = Box::leak(Box::new(DataModel::from_value(json!({"step": 10}))));
346        let fns = Box::leak(Box::new(HashMap::new()));
347        let ctx = DataContext::new(dm, fns).with_template_index(Some(0));
348        let dn = DynamicNumber::Function(index_call(&[("offset", json!({"path": "/step"}))]));
349        assert_eq!(ctx.resolve_dynamic_number(&dn), 10.0); // 0 + step(10)
350    }
351
352    #[test]
353    fn at_index_via_call_function_by_name() {
354        let ctx = ctx(Some(4));
355        let mut args = HashMap::new();
356        args.insert("offset".to_string(), json!(1));
357        assert_eq!(ctx.call_function_by_name("@index", &args), Some(json!(5.0)));
358    }
359
360    #[test]
361    fn at_index_zero_plus_offset() {
362        // First item with a 1-based offset → "1".
363        let ctx = ctx(Some(0));
364        let dn = DynamicNumber::Function(index_call(&[("offset", json!(1))]));
365        assert_eq!(ctx.resolve_dynamic_number(&dn), 1.0);
366    }
367}