Skip to main content

use_path/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// Classifies a path-like input using a small lexical model.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum PathKind {
7    /// The input begins with a recognized absolute prefix.
8    Absolute,
9    /// The input is non-empty and does not begin with a recognized absolute prefix.
10    Relative,
11    /// The input is an empty string.
12    Empty,
13}
14
15/// Describes which separator styles appear in a path-like input.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PathSeparator {
18    /// Only `/` appears.
19    Slash,
20    /// Only `\` appears.
21    Backslash,
22    /// Both `/` and `\` appear.
23    Mixed,
24    /// No path separator appears.
25    None,
26}
27
28/// A lexical view of a path-like input.
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub struct PathParts {
31    /// The parent directory portion when one is present.
32    pub directory: Option<String>,
33    /// The final file-name segment when one is present.
34    pub file_name: Option<String>,
35    /// The final simple extension when one is present.
36    pub extension: Option<String>,
37}
38
39/// Returns the lexical kind for a path-like input.
40#[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/// Returns `true` when the input begins with a recognized absolute prefix.
54#[must_use]
55pub fn is_absolute_path(input: &str) -> bool {
56    matches!(path_kind(input), PathKind::Absolute)
57}
58
59/// Returns `true` when the input is non-empty and not absolute.
60#[must_use]
61pub fn is_relative_path(input: &str) -> bool {
62    matches!(path_kind(input), PathKind::Relative)
63}
64
65/// Returns `true` when the input is empty.
66#[must_use]
67pub fn is_empty_path(input: &str) -> bool {
68    matches!(path_kind(input), PathKind::Empty)
69}
70
71/// Detects which path separator style appears in the input.
72#[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/// Normalizes path separators to `/`.
83#[must_use]
84pub fn normalize_path_separators(input: &str) -> String {
85    input.replace('\\', "/")
86}
87
88/// Removes trailing separators while preserving recognized roots.
89#[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/// Ensures a trailing `/` for non-empty input.
101#[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/// Joins path parts with `/` while preserving a leading absolute prefix when present.
112#[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/// Splits a path-like input into normalized non-empty segments.
189#[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/// Extracts the lexical parent path when one is present.
199#[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/// Extracts the final file-name segment from a path-like input.
230#[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/// Extracts the final simple extension from a path-like input.
247#[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/// Returns lexical directory, file-name, and extension parts.
255#[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}