Skip to main content

rustrails_record/
normalization.rs

1use std::collections::HashMap;
2
3use rustrails_support::string_ext::StringExt;
4use serde_json::Value;
5
6/// Describes a normalization to apply to an attribute before persistence.
7pub struct Normalization {
8    /// The attribute name to normalize.
9    pub attribute: &'static str,
10    /// The normalization function.
11    pub normalizer: fn(Value) -> Value,
12}
13
14/// Trait for models that declare normalizations.
15pub trait Normalizable {
16    /// Returns the normalizations declared for this model.
17    fn normalizations() -> &'static [Normalization] {
18        &[]
19    }
20
21    /// Applies all normalizations to the given attribute map.
22    fn normalize_attributes(attrs: &mut HashMap<String, Value>) {
23        for normalization in Self::normalizations() {
24            if let Some(value) = attrs.remove(normalization.attribute) {
25                attrs.insert(
26                    normalization.attribute.to_owned(),
27                    (normalization.normalizer)(value),
28                );
29            }
30        }
31    }
32}
33
34fn normalize_string(value: Value, normalizer: impl FnOnce(&str) -> String) -> Value {
35    match value {
36        Value::String(content) => Value::String(normalizer(&content)),
37        other => other,
38    }
39}
40
41/// Strips leading and trailing whitespace from string values.
42#[must_use]
43pub fn strip_normalizer(value: Value) -> Value {
44    normalize_string(value, |content| content.trim().to_owned())
45}
46
47/// Downcases string values.
48#[must_use]
49pub fn downcase_normalizer(value: Value) -> Value {
50    normalize_string(value, |content| content.to_lowercase())
51}
52
53/// Squishes whitespace by collapsing internal runs to a single space.
54#[must_use]
55pub fn squish_normalizer(value: Value) -> Value {
56    normalize_string(value, StringExt::squish)
57}
58
59#[cfg(test)]
60mod tests {
61    use std::collections::HashMap;
62
63    use serde_json::{Value, json};
64
65    use super::{
66        Normalizable, Normalization, downcase_normalizer, squish_normalizer, strip_normalizer,
67    };
68
69    struct NormalizedUser;
70
71    impl Normalizable for NormalizedUser {
72        fn normalizations() -> &'static [Normalization] {
73            &[
74                Normalization {
75                    attribute: "email",
76                    normalizer: strip_normalizer,
77                },
78                Normalization {
79                    attribute: "name",
80                    normalizer: squish_normalizer,
81                },
82                Normalization {
83                    attribute: "handle",
84                    normalizer: downcase_normalizer,
85                },
86            ]
87        }
88    }
89
90    #[test]
91    fn strip_normalizer_trims_whitespace() {
92        assert_eq!(strip_normalizer(json!("  hello  ")), json!("hello"));
93    }
94
95    #[test]
96    fn downcase_normalizer_lowercases_strings() {
97        assert_eq!(downcase_normalizer(json!("MiXeD")), json!("mixed"));
98    }
99
100    #[test]
101    fn squish_normalizer_collapses_internal_whitespace() {
102        assert_eq!(
103            squish_normalizer(json!("  hello   \n   world  ")),
104            json!("hello world")
105        );
106    }
107
108    #[test]
109    fn normalize_attributes_applies_normalizers_to_matching_keys() {
110        let mut attrs = HashMap::from([
111            ("email".to_owned(), json!("  alice@example.com  ")),
112            ("name".to_owned(), json!("  Alice   Example  ")),
113            ("handle".to_owned(), json!("AliceExample")),
114        ]);
115
116        NormalizedUser::normalize_attributes(&mut attrs);
117
118        assert_eq!(attrs.get("email"), Some(&json!("alice@example.com")));
119        assert_eq!(attrs.get("name"), Some(&json!("Alice Example")));
120        assert_eq!(attrs.get("handle"), Some(&json!("aliceexample")));
121    }
122
123    #[test]
124    fn normalize_attributes_ignores_keys_without_normalizers() {
125        let mut attrs = HashMap::from([
126            ("email".to_owned(), json!("  alice@example.com  ")),
127            ("role".to_owned(), json!("  Admin  ")),
128        ]);
129
130        NormalizedUser::normalize_attributes(&mut attrs);
131
132        assert_eq!(attrs.get("email"), Some(&json!("alice@example.com")));
133        assert_eq!(attrs.get("role"), Some(&json!("  Admin  ")));
134    }
135
136    #[test]
137    fn normalizers_leave_non_string_values_unchanged() {
138        let value = json!(42);
139
140        assert_eq!(strip_normalizer(value.clone()), Value::from(42));
141        assert_eq!(downcase_normalizer(value.clone()), Value::from(42));
142        assert_eq!(squish_normalizer(value), Value::from(42));
143    }
144}