Skip to main content

use_document_path/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7macro_rules! string_newtype {
8    ($(#[$meta:meta])* $name:ident) => {
9        $(#[$meta])*
10        #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
11        pub struct $name(String);
12
13        impl $name {
14            /// Creates a new string-backed primitive.
15            pub fn new(value: impl Into<String>) -> Self {
16                Self(value.into())
17            }
18
19            /// Returns the stored string value.
20            pub fn as_str(&self) -> &str {
21                &self.0
22            }
23        }
24
25        impl AsRef<str> for $name {
26            fn as_ref(&self) -> &str {
27                self.as_str()
28            }
29        }
30
31        impl From<String> for $name {
32            fn from(value: String) -> Self {
33                Self::new(value)
34            }
35        }
36
37        impl From<&str> for $name {
38            fn from(value: &str) -> Self {
39                Self::new(value)
40            }
41        }
42
43        impl fmt::Display for $name {
44            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45                formatter.write_str(self.as_str())
46            }
47        }
48    };
49}
50
51string_newtype! {
52    /// A validated or raw document path such as `profile.display_name`.
53    DocumentPath
54}
55string_newtype! {
56    /// One segment inside a dot-separated document path.
57    PathSegment
58}
59string_newtype! {
60    /// A field selector label for extracting document fields.
61    FieldSelector
62}
63
64impl DocumentPath {
65    /// Creates a document path after validating dot-path structure.
66    pub fn try_new(value: impl AsRef<str>) -> Result<Self, PathParseError> {
67        validate_path(value.as_ref())?;
68        Ok(Self::new(value.as_ref().trim()))
69    }
70
71    /// Parses the path into individual segments.
72    pub fn segments(&self) -> Result<Vec<PathSegment>, PathParseError> {
73        parse_segments(self.as_str())
74    }
75}
76
77impl FromStr for DocumentPath {
78    type Err = PathParseError;
79
80    fn from_str(input: &str) -> Result<Self, Self::Err> {
81        Self::try_new(input)
82    }
83}
84
85/// Error returned when a document dot-path is invalid.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum PathParseError {
88    /// The path had no non-whitespace content.
89    EmptyPath,
90    /// The path contained an empty segment at the given zero-based index.
91    EmptySegment { index: usize },
92}
93
94impl fmt::Display for PathParseError {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::EmptyPath => formatter.write_str("document path must not be empty"),
98            Self::EmptySegment { index } => {
99                write!(
100                    formatter,
101                    "document path segment at index {index} must not be empty"
102                )
103            },
104        }
105    }
106}
107
108impl Error for PathParseError {}
109
110fn validate_path(input: &str) -> Result<(), PathParseError> {
111    parse_segments(input).map(|_| ())
112}
113
114fn parse_segments(input: &str) -> Result<Vec<PathSegment>, PathParseError> {
115    let trimmed = input.trim();
116    if trimmed.is_empty() {
117        return Err(PathParseError::EmptyPath);
118    }
119
120    let mut segments = Vec::new();
121    for (index, segment) in trimmed.split('.').enumerate() {
122        if segment.is_empty() {
123            return Err(PathParseError::EmptySegment { index });
124        }
125        segments.push(PathSegment::new(segment));
126    }
127
128    Ok(segments)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::{DocumentPath, FieldSelector, PathParseError, PathSegment};
134
135    #[test]
136    fn constructs_and_displays_paths() {
137        let path = DocumentPath::new("profile.display_name");
138        assert_eq!(path.as_str(), "profile.display_name");
139        assert_eq!(path.to_string(), "profile.display_name");
140        assert_eq!(path.as_ref(), "profile.display_name");
141        assert_eq!(DocumentPath::from("profile"), DocumentPath::new("profile"));
142    }
143
144    #[test]
145    fn parses_dot_path_segments() -> Result<(), PathParseError> {
146        let path = DocumentPath::try_new("settings.notifications.email")?;
147        let segments = path.segments()?;
148
149        assert_eq!(
150            segments,
151            vec![
152                PathSegment::new("settings"),
153                PathSegment::new("notifications"),
154                PathSegment::new("email"),
155            ]
156        );
157        assert_eq!(
158            FieldSelector::new("profile.display_name").to_string(),
159            "profile.display_name"
160        );
161
162        Ok(())
163    }
164
165    #[test]
166    fn rejects_invalid_paths() {
167        assert_eq!(DocumentPath::try_new(""), Err(PathParseError::EmptyPath));
168        assert_eq!(
169            DocumentPath::try_new("profile..name"),
170            Err(PathParseError::EmptySegment { index: 1 })
171        );
172    }
173}