Skip to main content

qubit_fs/path/
fs_path.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! Provider-local filesystem path model.
11
12use std::fmt::{
13    Display,
14    Formatter,
15    Result as FmtResult,
16};
17
18use crate::{
19    FsError,
20    FsOperation,
21    FsResult,
22};
23
24/// Provider-local filesystem path.
25#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
26pub struct FsPath {
27    /// Whether the path is absolute.
28    absolute: bool,
29    /// Normalized path string using `/` separators.
30    normalized: String,
31}
32
33impl FsPath {
34    /// Parses and normalizes a filesystem path.
35    ///
36    /// # Parameters
37    /// - `path`: Raw path string.
38    ///
39    /// # Returns
40    /// Normalized provider-local path.
41    ///
42    /// # Errors
43    /// Returns [`FsError`] when the path is empty, contains a NUL byte, or tries
44    /// to escape above its root with `..`.
45    pub fn parse(path: &str) -> FsResult<Self> {
46        if path.is_empty() {
47            return Err(FsError::invalid_path(FsOperation::ParsePath, "path must not be empty"));
48        }
49        if path.contains('\0') {
50            return Err(FsError::invalid_path(
51                FsOperation::ParsePath,
52                "path must not contain NUL bytes",
53            ));
54        }
55        let absolute = path.starts_with('/');
56        let mut components = Vec::new();
57        for component in path.split('/') {
58            match component {
59                "" | "." => {}
60                ".." => {
61                    if components.pop().is_none() {
62                        return Err(FsError::invalid_path(
63                            FsOperation::ParsePath,
64                            "path must not escape above its root",
65                        ));
66                    }
67                }
68                _ => components.push(component),
69            }
70        }
71        let normalized = if absolute {
72            if components.is_empty() {
73                "/".to_owned()
74            } else {
75                format!("/{}", components.join("/"))
76            }
77        } else {
78            components.join("/")
79        };
80        if normalized.is_empty() {
81            return Err(FsError::invalid_path(
82                FsOperation::ParsePath,
83                "relative path must not normalize to empty",
84            ));
85        }
86        Ok(Self { absolute, normalized })
87    }
88
89    /// Creates the absolute root path.
90    ///
91    /// # Returns
92    /// Root filesystem path.
93    #[inline]
94    #[must_use]
95    pub fn root() -> Self {
96        Self {
97            absolute: true,
98            normalized: "/".to_owned(),
99        }
100    }
101
102    /// Tells whether this path is absolute.
103    ///
104    /// # Returns
105    /// `true` when the path starts at the provider root.
106    #[inline]
107    #[must_use]
108    pub fn is_absolute(&self) -> bool {
109        self.absolute
110    }
111
112    /// Gets the normalized path string.
113    ///
114    /// # Returns
115    /// Normalized path string using `/` separators.
116    #[inline]
117    #[must_use]
118    pub fn as_str(&self) -> &str {
119        &self.normalized
120    }
121
122    /// Joins a child path to this path.
123    ///
124    /// # Parameters
125    /// - `child`: Relative or absolute child path.
126    ///
127    /// # Returns
128    /// Joined path. Absolute child paths replace the base path.
129    ///
130    /// # Errors
131    /// Returns [`FsError`] when `child` is not a valid path.
132    pub fn join(&self, child: &str) -> FsResult<Self> {
133        let child = Self::parse(child)?;
134        if child.is_absolute() {
135            return Ok(child);
136        }
137        let joined = if self.normalized == "/" {
138            format!("/{}", child.as_str())
139        } else {
140            format!("{}/{}", self.normalized, child.as_str())
141        };
142        Self::parse(&joined)
143    }
144
145    /// Gets this path's parent.
146    ///
147    /// # Returns
148    /// `Some` parent path when the path has one, or `None` for root and
149    /// parentless relative paths.
150    #[must_use]
151    pub fn parent(&self) -> Option<Self> {
152        if self.normalized == "/" {
153            return None;
154        }
155        let trimmed = self.normalized.trim_end_matches('/');
156        let index = trimmed.rfind('/')?;
157        if index == 0 && self.absolute {
158            Some(Self::root())
159        } else if index == 0 {
160            None
161        } else {
162            Self::parse(&trimmed[..index]).ok()
163        }
164    }
165
166    /// Gets the final path component.
167    ///
168    /// # Returns
169    /// `Some` file name when one exists, or `None` for root.
170    #[must_use]
171    pub fn file_name(&self) -> Option<&str> {
172        if self.normalized == "/" {
173            None
174        } else {
175            self.normalized.rsplit('/').next()
176        }
177    }
178
179    /// Gets the final path component extension.
180    ///
181    /// # Returns
182    /// `Some` extension without the dot when the final path component has a
183    /// non-empty extension, or `None` for root, extensionless names, hidden
184    /// names such as `.profile`, and names ending with a dot.
185    #[must_use]
186    pub fn file_extension(&self) -> Option<&str> {
187        let file_name = self.file_name()?;
188        let index = file_name.rfind('.')?;
189        if index == 0 || index + 1 == file_name.len() {
190            None
191        } else {
192            Some(&file_name[index + 1..])
193        }
194    }
195}
196
197impl Display for FsPath {
198    #[inline]
199    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
200        formatter.write_str(&self.normalized)
201    }
202}