Skip to main content

shelly/
form.rs

1use crate::escape_html;
2use serde_json::{Map, Value};
3use std::collections::BTreeMap;
4
5/// Parse bracket or dot paths into normalized path segments.
6///
7/// Examples:
8/// - `owner[name]` -> `["owner", "name"]`
9/// - `addresses[0][city]` -> `["addresses", "0", "city"]`
10/// - `owner.email` -> `["owner", "email"]`
11pub fn parse_path_segments(path: &str) -> Vec<String> {
12    if path.contains('[') {
13        let mut out = Vec::new();
14        let mut start = 0usize;
15        let bytes = path.as_bytes();
16        let mut index = 0usize;
17        while index < bytes.len() {
18            if bytes[index] == b'[' {
19                if start < index {
20                    out.push(path[start..index].to_string());
21                }
22                let close = path[index + 1..]
23                    .find(']')
24                    .map(|value| value + index + 1)
25                    .unwrap_or(bytes.len());
26                if index + 1 < close {
27                    out.push(path[index + 1..close].to_string());
28                }
29                index = close + 1;
30                start = index;
31            } else {
32                index += 1;
33            }
34        }
35        if start < bytes.len() {
36            out.push(path[start..].to_string());
37        }
38        return out;
39    }
40
41    path.split('.')
42        .map(str::trim)
43        .filter(|segment| !segment.is_empty())
44        .map(ToString::to_string)
45        .collect()
46}
47
48/// Normalize bracket/dot path notation into dotted notation.
49pub fn dot_path(path: &str) -> String {
50    parse_path_segments(path).join(".")
51}
52
53/// Shared helper for reading optional JSON string values.
54pub fn value_as_string(value: Option<&Value>) -> String {
55    value
56        .and_then(Value::as_str)
57        .unwrap_or_default()
58        .to_string()
59}
60
61/// Path-aware reader for nested form payloads.
62pub struct FormData<'a> {
63    root: &'a Value,
64}
65
66impl<'a> FormData<'a> {
67    pub fn new(root: &'a Value) -> Self {
68        Self { root }
69    }
70
71    pub fn value(&self, path: &str) -> Option<&'a Value> {
72        let segments = parse_path_segments(path);
73        if segments.is_empty() {
74            return Some(self.root);
75        }
76        let mut current = self.root;
77        for segment in segments {
78            match current {
79                Value::Object(map) => {
80                    current = map.get(&segment)?;
81                }
82                Value::Array(items) => {
83                    let index = segment.parse::<usize>().ok()?;
84                    current = items.get(index)?;
85                }
86                _ => return None,
87            }
88        }
89        Some(current)
90    }
91
92    pub fn string(&self, path: &str) -> String {
93        self.value(path)
94            .and_then(Value::as_str)
95            .unwrap_or_default()
96            .to_string()
97    }
98
99    pub fn object(&self, path: &str) -> Option<&'a Map<String, Value>> {
100        self.value(path).and_then(Value::as_object)
101    }
102
103    pub fn array(&self, path: &str) -> Option<&'a Vec<Value>> {
104        self.value(path).and_then(Value::as_array)
105    }
106}
107
108/// Path-keyed validation error bag for nested forms.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct ValidationErrors {
111    inner: BTreeMap<String, String>,
112}
113
114impl ValidationErrors {
115    pub fn is_empty(&self) -> bool {
116        self.inner.is_empty()
117    }
118
119    pub fn get(&self, path: &str) -> Option<&String> {
120        self.inner.get(path)
121    }
122
123    pub fn set(&mut self, path: &str, value: Option<String>) {
124        match value {
125            Some(message) => {
126                self.inner.insert(path.to_string(), message);
127            }
128            None => {
129                self.inner.remove(path);
130            }
131        }
132    }
133
134    pub fn remove(&mut self, path: &str) {
135        self.inner.remove(path);
136    }
137
138    /// Reindex array-path errors after removing/reordering rows.
139    ///
140    /// Keys are expected in dotted form, for example:
141    /// `addresses.0.street`, `addresses.1.city`.
142    pub fn reindex_array(&mut self, prefix: &str, len: usize) {
143        let mut rebuilt = BTreeMap::new();
144        let base = format!("{prefix}.");
145        for (key, value) in &self.inner {
146            if let Some(rest) = key.strip_prefix(&base) {
147                let mut parts = rest.splitn(2, '.');
148                if let (Some(index), Some(field)) = (parts.next(), parts.next()) {
149                    if let Ok(index) = index.parse::<usize>() {
150                        if index < len {
151                            rebuilt.insert(format!("{prefix}.{index}.{field}"), value.clone());
152                        }
153                        continue;
154                    }
155                }
156            }
157            rebuilt.insert(key.clone(), value.clone());
158        }
159        self.inner = rebuilt;
160    }
161
162    pub fn as_map(&self) -> &BTreeMap<String, String> {
163        &self.inner
164    }
165
166    pub fn html(&self, path: &str) -> String {
167        self.get(path)
168            .map(|message| {
169                format!(
170                    r#"<p class="error" role="alert">{}</p>"#,
171                    escape_html(message)
172                )
173            })
174            .unwrap_or_default()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::{dot_path, parse_path_segments, value_as_string, FormData, ValidationErrors};
181    use serde_json::json;
182
183    #[test]
184    fn parse_path_segments_supports_bracket_and_dot_paths() {
185        assert_eq!(
186            parse_path_segments("addresses[2][city]"),
187            vec!["addresses", "2", "city"]
188        );
189        assert_eq!(parse_path_segments("owner.email"), vec!["owner", "email"]);
190    }
191
192    #[test]
193    fn dotted_path_normalization_round_trips() {
194        assert_eq!(dot_path("owner[name]"), "owner.name");
195        assert_eq!(dot_path("addresses[1][street]"), "addresses.1.street");
196    }
197
198    #[test]
199    fn form_data_reads_nested_values() {
200        let payload = json!({
201            "owner": { "name": "Ada", "email": "ada@example.test" },
202            "addresses": [
203                { "street": "42 Logic Rd", "city": "Math City" }
204            ]
205        });
206        let form = FormData::new(&payload);
207        assert_eq!(form.string("owner[name]"), "Ada");
208        assert_eq!(form.string("owner.email"), "ada@example.test");
209        assert_eq!(form.string("addresses[0][city]"), "Math City");
210    }
211
212    #[test]
213    fn value_as_string_returns_empty_for_non_strings() {
214        let payload = json!({
215            "name": "Ada",
216            "age": 19
217        });
218        assert_eq!(value_as_string(payload.get("name")), "Ada");
219        assert_eq!(value_as_string(payload.get("age")), "");
220        assert_eq!(value_as_string(payload.get("missing")), "");
221    }
222
223    #[test]
224    fn validation_errors_set_remove_and_reindex() {
225        let mut errors = ValidationErrors::default();
226        errors.set("addresses.0.street", Some("Street required".to_string()));
227        errors.set("addresses.1.city", Some("City required".to_string()));
228        errors.set("owner.name", Some("Owner required".to_string()));
229        errors.reindex_array("addresses", 1);
230
231        assert!(errors.get("addresses.0.street").is_some());
232        assert!(errors.get("addresses.1.city").is_none());
233        assert!(errors.get("owner.name").is_some());
234    }
235
236    #[test]
237    fn form_data_object_array_and_missing_paths_cover_edge_cases() {
238        let payload = json!({
239            "owner": { "name": "Ada", "email": "ada@example.test" },
240            "addresses": [
241                { "street": "42 Logic Rd", "city": "Math City" },
242                { "street": "73 Compile Ln", "city": "Rustville" }
243            ]
244        });
245        let form = FormData::new(&payload);
246
247        assert_eq!(form.value(""), Some(&payload));
248        assert!(form.object("owner").is_some());
249        assert!(form.array("addresses").is_some());
250        assert_eq!(form.string("owner[missing]"), "");
251        assert!(form.value("addresses[9][city]").is_none());
252        assert!(form.value("owner[0]").is_none());
253    }
254
255    #[test]
256    fn validation_errors_html_and_optional_set_paths_are_supported() {
257        let mut errors = ValidationErrors::default();
258        assert!(errors.is_empty());
259        assert_eq!(errors.html("owner.name"), "");
260
261        errors.set("owner.name", Some("<invalid>".to_string()));
262        assert!(!errors.is_empty());
263        assert!(errors.html("owner.name").contains("&lt;invalid&gt;"));
264        assert!(errors.as_map().contains_key("owner.name"));
265
266        errors.set("owner.name", None);
267        assert!(errors.get("owner.name").is_none());
268        errors.remove("owner.name");
269        assert!(errors.is_empty());
270    }
271
272    #[test]
273    fn validation_reindex_keeps_non_numeric_array_segments() {
274        let mut errors = ValidationErrors::default();
275        errors.set("addresses.foo.city", Some("bad key".to_string()));
276        errors.set("addresses.0.city", Some("required".to_string()));
277        errors.reindex_array("addresses", 1);
278
279        assert_eq!(
280            errors.get("addresses.foo.city").map(String::as_str),
281            Some("bad key")
282        );
283        assert_eq!(
284            errors.get("addresses.0.city").map(String::as_str),
285            Some("required")
286        );
287    }
288}