use crate::escape_html;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
pub fn parse_path_segments(path: &str) -> Vec<String> {
if path.contains('[') {
let mut out = Vec::new();
let mut start = 0usize;
let bytes = path.as_bytes();
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] == b'[' {
if start < index {
out.push(path[start..index].to_string());
}
let close = path[index + 1..]
.find(']')
.map(|value| value + index + 1)
.unwrap_or(bytes.len());
if index + 1 < close {
out.push(path[index + 1..close].to_string());
}
index = close + 1;
start = index;
} else {
index += 1;
}
}
if start < bytes.len() {
out.push(path[start..].to_string());
}
return out;
}
path.split('.')
.map(str::trim)
.filter(|segment| !segment.is_empty())
.map(ToString::to_string)
.collect()
}
pub fn dot_path(path: &str) -> String {
parse_path_segments(path).join(".")
}
pub fn value_as_string(value: Option<&Value>) -> String {
value
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
pub struct FormData<'a> {
root: &'a Value,
}
impl<'a> FormData<'a> {
pub fn new(root: &'a Value) -> Self {
Self { root }
}
pub fn value(&self, path: &str) -> Option<&'a Value> {
let segments = parse_path_segments(path);
if segments.is_empty() {
return Some(self.root);
}
let mut current = self.root;
for segment in segments {
match current {
Value::Object(map) => {
current = map.get(&segment)?;
}
Value::Array(items) => {
let index = segment.parse::<usize>().ok()?;
current = items.get(index)?;
}
_ => return None,
}
}
Some(current)
}
pub fn string(&self, path: &str) -> String {
self.value(path)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
pub fn object(&self, path: &str) -> Option<&'a Map<String, Value>> {
self.value(path).and_then(Value::as_object)
}
pub fn array(&self, path: &str) -> Option<&'a Vec<Value>> {
self.value(path).and_then(Value::as_array)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ValidationErrors {
inner: BTreeMap<String, String>,
}
impl ValidationErrors {
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn get(&self, path: &str) -> Option<&String> {
self.inner.get(path)
}
pub fn set(&mut self, path: &str, value: Option<String>) {
match value {
Some(message) => {
self.inner.insert(path.to_string(), message);
}
None => {
self.inner.remove(path);
}
}
}
pub fn remove(&mut self, path: &str) {
self.inner.remove(path);
}
pub fn reindex_array(&mut self, prefix: &str, len: usize) {
let mut rebuilt = BTreeMap::new();
let base = format!("{prefix}.");
for (key, value) in &self.inner {
if let Some(rest) = key.strip_prefix(&base) {
let mut parts = rest.splitn(2, '.');
if let (Some(index), Some(field)) = (parts.next(), parts.next()) {
if let Ok(index) = index.parse::<usize>() {
if index < len {
rebuilt.insert(format!("{prefix}.{index}.{field}"), value.clone());
}
continue;
}
}
}
rebuilt.insert(key.clone(), value.clone());
}
self.inner = rebuilt;
}
pub fn as_map(&self) -> &BTreeMap<String, String> {
&self.inner
}
pub fn html(&self, path: &str) -> String {
self.get(path)
.map(|message| {
format!(
r#"<p class="error" role="alert">{}</p>"#,
escape_html(message)
)
})
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::{dot_path, parse_path_segments, value_as_string, FormData, ValidationErrors};
use serde_json::json;
#[test]
fn parse_path_segments_supports_bracket_and_dot_paths() {
assert_eq!(
parse_path_segments("addresses[2][city]"),
vec!["addresses", "2", "city"]
);
assert_eq!(parse_path_segments("owner.email"), vec!["owner", "email"]);
}
#[test]
fn dotted_path_normalization_round_trips() {
assert_eq!(dot_path("owner[name]"), "owner.name");
assert_eq!(dot_path("addresses[1][street]"), "addresses.1.street");
}
#[test]
fn form_data_reads_nested_values() {
let payload = json!({
"owner": { "name": "Ada", "email": "ada@example.test" },
"addresses": [
{ "street": "42 Logic Rd", "city": "Math City" }
]
});
let form = FormData::new(&payload);
assert_eq!(form.string("owner[name]"), "Ada");
assert_eq!(form.string("owner.email"), "ada@example.test");
assert_eq!(form.string("addresses[0][city]"), "Math City");
}
#[test]
fn value_as_string_returns_empty_for_non_strings() {
let payload = json!({
"name": "Ada",
"age": 19
});
assert_eq!(value_as_string(payload.get("name")), "Ada");
assert_eq!(value_as_string(payload.get("age")), "");
assert_eq!(value_as_string(payload.get("missing")), "");
}
#[test]
fn validation_errors_set_remove_and_reindex() {
let mut errors = ValidationErrors::default();
errors.set("addresses.0.street", Some("Street required".to_string()));
errors.set("addresses.1.city", Some("City required".to_string()));
errors.set("owner.name", Some("Owner required".to_string()));
errors.reindex_array("addresses", 1);
assert!(errors.get("addresses.0.street").is_some());
assert!(errors.get("addresses.1.city").is_none());
assert!(errors.get("owner.name").is_some());
}
#[test]
fn form_data_object_array_and_missing_paths_cover_edge_cases() {
let payload = json!({
"owner": { "name": "Ada", "email": "ada@example.test" },
"addresses": [
{ "street": "42 Logic Rd", "city": "Math City" },
{ "street": "73 Compile Ln", "city": "Rustville" }
]
});
let form = FormData::new(&payload);
assert_eq!(form.value(""), Some(&payload));
assert!(form.object("owner").is_some());
assert!(form.array("addresses").is_some());
assert_eq!(form.string("owner[missing]"), "");
assert!(form.value("addresses[9][city]").is_none());
assert!(form.value("owner[0]").is_none());
}
#[test]
fn validation_errors_html_and_optional_set_paths_are_supported() {
let mut errors = ValidationErrors::default();
assert!(errors.is_empty());
assert_eq!(errors.html("owner.name"), "");
errors.set("owner.name", Some("<invalid>".to_string()));
assert!(!errors.is_empty());
assert!(errors.html("owner.name").contains("<invalid>"));
assert!(errors.as_map().contains_key("owner.name"));
errors.set("owner.name", None);
assert!(errors.get("owner.name").is_none());
errors.remove("owner.name");
assert!(errors.is_empty());
}
#[test]
fn validation_reindex_keeps_non_numeric_array_segments() {
let mut errors = ValidationErrors::default();
errors.set("addresses.foo.city", Some("bad key".to_string()));
errors.set("addresses.0.city", Some("required".to_string()));
errors.reindex_array("addresses", 1);
assert_eq!(
errors.get("addresses.foo.city").map(String::as_str),
Some("bad key")
);
assert_eq!(
errors.get("addresses.0.city").map(String::as_str),
Some("required")
);
}
}