1use core::fmt;
7
8use alloc::string::String;
9
10#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct FsPath(String);
17
18impl FsPath {
19 pub fn new(s: impl Into<String>) -> Self {
21 Self(s.into())
22 }
23
24 pub fn as_str(&self) -> &str {
26 &self.0
27 }
28
29 pub fn into_string(self) -> String {
31 self.0
32 }
33
34 pub fn is_empty(&self) -> bool {
36 self.0.is_empty()
37 }
38
39 pub fn join(&self, segment: &str) -> Self {
45 let mut out = self.clone();
46 out.push(segment);
47 out
48 }
49
50 pub fn push(&mut self, segment: &str) {
53 if !self.0.is_empty() && !self.0.ends_with('/') {
54 self.0.push('/');
55 }
56 self.0.push_str(segment);
57 }
58
59 pub fn file_name(&self) -> Option<&str> {
61 match self.0.rsplit_once('/') {
62 Some((_, name)) if !name.is_empty() => Some(name),
63 None if !self.0.is_empty() => Some(&self.0),
64 _ => None,
65 }
66 }
67
68 pub fn parent(&self) -> Option<&str> {
70 self.0.rsplit_once('/').map(|(parent, _)| parent)
71 }
72
73 pub fn with_file_name(&self, name: &str) -> Self {
77 match self.parent() {
78 Some(parent) => Self::new(parent).join(name),
79 None => Self::new(name),
80 }
81 }
82
83 pub fn strip_prefix(&self, prefix: &Self) -> Option<&str> {
86 let rest = self.0.strip_prefix(prefix.as_str())?;
87 Some(rest.strip_prefix('/').unwrap_or(rest))
88 }
89
90 pub fn starts_with(&self, prefix: &Self) -> bool {
92 self.0.starts_with(prefix.as_str())
93 }
94
95 pub fn components(&self) -> impl Iterator<Item = &str> {
97 self.0.split('/').filter(|c| !c.is_empty())
98 }
99}
100
101impl fmt::Display for FsPath {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 fmt::Display::fmt(&self.0, f)
104 }
105}
106
107impl From<String> for FsPath {
108 fn from(s: String) -> Self {
109 Self(s)
110 }
111}
112
113impl From<&str> for FsPath {
114 fn from(s: &str) -> Self {
115 Self(s.into())
116 }
117}
118
119#[cfg(feature = "client")]
120impl From<std::path::PathBuf> for FsPath {
121 fn from(path: std::path::PathBuf) -> Self {
122 let s = path.to_string_lossy().into_owned();
123 #[cfg(windows)]
124 let s = s.replace('\\', "/");
125 Self(s)
126 }
127}
128
129#[cfg(feature = "client")]
130impl From<&std::path::Path> for FsPath {
131 fn from(path: &std::path::Path) -> Self {
132 let s = path.to_string_lossy().into_owned();
133 #[cfg(windows)]
134 let s = s.replace('\\', "/");
135 Self(s)
136 }
137}
138
139#[cfg(feature = "client")]
140impl From<FsPath> for std::path::PathBuf {
141 fn from(path: FsPath) -> Self {
142 Self::from(path.0)
143 }
144}
145
146impl AsRef<str> for FsPath {
147 fn as_ref(&self) -> &str {
148 &self.0
149 }
150}
151
152#[cfg(feature = "client")]
153impl AsRef<std::path::Path> for FsPath {
154 fn as_ref(&self) -> &std::path::Path {
155 std::path::Path::new(&self.0)
156 }
157}
158
159#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
168pub struct MaildirPath(String);
169
170impl MaildirPath {
171 pub fn as_str(&self) -> &str {
173 &self.0
174 }
175
176 pub fn is_empty(&self) -> bool {
179 self.0.is_empty()
180 }
181
182 pub fn components(&self) -> impl Iterator<Item = &str> {
184 self.0.split('/').filter(|c| !c.is_empty())
185 }
186}
187
188impl fmt::Display for MaildirPath {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 fmt::Display::fmt(&self.0, f)
191 }
192}
193
194impl From<String> for MaildirPath {
195 fn from(s: String) -> Self {
196 Self(s)
197 }
198}
199
200impl From<&str> for MaildirPath {
201 fn from(s: &str) -> Self {
202 Self(s.into())
203 }
204}
205
206impl AsRef<str> for MaildirPath {
207 fn as_ref(&self) -> &str {
208 &self.0
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use alloc::vec::Vec;
215
216 use crate::path::{FsPath, MaildirPath};
217
218 #[test]
219 fn maildir_path_components_skips_empties() {
220 let p = MaildirPath::from("/Foo//Bar/");
221 let parts: Vec<&str> = p.components().collect();
222 assert_eq!(parts, ["Foo", "Bar"]);
223 }
224
225 #[test]
226 fn maildir_path_empty_is_root() {
227 assert!(MaildirPath::default().is_empty());
228 assert!(MaildirPath::from("").is_empty());
229 assert!(!MaildirPath::from("Foo").is_empty());
230 }
231
232 #[test]
233 fn join_inserts_separator() {
234 let p = FsPath::new("a");
235 assert_eq!(p.join("b").as_str(), "a/b");
236 }
237
238 #[test]
239 fn join_on_empty_skips_separator() {
240 let p = FsPath::default();
241 assert_eq!(p.join("a").as_str(), "a");
242 }
243
244 #[test]
245 fn join_normalises_trailing_separator() {
246 let p = FsPath::new("a/");
247 assert_eq!(p.join("b").as_str(), "a/b");
248 }
249
250 #[test]
251 fn file_name_returns_last_segment() {
252 assert_eq!(FsPath::new("a/b/c").file_name(), Some("c"));
253 assert_eq!(FsPath::new("c").file_name(), Some("c"));
254 assert_eq!(FsPath::default().file_name(), None);
255 assert_eq!(FsPath::new("a/").file_name(), None);
256 }
257
258 #[test]
259 fn parent_returns_path_without_last_segment() {
260 assert_eq!(FsPath::new("a/b/c").parent(), Some("a/b"));
261 assert_eq!(FsPath::new("a").parent(), None);
262 }
263
264 #[test]
265 fn with_file_name_replaces_last_segment() {
266 let p = FsPath::new("a/b/c");
267 assert_eq!(p.with_file_name("d").as_str(), "a/b/d");
268
269 let p = FsPath::new("a");
270 assert_eq!(p.with_file_name("z").as_str(), "z");
271 }
272
273 #[test]
274 fn strip_prefix_removes_leading_separator() {
275 let p = FsPath::new("root/sub/leaf");
276 let root = FsPath::new("root");
277 assert_eq!(p.strip_prefix(&root), Some("sub/leaf"));
278 }
279
280 #[test]
281 fn components_skips_empties() {
282 let p = FsPath::new("/a//b/");
283 let parts: Vec<&str> = p.components().collect();
284 assert_eq!(parts, ["a", "b"]);
285 }
286}