shelly-liveview 0.4.0

Core runtime primitives for Shelly LiveView.
Documentation
use crate::escape_html;
use serde_json::{Map, Value};
use std::collections::BTreeMap;

/// Parse bracket or dot paths into normalized path segments.
///
/// Examples:
/// - `owner[name]` -> `["owner", "name"]`
/// - `addresses[0][city]` -> `["addresses", "0", "city"]`
/// - `owner.email` -> `["owner", "email"]`
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()
}

/// Normalize bracket/dot path notation into dotted notation.
pub fn dot_path(path: &str) -> String {
    parse_path_segments(path).join(".")
}

/// Shared helper for reading optional JSON string values.
pub fn value_as_string(value: Option<&Value>) -> String {
    value
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string()
}

/// Path-aware reader for nested form payloads.
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)
    }
}

/// Path-keyed validation error bag for nested forms.
#[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);
    }

    /// Reindex array-path errors after removing/reordering rows.
    ///
    /// Keys are expected in dotted form, for example:
    /// `addresses.0.street`, `addresses.1.city`.
    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());
    }
}