Skip to main content

webui_state/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4extern crate serde_json;
5
6use std::borrow::Cow;
7
8use serde_json::Value;
9
10/// Objects with at most this many members are searched by direct comparison.
11///
12/// State objects reached from templates are usually small records (list items,
13/// nested settings). Comparing a handful of keys is cheaper than hashing the
14/// lookup key, which is what an order-preserving `serde_json::Map` does.
15const LINEAR_MEMBER_LOOKUP_MAX: usize = 8;
16
17#[inline]
18fn object_member<'a>(map: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a Value> {
19    if map.len() <= LINEAR_MEMBER_LOOKUP_MAX {
20        return map
21            .iter()
22            .find_map(|(name, value)| (name == key).then_some(value));
23    }
24    map.get(key)
25}
26
27/// Finds a value in a JSON object by dotted path and returns a borrowed value when possible.
28///
29/// Most lookups borrow directly from `state`. Synthetic values such as array
30/// and string `.length` are returned as owned values because they do not exist
31/// in the source JSON tree.
32#[must_use]
33pub fn find_value_by_dotted_path_ref<'a>(path: &str, state: &'a Value) -> Option<Cow<'a, Value>> {
34    let mut current_value: &Value = state;
35
36    for part in path.split('.') {
37        match current_value {
38            Value::Object(map) => {
39                current_value = object_member(map, part)?;
40            }
41            Value::Array(arr) if part == "length" => {
42                return Some(Cow::Owned(Value::Number(serde_json::Number::from(
43                    arr.len(),
44                ))));
45            }
46            Value::String(s) if part == "length" => {
47                return Some(Cow::Owned(Value::Number(serde_json::Number::from(s.len()))));
48            }
49            _ => return None,
50        }
51    }
52
53    Some(Cow::Borrowed(current_value))
54}
55
56/// Finds a value in a JSON object by dotted path and returns an owned value.
57///
58/// Prefer [`find_value_by_dotted_path_ref`] in render-time hot paths so state
59/// values can be borrowed without cloning.
60#[must_use]
61pub fn find_value_by_dotted_path(path: &str, state: &Value) -> Option<Value> {
62    find_value_by_dotted_path_ref(path, state).map(Cow::into_owned)
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use webui_test_utils::test_json;
69
70    #[test]
71    fn test_find_value_by_dotted_path() {
72        // Create a JSON object
73        let data = test_json!({
74            "name": {
75                "first": "John",
76                "last": "Doe"
77            },
78            "favorite": {
79                "categories": {
80                    "movies": ["The Matrix", "The Godfather"],
81                    "music": ["Jazz", "Blues"]
82                }
83            },
84            "age": 30
85        });
86
87        // Test a successful path
88        let value = find_value_by_dotted_path("name.first", &data);
89        assert_eq!(
90            value,
91            Some(Value::String("John".to_string())),
92            "Failed to get string."
93        );
94
95        // Test a path that leads to single string
96        let value = find_value_by_dotted_path("age", &data);
97        assert_eq!(
98            value,
99            Some(Value::Number(serde_json::Number::from(30))),
100            "Failed to get number."
101        );
102
103        // Test a path that leads to an array
104        let value = find_value_by_dotted_path("favorite.categories.music", &data);
105        assert_eq!(
106            value,
107            Some(Value::Array(vec![
108                Value::String("Jazz".to_string()),
109                Value::String("Blues".to_string())
110            ])),
111            "Failed to get array."
112        );
113
114        // Test a non-existent path
115        let value = find_value_by_dotted_path("name.middle", &data);
116        assert_eq!(value, None, "Failed to handle non-existent path.");
117
118        // Test a path that leads to a non-object value
119        let value = find_value_by_dotted_path("age.years", &data);
120        assert_eq!(
121            value, None,
122            "Failed to handle path that leads to a non-object value."
123        );
124
125        // Get length of array.
126        let value = find_value_by_dotted_path("favorite.categories.music.length", &data);
127        assert_eq!(
128            value,
129            Some(Value::Number(serde_json::Number::from(2))),
130            "Failed to get length of array."
131        );
132    }
133
134    #[test]
135    fn test_nested_nested_path() {
136        let data = test_json!({
137            "user": {
138                "profile": {
139                    "nameObj": {
140                        "first": "John"
141                    }
142                }
143            }
144        });
145        let value = find_value_by_dotted_path("user.profile.nameObj.first", &data);
146        assert_eq!(value, Some(Value::String("John".to_string())));
147    }
148
149    #[test]
150    fn test_empty_string_path() {
151        let data = test_json!({ "key": "value" });
152        let value = find_value_by_dotted_path("", &data);
153        assert_eq!(value, None);
154    }
155
156    #[test]
157    fn test_path_with_spaces() {
158        let data = test_json!({
159            "user": {
160                "profile": {
161                    "name": "Alice"
162                }
163            }
164        });
165        let value = find_value_by_dotted_path("user.profile.non existent", &data);
166        assert_eq!(value, None);
167    }
168
169    #[test]
170    fn test_numeric_string_token() {
171        let data = test_json!({ "numeric_string": "123" });
172        let value = find_value_by_dotted_path("numeric_string", &data);
173        assert_eq!(value, Some(Value::String("123".to_string())));
174    }
175
176    #[test]
177    fn test_length_on_empty_list() {
178        let data = test_json!({ "empty_list": [] });
179        let value = find_value_by_dotted_path("empty_list.length", &data);
180        assert_eq!(value, Some(Value::Number(serde_json::Number::from(0))));
181    }
182
183    #[test]
184    fn test_length_on_nonempty_list() {
185        let data = test_json!({ "items": [1, 2, 3] });
186        let value = find_value_by_dotted_path("items.length", &data);
187        assert_eq!(value, Some(Value::Number(serde_json::Number::from(3))));
188    }
189
190    #[test]
191    fn test_member_lookup_on_both_sides_of_linear_limit() {
192        for size in [LINEAR_MEMBER_LOOKUP_MAX, LINEAR_MEMBER_LOOKUP_MAX + 1] {
193            let mut map = serde_json::Map::new();
194            for index in 0..size {
195                map.insert(format!("key{index}"), Value::from(index));
196            }
197            let data = Value::Object(map);
198            for index in 0..size {
199                assert_eq!(
200                    find_value_by_dotted_path(&format!("key{index}"), &data),
201                    Some(Value::from(index)),
202                    "size {size}"
203                );
204            }
205            assert_eq!(find_value_by_dotted_path("key", &data), None, "size {size}");
206        }
207    }
208
209    #[test]
210    fn test_length_named_property() {
211        let data = test_json!({
212            "nestedlength": {
213                "length": "some value"
214            }
215        });
216        let value = find_value_by_dotted_path("nestedlength.length", &data);
217        assert_eq!(value, Some(Value::String("some value".to_string())));
218    }
219
220    #[test]
221    fn test_length_on_nested_list() {
222        let data = test_json!({
223            "nested": {
224                "data": ["a", "b"]
225            }
226        });
227        let value = find_value_by_dotted_path("nested.data.length", &data);
228        assert_eq!(value, Some(Value::Number(serde_json::Number::from(2))));
229    }
230
231    #[test]
232    fn test_nonexistent_path_with_length() {
233        let data = test_json!({ "existing": "value" });
234        let value = find_value_by_dotted_path("non_existent.length", &data);
235        assert_eq!(value, None);
236    }
237
238    #[test]
239    fn test_length_on_string() {
240        let data = test_json!({ "str_val": "hello" });
241        let value = find_value_by_dotted_path("str_val.length", &data);
242        assert_eq!(value, Some(Value::Number(serde_json::Number::from(5))));
243    }
244
245    #[test]
246    fn test_length_on_string_matches_nodejs() {
247        // NodeJS: 'string_value.length' on "test" → 4
248        let data = test_json!({ "string_value": "test" });
249        let value = find_value_by_dotted_path("string_value.length", &data);
250        assert_eq!(value, Some(Value::Number(serde_json::Number::from(4))));
251    }
252
253    #[test]
254    fn test_array_index_not_resolved() {
255        let data = test_json!({
256            "foo": {
257                "bar": [10, 20, 30]
258            }
259        });
260        let value = find_value_by_dotted_path("foo.bar.0", &data);
261        assert_eq!(value, None);
262    }
263
264    #[test]
265    fn test_negative_array_index_not_resolved() {
266        let data = test_json!({
267            "foo": {
268                "bar": [10, 20, 30]
269            }
270        });
271        let value = find_value_by_dotted_path("foo.bar.-1", &data);
272        assert_eq!(value, None);
273    }
274
275    #[test]
276    fn test_numeric_property_on_object() {
277        let data = test_json!({
278            "foo": {
279                "barObj": {
280                    "0": "not an array"
281                }
282            }
283        });
284        let value = find_value_by_dotted_path("foo.barObj.0", &data);
285        assert_eq!(value, Some(Value::String("not an array".to_string())));
286    }
287
288    #[test]
289    fn test_property_value_zero() {
290        let data = test_json!({
291            "foo": {
292                "zero": 0
293            }
294        });
295        let value = find_value_by_dotted_path("foo.zero", &data);
296        assert_eq!(value, Some(Value::Number(serde_json::Number::from(0))));
297    }
298
299    #[test]
300    fn test_property_value_false() {
301        let data = test_json!({
302            "foo": {
303                "isFalse": false
304            }
305        });
306        let value = find_value_by_dotted_path("foo.isFalse", &data);
307        assert_eq!(value, Some(Value::Bool(false)));
308    }
309
310    #[test]
311    fn test_property_value_empty_string() {
312        let data = test_json!({
313            "foo": {
314                "empty": ""
315            }
316        });
317        let value = find_value_by_dotted_path("foo.empty", &data);
318        assert_eq!(value, Some(Value::String("".to_string())));
319    }
320
321    #[test]
322    fn test_deeply_nested_property() {
323        let data = test_json!({
324            "a": {
325                "b": {
326                    "c": {
327                        "d": {
328                            "e": {
329                                "f": 42
330                            }
331                        }
332                    }
333                }
334            }
335        });
336        let value = find_value_by_dotted_path("a.b.c.d.e.f", &data);
337        assert_eq!(value, Some(Value::Number(serde_json::Number::from(42))));
338    }
339
340    #[test]
341    fn test_property_with_special_char() {
342        let data = test_json!({
343            "foo": {
344                "$bar": 123
345            }
346        });
347        let value = find_value_by_dotted_path("foo.$bar", &data);
348        assert_eq!(value, Some(Value::Number(serde_json::Number::from(123))));
349    }
350
351    #[test]
352    fn test_property_named_tostring() {
353        let data = test_json!({
354            "foo": {
355                "toString": "stringify"
356            }
357        });
358        let value = find_value_by_dotted_path("foo.toString", &data);
359        assert_eq!(value, Some(Value::String("stringify".to_string())));
360    }
361
362    #[test]
363    fn test_intermediate_property_not_found() {
364        let data = test_json!({
365            "foo": {
366                "bar": "baz"
367            }
368        });
369        let value = find_value_by_dotted_path("foo.undefinedMid.baz", &data);
370        assert_eq!(value, None);
371    }
372
373    #[test]
374    fn test_string_in_deeply_nested_path() {
375        let data = test_json!({
376            "first": {
377                "second": {
378                    "third": {
379                        "data": "deep_string"
380                    }
381                }
382            }
383        });
384        let value = find_value_by_dotted_path("first.second.third.data", &data);
385        assert_eq!(value, Some(Value::String("deep_string".to_string())));
386    }
387
388    #[test]
389    fn test_double_value_directly() {
390        let data = test_json!({ "double_value": 1.23 });
391        let value = find_value_by_dotted_path("double_value", &data);
392        assert_eq!(
393            value,
394            Some(Value::Number(serde_json::Number::from_f64(1.23).unwrap()))
395        );
396    }
397
398    #[test]
399    fn test_ref_lookup_borrows_existing_value() {
400        let data = test_json!({
401            "user": {
402                "name": "Alice"
403            }
404        });
405        let value = find_value_by_dotted_path_ref("user.name", &data).expect("value");
406        let expected = data
407            .get("user")
408            .and_then(|user| user.get("name"))
409            .expect("expected value");
410
411        assert!(matches!(value, std::borrow::Cow::Borrowed(_)));
412        assert!(std::ptr::eq(value.as_ref(), expected));
413    }
414
415    #[test]
416    fn test_ref_lookup_owns_synthetic_length() {
417        let data = test_json!({ "items": [1, 2, 3] });
418        let value = find_value_by_dotted_path_ref("items.length", &data).expect("value");
419
420        assert!(matches!(value, std::borrow::Cow::Owned(_)));
421        assert_eq!(value.as_ref(), &Value::Number(serde_json::Number::from(3)));
422    }
423}