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        Self {
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// The shared conversion primitives live in `buffa::json_helpers::wkt`. Both
73// this typed serde impl and `buffa-descriptor`'s reflective JSON codec call
74// into the same code, so the two paths can't drift on edge cases the
75// conformance suite exercises.
76
77#[cfg(feature = "json")]
78use alloc::vec::Vec;
79#[cfg(feature = "json")]
80use buffa::json_helpers::wkt::{camel_to_snake, snake_to_camel};
81
82// ── serde impls ──────────────────────────────────────────────────────────────
83
84#[cfg(feature = "json")]
85impl serde::Serialize for FieldMask {
86    /// Serializes as a comma-separated string of lowerCamelCase field paths.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if any path cannot round-trip through camelCase
91    /// conversion (e.g. paths that are already camelCase, contain consecutive
92    /// underscores, or have digits immediately after underscores).
93    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
94        let camel_paths: Vec<String> = self
95            .paths
96            .iter()
97            .map(|p| {
98                let camel = snake_to_camel(p);
99                if camel_to_snake(&camel) != *p {
100                    return Err(serde::ser::Error::custom(alloc::format!(
101                        "FieldMask path '{p}' cannot round-trip through camelCase conversion"
102                    )));
103                }
104                Ok(camel)
105            })
106            .collect::<Result<_, _>>()?;
107        s.serialize_str(&camel_paths.join(","))
108    }
109}
110
111#[cfg(feature = "json")]
112impl<'de> serde::Deserialize<'de> for FieldMask {
113    /// Deserializes from a comma-separated string of lowerCamelCase field paths.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if any path component contains an underscore, which is
118    /// invalid in the lowerCamelCase JSON representation.
119    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
120        let s: String = serde::Deserialize::deserialize(d)?;
121        let paths = if s.is_empty() {
122            Vec::new()
123        } else {
124            s.split(',')
125                .map(|component| {
126                    if component.contains('_') {
127                        return Err(serde::de::Error::custom(alloc::format!(
128                            "FieldMask path '{component}' contains underscore, \
129                             which is invalid in JSON (lowerCamelCase) representation"
130                        )));
131                    }
132                    Ok(camel_to_snake(component))
133                })
134                .collect::<Result<_, _>>()?
135        };
136        Ok(Self {
137            paths,
138            ..Default::default()
139        })
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn from_paths_empty() {
149        let mask = FieldMask::from_paths(core::iter::empty::<&str>());
150        assert!(mask.paths.is_empty());
151        assert!(mask.is_empty());
152        assert_eq!(mask.len(), 0);
153    }
154
155    #[test]
156    fn len_and_is_empty() {
157        let mask = FieldMask::from_paths(["a", "b", "c"]);
158        assert_eq!(mask.len(), 3);
159        assert!(!mask.is_empty());
160    }
161
162    #[test]
163    fn iter_yields_all_paths() {
164        let mask = FieldMask::from_paths(["x.y", "z"]);
165        let collected: Vec<_> = mask.iter().collect();
166        assert_eq!(collected, [&"x.y".to_string(), &"z".to_string()]);
167    }
168
169    #[test]
170    fn from_paths_string_slices() {
171        let mask = FieldMask::from_paths(["a.b", "c.d"]);
172        assert_eq!(mask.paths, vec!["a.b", "c.d"]);
173    }
174
175    #[test]
176    fn from_paths_owned_strings() {
177        let paths = vec!["x".to_string(), "y.z".to_string()];
178        let mask = FieldMask::from_paths(paths);
179        assert_eq!(mask.paths, vec!["x", "y.z"]);
180    }
181
182    #[test]
183    fn contains_returns_true_for_present_path() {
184        let mask = FieldMask::from_paths(["user.name", "user.email"]);
185        assert!(mask.contains("user.name"));
186        assert!(mask.contains("user.email"));
187    }
188
189    #[test]
190    fn contains_returns_false_for_absent_path() {
191        let mask = FieldMask::from_paths(["user.name"]);
192        assert!(!mask.contains("user.age"));
193    }
194
195    #[test]
196    fn contains_is_exact_match_not_prefix() {
197        let mask = FieldMask::from_paths(["user"]);
198        assert!(!mask.contains("user.name"));
199    }
200
201    #[test]
202    fn contains_is_case_sensitive() {
203        let mask = FieldMask::from_paths(["user.Name"]);
204        assert!(!mask.contains("user.name"));
205    }
206
207    #[cfg(feature = "json")]
208    mod serde_tests {
209        use super::*;
210
211        // ---- camelCase conversion unit tests ------------------------------
212
213        #[test]
214        fn snake_to_camel_simple() {
215            assert_eq!(snake_to_camel("foo_bar"), "fooBar");
216            assert_eq!(snake_to_camel("foo"), "foo");
217            assert_eq!(snake_to_camel("foo_bar_baz"), "fooBarBaz");
218        }
219
220        #[test]
221        fn snake_to_camel_dotted() {
222            assert_eq!(snake_to_camel("user.first_name"), "user.firstName");
223        }
224
225        #[test]
226        fn camel_to_snake_simple() {
227            assert_eq!(camel_to_snake("fooBar"), "foo_bar");
228            assert_eq!(camel_to_snake("foo"), "foo");
229            assert_eq!(camel_to_snake("fooBarBaz"), "foo_bar_baz");
230        }
231
232        #[test]
233        fn camel_to_snake_pascal_case_no_leading_underscore() {
234            // Regression: leading uppercase must not produce a leading
235            // underscore. Proto field names can't start with `_`, so
236            // `_foo_bar` would never match a real field.
237            assert_eq!(camel_to_snake("FooBar"), "foo_bar");
238            assert_eq!(camel_to_snake("Foo"), "foo");
239            assert_eq!(camel_to_snake("A.B"), "a.b");
240        }
241
242        #[test]
243        fn camel_to_snake_dotted() {
244            assert_eq!(camel_to_snake("user.firstName"), "user.first_name");
245        }
246
247        #[test]
248        fn snake_to_camel_camel_to_snake_roundtrip() {
249            let original = "user.first_name";
250            assert_eq!(camel_to_snake(&snake_to_camel(original)), original);
251        }
252
253        // ---- serde roundtrips ---------------------------------------------
254
255        #[test]
256        fn field_mask_empty_roundtrip() {
257            let m = FieldMask::from_paths(core::iter::empty::<&str>());
258            let json = serde_json::to_string(&m).unwrap();
259            assert_eq!(json, r#""""#);
260            let back: FieldMask = serde_json::from_str(&json).unwrap();
261            assert!(back.paths.is_empty());
262        }
263
264        #[test]
265        fn field_mask_single_path_roundtrip() {
266            let m = FieldMask::from_paths(["foo_bar"]);
267            let json = serde_json::to_string(&m).unwrap();
268            assert_eq!(json, r#""fooBar""#);
269            let back: FieldMask = serde_json::from_str(&json).unwrap();
270            assert_eq!(back.paths, ["foo_bar"]);
271        }
272
273        #[test]
274        fn field_mask_multiple_paths_roundtrip() {
275            let m = FieldMask::from_paths(["user_id", "display_name"]);
276            let json = serde_json::to_string(&m).unwrap();
277            assert_eq!(json, r#""userId,displayName""#);
278            let back: FieldMask = serde_json::from_str(&json).unwrap();
279            assert_eq!(back.paths, ["user_id", "display_name"]);
280        }
281
282        #[test]
283        fn field_mask_dotted_path_roundtrip() {
284            let m = FieldMask::from_paths(["user.email_address"]);
285            let json = serde_json::to_string(&m).unwrap();
286            assert_eq!(json, r#""user.emailAddress""#);
287            let back: FieldMask = serde_json::from_str(&json).unwrap();
288            assert_eq!(back.paths, ["user.email_address"]);
289        }
290
291        // ---- serialize validation -------------------------------------------
292
293        #[test]
294        fn serialize_rejects_already_camel_case_path() {
295            let m = FieldMask::from_paths(["fooBar"]);
296            assert!(serde_json::to_string(&m).is_err());
297        }
298
299        #[test]
300        fn serialize_rejects_digit_after_underscore() {
301            let m = FieldMask::from_paths(["foo_3_bar"]);
302            assert!(serde_json::to_string(&m).is_err());
303        }
304
305        #[test]
306        fn serialize_rejects_consecutive_underscores() {
307            let m = FieldMask::from_paths(["foo__bar"]);
308            assert!(serde_json::to_string(&m).is_err());
309        }
310
311        // ---- deserialize validation -----------------------------------------
312
313        #[test]
314        fn deserialize_rejects_underscore_in_json() {
315            let result: Result<FieldMask, _> = serde_json::from_str(r#""foo_bar""#);
316            assert!(result.is_err());
317        }
318
319        #[test]
320        fn deserialize_rejects_underscore_in_multi_path() {
321            let result: Result<FieldMask, _> = serde_json::from_str(r#""fooBar,baz_qux""#);
322            assert!(result.is_err());
323        }
324
325        #[test]
326        fn serialize_accepts_path_with_digit_not_after_underscore() {
327            let m = FieldMask::from_paths(["foo3_bar"]);
328            let json = serde_json::to_string(&m).unwrap();
329            assert_eq!(json, r#""foo3Bar""#);
330            let back: FieldMask = serde_json::from_str(&json).unwrap();
331            assert_eq!(back.paths, ["foo3_bar"]);
332        }
333
334        #[test]
335        fn serialize_rejects_trailing_underscore() {
336            let m = FieldMask::from_paths(["foo_"]);
337            assert!(serde_json::to_string(&m).is_err());
338        }
339    }
340}