Skip to main content

buffa_types/
field_mask_ext.rs

1//! Ergonomic helpers for [`google::protobuf::FieldMask`](crate::google::protobuf::FieldMask).
2
3use alloc::string::String;
4
5use crate::google::protobuf::FieldMask;
6
7impl FieldMask {
8    /// Create a [`FieldMask`] from an iterator of field paths.
9    ///
10    /// # Example
11    ///
12    /// ```rust
13    /// use buffa_types::google::protobuf::FieldMask;
14    ///
15    /// let mask = FieldMask::from_paths(["user.name", "user.email"]);
16    /// assert!(mask.contains("user.name"));
17    /// ```
18    pub fn from_paths(paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
19        FieldMask {
20            paths: paths.into_iter().map(Into::into).collect(),
21            ..Default::default()
22        }
23    }
24
25    /// Returns `true` if `path` is present in this field mask.
26    ///
27    /// Comparison is exact (case-sensitive, no wildcard expansion).
28    /// Runs in O(n) time where n is the number of paths.
29    pub fn contains(&self, path: &str) -> bool {
30        self.paths.iter().any(|p| p == path)
31    }
32
33    /// Returns the number of paths in the field mask.
34    #[inline]
35    pub fn len(&self) -> usize {
36        self.paths.len()
37    }
38
39    /// Returns `true` if the field mask contains no paths.
40    #[inline]
41    pub fn is_empty(&self) -> bool {
42        self.paths.is_empty()
43    }
44
45    /// Returns an iterator over the paths in the field mask.
46    #[inline]
47    pub fn iter(&self) -> core::slice::Iter<'_, String> {
48        self.paths.iter()
49    }
50}
51
52impl<'a> IntoIterator for &'a FieldMask {
53    type Item = &'a String;
54    type IntoIter = core::slice::Iter<'a, String>;
55
56    fn into_iter(self) -> Self::IntoIter {
57        self.paths.iter()
58    }
59}
60
61impl IntoIterator for FieldMask {
62    type Item = String;
63    type IntoIter = alloc::vec::IntoIter<String>;
64
65    fn into_iter(self) -> Self::IntoIter {
66        self.paths.into_iter()
67    }
68}
69
70// ── proto JSON camelCase ↔ snake_case conversion ──────────────────────────────
71
72/// Convert a snake_case field path to lowerCamelCase, handling dotted sub-paths.
73///
74/// Each `.`-separated component is converted independently so that
75/// `"user.first_name"` → `"user.firstName"`.
76#[cfg(feature = "json")]
77use alloc::vec::Vec;
78
79#[cfg(feature = "json")]
80fn snake_to_camel(path: &str) -> String {
81    path.split('.')
82        .map(|component| {
83            let mut out = String::with_capacity(component.len());
84            let mut capitalize_next = false;
85            for ch in component.chars() {
86                if ch == '_' {
87                    capitalize_next = true;
88                } else if capitalize_next {
89                    out.extend(ch.to_uppercase());
90                    capitalize_next = false;
91                } else {
92                    out.push(ch);
93                }
94            }
95            out
96        })
97        .collect::<Vec<_>>()
98        .join(".")
99}
100
101/// Convert a lowerCamelCase field path to snake_case, handling dotted sub-paths.
102#[cfg(feature = "json")]
103fn camel_to_snake(path: &str) -> String {
104    path.split('.')
105        .map(|component| {
106            let mut out = String::with_capacity(component.len() + 4);
107            for ch in component.chars() {
108                if ch.is_uppercase() {
109                    // No underscore before the first char of a component,
110                    // even if it's uppercase (PascalCase → snake, not _snake).
111                    if !out.is_empty() {
112                        out.push('_');
113                    }
114                    out.extend(ch.to_lowercase());
115                } else {
116                    out.push(ch);
117                }
118            }
119            out
120        })
121        .collect::<Vec<_>>()
122        .join(".")
123}
124
125// ── serde impls ──────────────────────────────────────────────────────────────
126
127#[cfg(feature = "json")]
128impl serde::Serialize for FieldMask {
129    /// Serializes as a comma-separated string of lowerCamelCase field paths.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if any path cannot round-trip through camelCase
134    /// conversion (e.g. paths that are already camelCase, contain consecutive
135    /// underscores, or have digits immediately after underscores).
136    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
137        let camel_paths: Vec<String> = self
138            .paths
139            .iter()
140            .map(|p| {
141                let camel = snake_to_camel(p);
142                if camel_to_snake(&camel) != *p {
143                    return Err(serde::ser::Error::custom(alloc::format!(
144                        "FieldMask path '{p}' cannot round-trip through camelCase conversion"
145                    )));
146                }
147                Ok(camel)
148            })
149            .collect::<Result<_, _>>()?;
150        s.serialize_str(&camel_paths.join(","))
151    }
152}
153
154#[cfg(feature = "json")]
155impl<'de> serde::Deserialize<'de> for FieldMask {
156    /// Deserializes from a comma-separated string of lowerCamelCase field paths.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if any path component contains an underscore, which is
161    /// invalid in the lowerCamelCase JSON representation.
162    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
163        let s: String = serde::Deserialize::deserialize(d)?;
164        let paths = if s.is_empty() {
165            Vec::new()
166        } else {
167            s.split(',')
168                .map(|component| {
169                    if component.contains('_') {
170                        return Err(serde::de::Error::custom(alloc::format!(
171                            "FieldMask path '{component}' contains underscore, \
172                             which is invalid in JSON (lowerCamelCase) representation"
173                        )));
174                    }
175                    Ok(camel_to_snake(component))
176                })
177                .collect::<Result<_, _>>()?
178        };
179        Ok(FieldMask {
180            paths,
181            ..Default::default()
182        })
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn from_paths_empty() {
192        let mask = FieldMask::from_paths(core::iter::empty::<&str>());
193        assert!(mask.paths.is_empty());
194        assert!(mask.is_empty());
195        assert_eq!(mask.len(), 0);
196    }
197
198    #[test]
199    fn len_and_is_empty() {
200        let mask = FieldMask::from_paths(["a", "b", "c"]);
201        assert_eq!(mask.len(), 3);
202        assert!(!mask.is_empty());
203    }
204
205    #[test]
206    fn iter_yields_all_paths() {
207        let mask = FieldMask::from_paths(["x.y", "z"]);
208        let collected: Vec<_> = mask.iter().collect();
209        assert_eq!(collected, [&"x.y".to_string(), &"z".to_string()]);
210    }
211
212    #[test]
213    fn from_paths_string_slices() {
214        let mask = FieldMask::from_paths(["a.b", "c.d"]);
215        assert_eq!(mask.paths, vec!["a.b", "c.d"]);
216    }
217
218    #[test]
219    fn from_paths_owned_strings() {
220        let paths = vec!["x".to_string(), "y.z".to_string()];
221        let mask = FieldMask::from_paths(paths);
222        assert_eq!(mask.paths, vec!["x", "y.z"]);
223    }
224
225    #[test]
226    fn contains_returns_true_for_present_path() {
227        let mask = FieldMask::from_paths(["user.name", "user.email"]);
228        assert!(mask.contains("user.name"));
229        assert!(mask.contains("user.email"));
230    }
231
232    #[test]
233    fn contains_returns_false_for_absent_path() {
234        let mask = FieldMask::from_paths(["user.name"]);
235        assert!(!mask.contains("user.age"));
236    }
237
238    #[test]
239    fn contains_is_exact_match_not_prefix() {
240        let mask = FieldMask::from_paths(["user"]);
241        assert!(!mask.contains("user.name"));
242    }
243
244    #[test]
245    fn contains_is_case_sensitive() {
246        let mask = FieldMask::from_paths(["user.Name"]);
247        assert!(!mask.contains("user.name"));
248    }
249
250    #[cfg(feature = "json")]
251    mod serde_tests {
252        use super::*;
253
254        // ---- camelCase conversion unit tests ------------------------------
255
256        #[test]
257        fn snake_to_camel_simple() {
258            assert_eq!(snake_to_camel("foo_bar"), "fooBar");
259            assert_eq!(snake_to_camel("foo"), "foo");
260            assert_eq!(snake_to_camel("foo_bar_baz"), "fooBarBaz");
261        }
262
263        #[test]
264        fn snake_to_camel_dotted() {
265            assert_eq!(snake_to_camel("user.first_name"), "user.firstName");
266        }
267
268        #[test]
269        fn camel_to_snake_simple() {
270            assert_eq!(camel_to_snake("fooBar"), "foo_bar");
271            assert_eq!(camel_to_snake("foo"), "foo");
272            assert_eq!(camel_to_snake("fooBarBaz"), "foo_bar_baz");
273        }
274
275        #[test]
276        fn camel_to_snake_pascal_case_no_leading_underscore() {
277            // Regression: leading uppercase must not produce a leading
278            // underscore. Proto field names can't start with `_`, so
279            // `_foo_bar` would never match a real field.
280            assert_eq!(camel_to_snake("FooBar"), "foo_bar");
281            assert_eq!(camel_to_snake("Foo"), "foo");
282            assert_eq!(camel_to_snake("A.B"), "a.b");
283        }
284
285        #[test]
286        fn camel_to_snake_dotted() {
287            assert_eq!(camel_to_snake("user.firstName"), "user.first_name");
288        }
289
290        #[test]
291        fn snake_to_camel_camel_to_snake_roundtrip() {
292            let original = "user.first_name";
293            assert_eq!(camel_to_snake(&snake_to_camel(original)), original);
294        }
295
296        // ---- serde roundtrips ---------------------------------------------
297
298        #[test]
299        fn field_mask_empty_roundtrip() {
300            let m = FieldMask::from_paths(core::iter::empty::<&str>());
301            let json = serde_json::to_string(&m).unwrap();
302            assert_eq!(json, r#""""#);
303            let back: FieldMask = serde_json::from_str(&json).unwrap();
304            assert!(back.paths.is_empty());
305        }
306
307        #[test]
308        fn field_mask_single_path_roundtrip() {
309            let m = FieldMask::from_paths(["foo_bar"]);
310            let json = serde_json::to_string(&m).unwrap();
311            assert_eq!(json, r#""fooBar""#);
312            let back: FieldMask = serde_json::from_str(&json).unwrap();
313            assert_eq!(back.paths, ["foo_bar"]);
314        }
315
316        #[test]
317        fn field_mask_multiple_paths_roundtrip() {
318            let m = FieldMask::from_paths(["user_id", "display_name"]);
319            let json = serde_json::to_string(&m).unwrap();
320            assert_eq!(json, r#""userId,displayName""#);
321            let back: FieldMask = serde_json::from_str(&json).unwrap();
322            assert_eq!(back.paths, ["user_id", "display_name"]);
323        }
324
325        #[test]
326        fn field_mask_dotted_path_roundtrip() {
327            let m = FieldMask::from_paths(["user.email_address"]);
328            let json = serde_json::to_string(&m).unwrap();
329            assert_eq!(json, r#""user.emailAddress""#);
330            let back: FieldMask = serde_json::from_str(&json).unwrap();
331            assert_eq!(back.paths, ["user.email_address"]);
332        }
333
334        // ---- serialize validation -------------------------------------------
335
336        #[test]
337        fn serialize_rejects_already_camel_case_path() {
338            let m = FieldMask::from_paths(["fooBar"]);
339            assert!(serde_json::to_string(&m).is_err());
340        }
341
342        #[test]
343        fn serialize_rejects_digit_after_underscore() {
344            let m = FieldMask::from_paths(["foo_3_bar"]);
345            assert!(serde_json::to_string(&m).is_err());
346        }
347
348        #[test]
349        fn serialize_rejects_consecutive_underscores() {
350            let m = FieldMask::from_paths(["foo__bar"]);
351            assert!(serde_json::to_string(&m).is_err());
352        }
353
354        // ---- deserialize validation -----------------------------------------
355
356        #[test]
357        fn deserialize_rejects_underscore_in_json() {
358            let result: Result<FieldMask, _> = serde_json::from_str(r#""foo_bar""#);
359            assert!(result.is_err());
360        }
361
362        #[test]
363        fn deserialize_rejects_underscore_in_multi_path() {
364            let result: Result<FieldMask, _> = serde_json::from_str(r#""fooBar,baz_qux""#);
365            assert!(result.is_err());
366        }
367
368        #[test]
369        fn serialize_accepts_path_with_digit_not_after_underscore() {
370            let m = FieldMask::from_paths(["foo3_bar"]);
371            let json = serde_json::to_string(&m).unwrap();
372            assert_eq!(json, r#""foo3Bar""#);
373            let back: FieldMask = serde_json::from_str(&json).unwrap();
374            assert_eq!(back.paths, ["foo3_bar"]);
375        }
376
377        #[test]
378        fn serialize_rejects_trailing_underscore() {
379            let m = FieldMask::from_paths(["foo_"]);
380            assert!(serde_json::to_string(&m).is_err());
381        }
382    }
383}