1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum PathKind {
7 Absolute,
9 Relative,
11 Empty,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PathSeparator {
18 Slash,
20 Backslash,
22 Mixed,
24 None,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub struct PathParts {
31 pub directory: Option<String>,
33 pub file_name: Option<String>,
35 pub extension: Option<String>,
37}
38
39#[must_use]
41pub fn path_kind(input: &str) -> PathKind {
42 if input.is_empty() {
43 return PathKind::Empty;
44 }
45
46 if is_absolute_prefix(input) {
47 PathKind::Absolute
48 } else {
49 PathKind::Relative
50 }
51}
52
53#[must_use]
55pub fn is_absolute_path(input: &str) -> bool {
56 matches!(path_kind(input), PathKind::Absolute)
57}
58
59#[must_use]
61pub fn is_relative_path(input: &str) -> bool {
62 matches!(path_kind(input), PathKind::Relative)
63}
64
65#[must_use]
67pub fn is_empty_path(input: &str) -> bool {
68 matches!(path_kind(input), PathKind::Empty)
69}
70
71#[must_use]
73pub fn detect_path_separator(input: &str) -> PathSeparator {
74 match (input.contains('/'), input.contains('\\')) {
75 (true, true) => PathSeparator::Mixed,
76 (true, false) => PathSeparator::Slash,
77 (false, true) => PathSeparator::Backslash,
78 (false, false) => PathSeparator::None,
79 }
80}
81
82#[must_use]
84pub fn normalize_path_separators(input: &str) -> String {
85 input.replace('\\', "/")
86}
87
88#[must_use]
90pub fn trim_trailing_separator(input: &str) -> String {
91 let mut normalized = normalize_path_separators(input);
92
93 while normalized.ends_with('/') && !is_root_like(&normalized) {
94 normalized.pop();
95 }
96
97 normalized
98}
99
100#[must_use]
102pub fn ensure_trailing_separator(input: &str) -> String {
103 let normalized = normalize_path_separators(input);
104 if normalized.is_empty() || normalized.ends_with('/') {
105 return normalized;
106 }
107
108 format!("{normalized}/")
109}
110
111#[must_use]
113pub fn join_path_parts(parts: &[&str]) -> String {
114 let mut prefix = String::new();
115 let mut segments = Vec::new();
116
117 for part in parts.iter().copied().filter(|part| !part.is_empty()) {
118 let normalized = normalize_path_separators(part);
119 if prefix.is_empty() {
120 if normalized.starts_with("//") {
121 prefix = String::from("//");
122 segments.extend(
123 normalized[2..]
124 .split('/')
125 .filter(|segment| !segment.is_empty())
126 .map(ToOwned::to_owned),
127 );
128 continue;
129 }
130
131 if let Some(root) = drive_root_prefix(&normalized) {
132 prefix = root.to_string();
133 segments.extend(
134 normalized[root.len()..]
135 .split('/')
136 .filter(|segment| !segment.is_empty())
137 .map(ToOwned::to_owned),
138 );
139 continue;
140 }
141
142 if let Some(remainder) = normalized.strip_prefix('/') {
143 prefix = String::from("/");
144 segments.extend(
145 remainder
146 .split('/')
147 .filter(|segment| !segment.is_empty())
148 .map(ToOwned::to_owned),
149 );
150 continue;
151 }
152 }
153
154 segments.extend(
155 normalized
156 .split('/')
157 .filter(|segment| !segment.is_empty())
158 .map(ToOwned::to_owned),
159 );
160 }
161
162 match prefix.as_str() {
163 "//" => {
164 if segments.is_empty() {
165 String::from("//")
166 } else {
167 format!("//{}", segments.join("/"))
168 }
169 }
170 "/" => {
171 if segments.is_empty() {
172 String::from("/")
173 } else {
174 format!("/{}", segments.join("/"))
175 }
176 }
177 _ if !prefix.is_empty() => {
178 if segments.is_empty() {
179 prefix
180 } else {
181 format!("{prefix}{}", segments.join("/"))
182 }
183 }
184 _ => segments.join("/"),
185 }
186}
187
188#[must_use]
190pub fn split_path_parts(input: &str) -> Vec<String> {
191 normalize_path_separators(input)
192 .split('/')
193 .filter(|segment| !segment.is_empty())
194 .map(ToOwned::to_owned)
195 .collect()
196}
197
198#[must_use]
200pub fn parent_path(input: &str) -> Option<String> {
201 let normalized = trim_trailing_separator(input);
202 if normalized.is_empty() || is_root_like(&normalized) {
203 return None;
204 }
205
206 let slash_index = normalized.rfind('/')?;
207 if slash_index == 0 {
208 return Some(String::from("/"));
209 }
210
211 if slash_index == 2 && drive_root_prefix(&normalized).is_some() {
212 return Some(normalized[..=slash_index].to_string());
213 }
214
215 if !is_absolute_prefix(&normalized)
216 && (normalized[..slash_index].ends_with(':') || normalized[..slash_index].contains(":/"))
217 {
218 return None;
219 }
220
221 let parent = &normalized[..slash_index];
222 if parent.is_empty() {
223 None
224 } else {
225 Some(parent.to_string())
226 }
227}
228
229#[must_use]
231pub fn file_name_from_path(input: &str) -> Option<String> {
232 let normalized = normalize_path_separators(input);
233 let candidate = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
234 if candidate.is_empty() {
235 return None;
236 }
237
238 let trimmed = trim_trailing_separator(&normalized);
239 if is_root_like(&trimmed) {
240 return None;
241 }
242
243 Some(candidate.to_string())
244}
245
246#[must_use]
248pub fn extension_from_path(input: &str) -> Option<String> {
249 let file_name = file_name_from_path(input)?;
250 let (_, extension) = split_simple_extension(file_name.as_str())?;
251 Some(extension.to_string())
252}
253
254#[must_use]
256pub fn path_parts(input: &str) -> PathParts {
257 let normalized = normalize_path_separators(input);
258 if normalized.is_empty() {
259 return PathParts::default();
260 }
261
262 if normalized.ends_with('/') && !is_root_like(&trim_trailing_separator(&normalized)) {
263 return PathParts {
264 directory: Some(trim_trailing_separator(&normalized)),
265 file_name: None,
266 extension: None,
267 };
268 }
269
270 PathParts {
271 directory: parent_path(input),
272 file_name: file_name_from_path(input),
273 extension: extension_from_path(input),
274 }
275}
276
277fn is_absolute_prefix(input: &str) -> bool {
278 input.starts_with('/')
279 || input.starts_with('\\')
280 || input.starts_with("//")
281 || drive_root_prefix(&normalize_path_separators(input)).is_some()
282}
283
284fn drive_root_prefix(input: &str) -> Option<&str> {
285 let bytes = input.as_bytes();
286 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
287 Some(&input[..3])
288 } else {
289 None
290 }
291}
292
293fn is_root_like(input: &str) -> bool {
294 if matches!(input, "/" | "//") {
295 return true;
296 }
297
298 if drive_root_prefix(input).is_some() && input.len() == 3 {
299 return true;
300 }
301
302 if let Some(remainder) = input.strip_prefix("//") {
303 let segments: Vec<_> = remainder
304 .split('/')
305 .filter(|segment| !segment.is_empty())
306 .collect();
307 return segments.len() == 2;
308 }
309
310 false
311}
312
313fn split_simple_extension(file_name: &str) -> Option<(&str, &str)> {
314 let dot_index = file_name.rfind('.')?;
315 if dot_index == file_name.len() - 1 {
316 return None;
317 }
318
319 if dot_index == 0 {
320 let nested_dot = file_name[1..].rfind('.')? + 1;
321 if nested_dot == file_name.len() - 1 {
322 return None;
323 }
324
325 return Some((&file_name[..nested_dot], &file_name[nested_dot + 1..]));
326 }
327
328 Some((&file_name[..dot_index], &file_name[dot_index + 1..]))
329}