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}