Skip to main content

io_vdir/
path.rs

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