Skip to main content

ferro_json_ui/
data.rs

1//! Data path resolution for JSON-UI.
2//!
3//! Resolves slash-separated paths against JSON values, enabling components
4//! to reference dynamic data from handler payloads. Path format: `/segment/segment/...`
5//! where segments are object keys or array indices (numeric strings).
6
7use serde_json::Value;
8
9/// Resolves a slash-separated path against a JSON value.
10///
11/// Path format: `/segment/segment/...` where each segment is an object key
12/// or array index (numeric string). Leading slash is required for non-empty
13/// paths. Empty path or `"/"` returns the root value. Returns `None` if any
14/// segment fails to resolve.
15///
16/// Consumed by the Phase 116 form-control renderers (data_path → checked
17/// attribute for Checkbox and Switch) and by the data-display renderers
18/// (data_path → row array for Table and DataTable).
19pub(crate) fn resolve_path<'a>(data: &'a Value, path: &str) -> Option<&'a Value> {
20    if path.is_empty() || path == "/" {
21        return Some(data);
22    }
23
24    let trimmed = path.strip_prefix('/').unwrap_or(path);
25    let segments: Vec<&str> = trimmed.split('/').collect();
26
27    let mut current = data;
28    for segment in segments {
29        if segment.is_empty() {
30            continue;
31        }
32        match current {
33            Value::Object(map) => {
34                current = map.get(segment)?;
35            }
36            Value::Array(arr) => {
37                let index: usize = segment.parse().ok()?;
38                current = arr.get(index)?;
39            }
40            _ => return None,
41        }
42    }
43
44    Some(current)
45}
46
47/// Resolves a path and converts the result to a string representation.
48///
49/// For `String` values, returns the string directly. For numbers and booleans,
50/// uses `to_string()`. For `null`, returns `None`. For objects and arrays,
51/// returns their JSON serialization.
52///
53/// Consumed by the Phase 116 Input and Select renderers to resolve
54/// `data_path` into a `value=""` / `selected` attribute.
55pub(crate) fn resolve_path_string(data: &Value, path: &str) -> Option<String> {
56    let value = resolve_path(data, path)?;
57    match value {
58        Value::String(s) => Some(s.clone()),
59        Value::Number(n) => Some(n.to_string()),
60        Value::Bool(b) => Some(b.to_string()),
61        Value::Null => None,
62        Value::Array(_) | Value::Object(_) => serde_json::to_string(value).ok(),
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use serde_json::json;
70
71    #[test]
72    fn simple_key_resolution() {
73        let data = json!({"name": "Alice"});
74        assert_eq!(resolve_path(&data, "/name"), Some(&json!("Alice")));
75    }
76
77    #[test]
78    fn nested_key_resolution() {
79        let data = json!({"user": {"name": "Bob"}});
80        assert_eq!(resolve_path(&data, "/user/name"), Some(&json!("Bob")));
81    }
82
83    #[test]
84    fn array_index_resolution() {
85        let data = json!({"users": [{"name": "Carol"}]});
86        assert_eq!(resolve_path(&data, "/users/0/name"), Some(&json!("Carol")));
87    }
88
89    #[test]
90    fn missing_key_returns_none() {
91        let data = json!({"name": "Alice"});
92        assert_eq!(resolve_path(&data, "/missing"), None);
93    }
94
95    #[test]
96    fn empty_path_returns_root() {
97        let data = json!({"name": "Alice"});
98        assert_eq!(resolve_path(&data, ""), Some(&data));
99    }
100
101    #[test]
102    fn root_slash_returns_root() {
103        let data = json!({"name": "Alice"});
104        assert_eq!(resolve_path(&data, "/"), Some(&data));
105    }
106
107    #[test]
108    fn numeric_value_resolution() {
109        let data = json!({"count": 42});
110        let result = resolve_path(&data, "/count");
111        assert_eq!(result, Some(&json!(42)));
112        assert!(result.unwrap().is_number());
113    }
114
115    #[test]
116    fn boolean_resolution() {
117        let data = json!({"active": true});
118        let result = resolve_path(&data, "/active");
119        assert_eq!(result, Some(&json!(true)));
120        assert!(result.unwrap().is_boolean());
121    }
122
123    #[test]
124    fn null_value_resolve_path() {
125        let data = json!({"deleted_at": null});
126        let result = resolve_path(&data, "/deleted_at");
127        assert_eq!(result, Some(&Value::Null));
128    }
129
130    #[test]
131    fn null_value_resolve_path_string_returns_none() {
132        let data = json!({"deleted_at": null});
133        assert_eq!(resolve_path_string(&data, "/deleted_at"), None);
134    }
135
136    #[test]
137    fn deep_nesting() {
138        let data = json!({"a": {"b": {"c": {"d": "deep"}}}});
139        assert_eq!(resolve_path(&data, "/a/b/c/d"), Some(&json!("deep")));
140    }
141
142    #[test]
143    fn invalid_array_index_returns_none() {
144        let data = json!({"items": [1, 2, 3]});
145        assert_eq!(resolve_path(&data, "/items/5"), None);
146        assert_eq!(resolve_path(&data, "/items/abc"), None);
147    }
148
149    #[test]
150    fn resolve_path_string_for_string() {
151        let data = json!({"name": "Alice"});
152        assert_eq!(
153            resolve_path_string(&data, "/name"),
154            Some("Alice".to_string())
155        );
156    }
157
158    #[test]
159    fn resolve_path_string_for_number() {
160        let data = json!({"count": 42});
161        assert_eq!(resolve_path_string(&data, "/count"), Some("42".to_string()));
162    }
163
164    #[test]
165    fn resolve_path_string_for_boolean() {
166        let data = json!({"active": true});
167        assert_eq!(
168            resolve_path_string(&data, "/active"),
169            Some("true".to_string())
170        );
171    }
172
173    #[test]
174    fn resolve_path_string_for_object() {
175        let data = json!({"user": {"name": "Alice"}});
176        let result = resolve_path_string(&data, "/user");
177        assert_eq!(result, Some(r#"{"name":"Alice"}"#.to_string()));
178    }
179
180    #[test]
181    fn resolve_path_string_for_array() {
182        let data = json!({"items": [1, 2, 3]});
183        let result = resolve_path_string(&data, "/items");
184        assert_eq!(result, Some("[1,2,3]".to_string()));
185    }
186
187    #[test]
188    fn resolve_path_string_missing_returns_none() {
189        let data = json!({"name": "Alice"});
190        assert_eq!(resolve_path_string(&data, "/missing"), None);
191    }
192
193    #[test]
194    fn resolve_path_on_non_object_non_array() {
195        let data = json!("just a string");
196        assert_eq!(resolve_path(&data, "/anything"), None);
197    }
198
199    #[test]
200    fn nested_array_access() {
201        let data = json!({"matrix": [[1, 2], [3, 4]]});
202        assert_eq!(resolve_path(&data, "/matrix/1/0"), Some(&json!(3)));
203    }
204}