Skip to main content

qubit_fs/path/
path.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8
9//! Provider-neutral logical paths.
10
11use std::fmt::Display;
12use std::fmt::Formatter;
13use std::fmt::Result as FmtResult;
14
15use super::PathComponent;
16use super::PathComponents;
17use super::PathSemantics;
18use super::RelativePath;
19use crate::error::FsError;
20use crate::error::FsOperation;
21use crate::error::FsResult;
22
23/// A validated logical path independent of any provider-native representation.
24///
25/// # Examples
26/// ```rust
27/// use qubit_fs::Path;
28/// use qubit_fs::path::RelativePath;
29/// let root = Path::parse("/reports")?;
30/// let report = root.join(&RelativePath::parse("daily.csv")?);
31/// assert_eq!("/reports/daily.csv", report.as_str());
32/// assert!(report.is_absolute());
33/// # Ok::<(), qubit_fs::FsError>(())
34/// ```
35#[derive(Clone, Debug, Eq, Hash, PartialEq)]
36pub struct Path {
37    /// Whether this logical path starts at a provider root.
38    absolute: bool,
39    /// Canonical normalized or provider-literal text.
40    text: String,
41    /// Whether component iteration must preserve literal slash boundaries.
42    literal: bool,
43    /// Semantics used to validate this spelling.
44    semantics: PathSemantics,
45}
46
47impl Path {
48    /// Creates the canonical hierarchical root path.
49    #[inline]
50    #[must_use]
51    pub fn root() -> Self {
52        Self {
53            absolute: true,
54            text: "/".to_owned(),
55            literal: false,
56            semantics: PathSemantics::Hierarchical,
57        }
58    }
59
60    /// Constructs a hierarchical path from independently validated components.
61    ///
62    /// Each item is validated as one component without reparsing a joined path
63    /// string. An empty absolute sequence produces the root; an empty relative
64    /// sequence returns an invalid-path error.
65    ///
66    /// # Parameters
67    /// - `absolute`: Whether the resulting path is rooted at the provider root.
68    /// - `components`: Validated path-component text to join in order.
69    ///
70    /// # Errors
71    /// Returns an invalid-path error when a component is empty, contains a
72    /// separator or traversal marker, or when a relative sequence is empty.
73    #[inline]
74    pub fn from_components<I, S>(absolute: bool, components: I) -> FsResult<Self>
75    where
76        I: IntoIterator<Item = S>,
77        S: AsRef<str>,
78    {
79        let components = components
80            .into_iter()
81            .map(|value| PathComponent::parse(value.as_ref()))
82            .collect::<FsResult<Vec<_>>>()?;
83        if !absolute && components.is_empty() {
84            return Err(invalid_path());
85        }
86        let joined = components
87            .iter()
88            .map(PathComponent::as_str)
89            .collect::<Vec<_>>()
90            .join("/");
91        Ok(Self {
92            absolute,
93            text: if absolute {
94                if joined.is_empty() {
95                    "/".to_owned()
96                } else {
97                    format!("/{joined}")
98                }
99            } else {
100                joined
101            },
102            literal: false,
103            semantics: PathSemantics::Hierarchical,
104        })
105    }
106
107    /// Parses a hierarchical logical path using normalized semantics.
108    ///
109    /// Returns an invalid-path error for empty input, NUL, or root escape.
110    ///
111    /// # Errors
112    /// Returns [`FsError`] with an invalid-path kind when `text` is empty,
113    /// contains NUL, or escapes above the hierarchical root.
114    #[inline]
115    pub fn parse(text: &str) -> FsResult<Self> {
116        Self::parse_with_semantics(text, PathSemantics::Hierarchical)
117    }
118
119    /// Parses a provider-literal path without interpreting separators or dots.
120    ///
121    /// Returns an invalid-path error for empty input or NUL.
122    ///
123    /// # Errors
124    /// Returns [`FsError`] with an invalid-path kind when `text` is empty or
125    /// contains NUL.
126    #[inline]
127    pub fn parse_literal(text: &str) -> FsResult<Self> {
128        Self::parse_with_semantics(text, PathSemantics::ObjectKey)
129    }
130
131    /// Parses `text` according to explicitly selected provider semantics.
132    ///
133    /// Hierarchical values normalize empty and dot components and reject root
134    /// escapes. Object-key and provider-specific values preserve their text.
135    ///
136    /// # Parameters
137    /// - `text`: Provider path text to validate and normalize.
138    /// - `semantics`: Path semantics controlling normalization and root rules.
139    ///
140    /// # Errors
141    /// Returns an invalid-path error when `text` is empty, contains NUL, or
142    /// escapes above the hierarchical root.
143    pub fn parse_with_semantics(text: &str, semantics: PathSemantics) -> FsResult<Self> {
144        if text.is_empty() || text.contains('\0') {
145            return Err(invalid_path());
146        }
147        if semantics != PathSemantics::Hierarchical {
148            return Ok(Self {
149                absolute: text.starts_with('/'),
150                text: text.to_owned(),
151                literal: true,
152                semantics,
153            });
154        }
155        let absolute = text.starts_with('/');
156        let mut components = Vec::new();
157        for component in text.split('/') {
158            match component {
159                "" | "." => {}
160                ".." => {
161                    if components.pop().is_none() {
162                        return Err(invalid_path());
163                    }
164                }
165                value => components.push(value),
166            }
167        }
168        let text = if absolute {
169            if components.is_empty() {
170                "/".to_owned()
171            } else {
172                format!("/{}", components.join("/"))
173            }
174        } else {
175            components.join("/")
176        };
177        if text.is_empty() {
178            return Err(invalid_path());
179        }
180        Ok(Self {
181            absolute,
182            text,
183            literal: false,
184            semantics,
185        })
186    }
187
188    /// Returns the validated logical path text.
189    #[inline]
190    #[must_use]
191    pub fn as_str(&self) -> &str {
192        &self.text
193    }
194
195    /// Returns the final non-empty path component, when one is present.
196    ///
197    /// A root path and a literal path ending in a separator have no file
198    /// name. Hierarchical paths are canonicalized during parsing, so their
199    /// final component is always non-empty.
200    ///
201    /// # Returns
202    /// `Some` with the final non-empty component, or `None` for a root or a
203    /// literal path ending in a separator.
204    #[inline]
205    #[must_use]
206    pub fn file_name(&self) -> Option<&str> {
207        if self.text == "/" || (self.literal && self.text.ends_with('/')) {
208            return None;
209        }
210        self.text.rsplit('/').find(|component| !component.is_empty())
211    }
212
213    /// Returns whether this path is absolute.
214    #[inline]
215    #[must_use]
216    pub const fn is_absolute(&self) -> bool {
217        self.absolute
218    }
219
220    /// Returns the semantics used to validate this logical path.
221    #[inline]
222    #[must_use]
223    pub const fn semantics(&self) -> PathSemantics {
224        self.semantics
225    }
226
227    /// Iterates lexical component boundaries without using an empty root value.
228    #[inline]
229    #[must_use]
230    pub fn components(&self) -> PathComponents<'_> {
231        PathComponents::new(&self.text, self.absolute, self.literal)
232    }
233
234    /// Appends one validated component without re-parsing provider text.
235    #[inline]
236    #[must_use]
237    pub fn child(&self, component: &PathComponent) -> Self {
238        self.append(component.as_str())
239    }
240
241    /// Appends a safe normalized relative path without re-parsing provider
242    /// text.
243    #[inline]
244    #[must_use]
245    pub fn join(&self, relative: &RelativePath) -> Self {
246        self.append(relative.as_str())
247    }
248
249    /// Joins an already validated suffix to this path.
250    fn append(&self, suffix: &str) -> Self {
251        let text = if self.text == "/" {
252            format!("/{suffix}")
253        } else {
254            format!("{}/{}", self.text, suffix)
255        };
256        Self {
257            absolute: self.absolute,
258            text,
259            literal: self.literal,
260            semantics: self.semantics,
261        }
262    }
263}
264
265impl Display for Path {
266    /// Formats the validated logical spelling.
267    #[inline]
268    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
269        formatter.write_str(self.as_str())
270    }
271}
272
273impl AsRef<str> for Path {
274    /// Returns the logical path text for generic text consumers.
275    #[inline]
276    fn as_ref(&self) -> &str {
277        self.as_str()
278    }
279}
280
281/// Builds the shared logical path validation failure.
282fn invalid_path() -> FsError {
283    FsError::invalid_path(
284        FsOperation::ParsePath,
285        "path must be non-empty, NUL-free, and remain within its root",
286    )
287}
288
289#[cfg(test)]
290mod tests {
291    use std::hint::black_box;
292
293    use super::Path;
294    use crate::path::PathComponent;
295    use crate::path::PathSemantics;
296    use crate::path::RelativePath;
297
298    #[test]
299    fn path_accessors_and_constructors_are_executed_at_runtime() {
300        let root: fn() -> Path = black_box(Path::root);
301        let parse_literal: fn(&str) -> crate::error::FsResult<Path> = black_box(Path::parse_literal);
302        let parse_with_semantics: fn(&str, PathSemantics) -> crate::error::FsResult<Path> =
303            black_box(Path::parse_with_semantics);
304        let as_str: for<'a> fn(&'a Path) -> &'a str = black_box(Path::as_str);
305        let file_name: for<'a> fn(&'a Path) -> Option<&'a str> = black_box(Path::file_name);
306        let is_absolute: fn(&Path) -> bool = black_box(Path::is_absolute);
307        let semantics: fn(&Path) -> PathSemantics = black_box(Path::semantics);
308        let components = black_box(Path::components);
309        let child: fn(&Path, &PathComponent) -> Path = black_box(Path::child);
310        let join: fn(&Path, &RelativePath) -> Path = black_box(Path::join);
311        let as_ref: for<'a> fn(&'a Path) -> &'a str = black_box(<Path as AsRef<str>>::as_ref);
312
313        let built = Path::from_components(true, vec!["reports", "daily.csv"]).expect("components should form a path");
314        assert!(Path::from_components(false, Vec::<&str>::new()).is_err());
315        let literal = parse_literal("bucket/key").expect("literal path should parse");
316        let provider = parse_with_semantics("bucket/key", PathSemantics::ProviderSpecific)
317            .expect("provider-specific path should parse");
318        let component = PathComponent::parse("archive").expect("component should parse");
319        let relative = RelativePath::parse("daily.csv").expect("relative path should parse");
320
321        assert_eq!("/", as_str(&root()));
322        assert_eq!(Some("daily.csv"), file_name(&built));
323        assert!(is_absolute(&built));
324        assert_eq!(PathSemantics::ObjectKey, semantics(&literal));
325        assert_eq!(PathSemantics::ProviderSpecific, semantics(&provider));
326        let parent = Path::parse("/reports").expect("parent path should parse");
327        assert_eq!("/reports/daily.csv", as_str(&join(&parent, &relative)));
328        assert_eq!(
329            "/reports/archive",
330            as_str(&child(&Path::parse("/reports").unwrap(), &component))
331        );
332        assert_eq!("reports/daily.csv", components(&built).collect::<Vec<_>>().join("/"));
333        assert_eq!(as_str(&built), as_ref(&built));
334    }
335}