Skip to main content

io_m2dir/
path.rs

1//! '/'-separated path used by m2dir and m2store.
2
3use core::fmt;
4
5use alloc::string::String;
6
7/// Forward-slash separated path.
8///
9/// Always uses `/` regardless of host OS. `std::fs::*` accepts `/`-paths
10/// on both Unix and Windows, so no boundary conversion is needed in the
11/// client layer.
12#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub struct M2dirPath(String);
14
15impl M2dirPath {
16    /// Builds a new path from `s` without validation.
17    pub fn new(s: impl Into<String>) -> Self {
18        Self(s.into())
19    }
20
21    /// Returns the path as a `&str`.
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25
26    /// Returns the underlying [`String`].
27    pub fn into_string(self) -> String {
28        self.0
29    }
30
31    /// Returns `true` when the path is empty.
32    pub fn is_empty(&self) -> bool {
33        self.0.is_empty()
34    }
35
36    /// Returns a new path with `segment` appended after a `/`
37    /// separator.
38    ///
39    /// If `self` is empty the result is `segment` alone (no leading
40    /// `/`). Trailing `/` in `self` is normalized.
41    pub fn join(&self, segment: &str) -> Self {
42        let mut out = self.clone();
43        out.push(segment);
44        out
45    }
46
47    /// Appends `segment` to this path in place, inserting a `/`
48    /// separator unless `self` is empty or already ends with one.
49    pub fn push(&mut self, segment: &str) {
50        if !self.0.is_empty() && !self.0.ends_with('/') {
51            self.0.push('/');
52        }
53        self.0.push_str(segment);
54    }
55
56    /// Returns the final path component, if any.
57    pub fn file_name(&self) -> Option<&str> {
58        match self.0.rsplit_once('/') {
59            Some((_, name)) if !name.is_empty() => Some(name),
60            None if !self.0.is_empty() => Some(&self.0),
61            _ => None,
62        }
63    }
64
65    /// Returns the path without its final component, if any.
66    pub fn parent(&self) -> Option<&str> {
67        self.0.rsplit_once('/').map(|(parent, _)| parent)
68    }
69
70    /// If `self` is rooted at `prefix`, returns the relative remainder
71    /// (without leading `/`).
72    pub fn strip_prefix(&self, prefix: &Self) -> Option<&str> {
73        let rest = self.0.strip_prefix(prefix.as_str())?;
74        Some(rest.strip_prefix('/').unwrap_or(rest))
75    }
76
77    /// Returns `true` when this path begins with `prefix`.
78    pub fn starts_with(&self, prefix: &Self) -> bool {
79        self.0.starts_with(prefix.as_str())
80    }
81
82    /// Iterates over the non-empty components of this path.
83    pub fn components(&self) -> impl Iterator<Item = &str> {
84        self.0.split('/').filter(|c| !c.is_empty())
85    }
86}
87
88impl fmt::Display for M2dirPath {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        fmt::Display::fmt(&self.0, f)
91    }
92}
93
94impl From<String> for M2dirPath {
95    fn from(s: String) -> Self {
96        Self(s)
97    }
98}
99
100impl From<&str> for M2dirPath {
101    fn from(s: &str) -> Self {
102        Self(s.into())
103    }
104}
105
106#[cfg(feature = "client")]
107impl From<std::path::PathBuf> for M2dirPath {
108    fn from(path: std::path::PathBuf) -> Self {
109        Self(path.to_string_lossy().into_owned())
110    }
111}
112
113#[cfg(feature = "client")]
114impl From<&std::path::Path> for M2dirPath {
115    fn from(path: &std::path::Path) -> Self {
116        Self(path.to_string_lossy().into_owned())
117    }
118}
119
120#[cfg(feature = "client")]
121impl From<M2dirPath> for std::path::PathBuf {
122    fn from(path: M2dirPath) -> Self {
123        Self::from(path.0)
124    }
125}
126
127impl AsRef<str> for M2dirPath {
128    fn as_ref(&self) -> &str {
129        &self.0
130    }
131}
132
133#[cfg(feature = "client")]
134impl AsRef<std::path::Path> for M2dirPath {
135    fn as_ref(&self) -> &std::path::Path {
136        std::path::Path::new(&self.0)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use alloc::vec::Vec;
143
144    use crate::path::M2dirPath;
145
146    #[test]
147    fn join_inserts_separator() {
148        let p = M2dirPath::new("a");
149        assert_eq!(p.join("b").as_str(), "a/b");
150    }
151
152    #[test]
153    fn join_on_empty_skips_separator() {
154        let p = M2dirPath::default();
155        assert_eq!(p.join("a").as_str(), "a");
156    }
157
158    #[test]
159    fn join_normalises_trailing_separator() {
160        let p = M2dirPath::new("a/");
161        assert_eq!(p.join("b").as_str(), "a/b");
162    }
163
164    #[test]
165    fn file_name_returns_last_segment() {
166        assert_eq!(M2dirPath::new("a/b/c").file_name(), Some("c"));
167        assert_eq!(M2dirPath::new("c").file_name(), Some("c"));
168        assert_eq!(M2dirPath::default().file_name(), None);
169        assert_eq!(M2dirPath::new("a/").file_name(), None);
170    }
171
172    #[test]
173    fn parent_returns_path_without_last_segment() {
174        assert_eq!(M2dirPath::new("a/b/c").parent(), Some("a/b"));
175        assert_eq!(M2dirPath::new("a").parent(), None);
176    }
177
178    #[test]
179    fn strip_prefix_removes_leading_separator() {
180        let p = M2dirPath::new("root/sub/leaf");
181        let root = M2dirPath::new("root");
182        assert_eq!(p.strip_prefix(&root), Some("sub/leaf"));
183    }
184
185    #[test]
186    fn components_skips_empties() {
187        let p = M2dirPath::new("/a//b/");
188        let parts: Vec<&str> = p.components().collect();
189        assert_eq!(parts, ["a", "b"]);
190    }
191}