Skip to main content

io_maildir/
path.rs

1//! Two path flavours used across the crate: literal filesystem paths
2//! ([`FsPath`]) and logical mailbox-hierarchy paths ([`MaildirPath`]).
3//! A [`crate::store::MaildirStore`] translates between them under its
4//! configured layout (fs nested, or Maildir++ flat-dotted).
5
6use core::fmt;
7
8use alloc::string::String;
9
10/// Forward-slash separated literal filesystem path.
11///
12/// Always uses `/` regardless of host OS. `std::fs::*` accepts `/`-paths on
13/// both Unix and Windows, so no boundary conversion is needed in the client
14/// layer.
15#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct FsPath(String);
17
18impl FsPath {
19    /// Builds a new path from `s` without validation.
20    pub fn new(s: impl Into<String>) -> Self {
21        Self(s.into())
22    }
23
24    /// Returns the path as a `&str`.
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28
29    /// Returns the underlying [`String`].
30    pub fn into_string(self) -> String {
31        self.0
32    }
33
34    /// Returns `true` when the path is empty.
35    pub fn is_empty(&self) -> bool {
36        self.0.is_empty()
37    }
38
39    /// Returns a new path with `segment` appended after a `/`
40    /// separator.
41    ///
42    /// If `self` is empty the result is `segment` alone (no leading
43    /// `/`). Trailing `/` in `self` is normalized.
44    pub fn join(&self, segment: &str) -> Self {
45        let mut out = self.clone();
46        out.push(segment);
47        out
48    }
49
50    /// Appends `segment` to this path in place, inserting a `/`
51    /// separator unless `self` is empty or already ends with one.
52    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    /// Returns the final path component, if any.
60    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    /// Returns the path without its final component, if any.
69    pub fn parent(&self) -> Option<&str> {
70        self.0.rsplit_once('/').map(|(parent, _)| parent)
71    }
72
73    /// Replaces the final component of this path with `name`.
74    ///
75    /// If `self` has no parent, the result is `name` alone.
76    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    /// If `self` is rooted at `prefix`, returns the relative remainder
84    /// (without leading `/`).
85    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    /// Returns `true` when this path begins with `prefix`.
91    pub fn starts_with(&self, prefix: &Self) -> bool {
92        self.0.starts_with(prefix.as_str())
93    }
94
95    /// Iterates over the non-empty components of this path.
96    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/// Logical mailbox-hierarchy path, always `/`-separated regardless of
160/// the on-disk layout.
161///
162/// The empty path designates the store root itself (which is INBOX in
163/// Maildir++). A [`crate::store::MaildirStore`] turns this into a
164/// concrete [`FsPath`] under its layout: fs nested ("Foo/Bar" →
165/// `<root>/Foo/Bar`) or Maildir++ flat-dotted ("Foo/Bar" →
166/// `<root>/.Foo.Bar`).
167#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
168pub struct MaildirPath(String);
169
170impl MaildirPath {
171    /// Returns the path as a `&str`.
172    pub fn as_str(&self) -> &str {
173        &self.0
174    }
175
176    /// Returns `true` when the path is empty (i.e. designates the
177    /// store root).
178    pub fn is_empty(&self) -> bool {
179        self.0.is_empty()
180    }
181
182    /// Iterates over the non-empty hierarchy segments.
183    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}