Skip to main content

wyvern_schema/
field_name.rs

1//! Validated JSON field path names for error reporting.
2
3use std::fmt;
4use std::ops::Deref;
5
6/// Error when constructing a [`FieldName`] from an empty string.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct FieldNameError;
9
10impl fmt::Display for FieldNameError {
11    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
12        f.write_str("field name must not be empty")
13    }
14}
15
16impl std::error::Error for FieldNameError {}
17
18/// A command JSON field path used in validation and stderr errors.
19///
20/// Invariant: the inner string is non-empty after construction. Prefer
21/// [`FieldName::try_new`] at trust boundaries; [`FieldName::new`] maps empty
22/// input to `"_"` so emit helpers never panic (RBP-F006).
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct FieldName(String);
25
26impl FieldName {
27    /// Wrap a field path (e.g. `title`, `type`, `file`).
28    ///
29    /// Empty strings are replaced with `"_"` so callers that already validated
30    /// the payload cannot panic at the emit boundary. Use [`try_new`] when an
31    /// empty path should be treated as an error.
32    pub fn new(value: impl Into<String>) -> Self {
33        match Self::try_new(value) {
34            Ok(name) => name,
35            Err(FieldNameError) => Self("_".into()),
36        }
37    }
38
39    /// Construct a field name, rejecting empty strings.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`FieldNameError`] when `value` is empty after conversion.
44    pub fn try_new(value: impl Into<String>) -> Result<Self, FieldNameError> {
45        let value = value.into();
46        if value.is_empty() {
47            return Err(FieldNameError);
48        }
49        Ok(Self(value))
50    }
51
52    /// Borrow the field name as a string slice.
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl Deref for FieldName {
59    type Target = str;
60
61    fn deref(&self) -> &Self::Target {
62        &self.0
63    }
64}
65
66impl AsRef<str> for FieldName {
67    fn as_ref(&self) -> &str {
68        self.as_str()
69    }
70}
71
72impl fmt::Display for FieldName {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        self.0.fmt(f)
75    }
76}
77
78impl From<&str> for FieldName {
79    fn from(value: &str) -> Self {
80        Self::new(value)
81    }
82}
83
84impl From<String> for FieldName {
85    fn from(value: String) -> Self {
86        Self::new(value)
87    }
88}
89
90impl PartialEq<str> for FieldName {
91    fn eq(&self, other: &str) -> bool {
92        self.0 == other
93    }
94}
95
96impl PartialEq<&str> for FieldName {
97    fn eq(&self, other: &&str) -> bool {
98        self.0 == *other
99    }
100}
101
102impl serde::Serialize for FieldName {
103    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
104    where
105        S: serde::Serializer,
106    {
107        serializer.serialize_str(self.as_str())
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn try_new_rejects_empty() {
117        assert_eq!(FieldName::try_new("").unwrap_err(), FieldNameError);
118        assert_eq!(FieldName::try_new("title").unwrap().as_str(), "title");
119    }
120
121    #[test]
122    fn new_maps_empty_to_underscore() {
123        assert_eq!(FieldName::new("").as_str(), "_");
124        assert_eq!(FieldName::new("file").as_str(), "file");
125    }
126}