Skip to main content

use_dir/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A string-backed directory path.
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub struct DirectoryPath {
7    pub value: String,
8}
9
10/// Extracts the most relevant directory segment from a path-like input.
11#[must_use]
12pub fn dir_name(input: &str) -> Option<String> {
13    let normalized = normalize_path_like(input);
14    let trimmed = trim_trailing_dir_separator(&normalized);
15    let (_, segments) = split_root_and_segments(&trimmed);
16    let last = segments.last()?.to_string();
17
18    if normalized.ends_with('/') || !looks_like_file_name(last.as_str()) {
19        return Some(last);
20    }
21
22    (segments.len() >= 2).then(|| segments[segments.len() - 2].to_string())
23}
24
25/// Returns `true` when the input is a recognized root directory.
26#[must_use]
27pub fn is_root_dir(input: &str) -> bool {
28    let normalized = normalize_path_like(input);
29    let trimmed = trim_trailing_dir_separator(&normalized);
30    let (root, segments) = split_root_and_segments(&trimmed);
31    root.is_some() && segments.is_empty()
32}
33
34/// Returns `true` when the input refers to the current directory.
35#[must_use]
36pub fn is_current_dir(input: &str) -> bool {
37    trim_trailing_dir_separator(&normalize_path_like(input)) == "."
38}
39
40/// Returns `true` when the input refers to the parent directory.
41#[must_use]
42pub fn is_parent_dir(input: &str) -> bool {
43    trim_trailing_dir_separator(&normalize_path_like(input)) == ".."
44}
45
46/// Normalizes directory separators to `/` and removes trailing separators unless the input is a root.
47#[must_use]
48pub fn normalize_dir_path(input: &str) -> String {
49    trim_trailing_dir_separator(&normalize_path_like(input))
50}
51
52/// Ensures a trailing `/` for non-empty directory paths.
53#[must_use]
54pub fn ensure_dir_trailing_separator(input: &str) -> String {
55    let normalized = normalize_path_like(input);
56    if normalized.is_empty() || normalized.ends_with('/') {
57        return normalized;
58    }
59
60    format!("{normalized}/")
61}
62
63/// Removes trailing separators while preserving roots.
64#[must_use]
65pub fn strip_dir_trailing_separator(input: &str) -> String {
66    trim_trailing_dir_separator(&normalize_path_like(input))
67}
68
69/// Returns the lexical depth of a directory path.
70#[must_use]
71pub fn path_depth(input: &str) -> usize {
72    let normalized = trim_trailing_dir_separator(&normalize_path_like(input));
73    let (_, segments) = split_root_and_segments(&normalized);
74    segments.len()
75}
76
77/// Returns `true` when the input starts with the given directory on segment boundaries.
78#[must_use]
79pub fn starts_with_dir(input: &str, dir: &str) -> bool {
80    let input_normalized = normalize_dir_path(input);
81    let dir_normalized = normalize_dir_path(dir);
82    if dir_normalized.is_empty() {
83        return false;
84    }
85
86    let (input_root, input_segments) = split_root_and_segments(&input_normalized);
87    let (dir_root, dir_segments) = split_root_and_segments(&dir_normalized);
88    if input_root != dir_root || dir_segments.len() > input_segments.len() {
89        return false;
90    }
91
92    input_segments.starts_with(&dir_segments)
93}
94
95/// Computes a lexical relative path when `base` is a prefix of `path`.
96#[must_use]
97pub fn relative_to_dir(path: &str, base: &str) -> Option<String> {
98    let normalized_path = normalize_dir_path(path);
99    let normalized_base = normalize_dir_path(base);
100    if normalized_base.is_empty() {
101        return None;
102    }
103
104    let (path_root, path_segments) = split_root_and_segments(&normalized_path);
105    let (base_root, base_segments) = split_root_and_segments(&normalized_base);
106    if path_root != base_root || base_segments.len() > path_segments.len() {
107        return None;
108    }
109
110    if !path_segments.starts_with(&base_segments) {
111        return None;
112    }
113
114    let remainder = &path_segments[base_segments.len()..];
115    if remainder.is_empty() {
116        Some(String::from("."))
117    } else {
118        Some(remainder.join("/"))
119    }
120}
121
122fn normalize_path_like(input: &str) -> String {
123    input.replace('\\', "/")
124}
125
126fn trim_trailing_dir_separator(input: &str) -> String {
127    let mut value = input.to_string();
128    while value.ends_with('/') && !is_root_like(&value) {
129        value.pop();
130    }
131    value
132}
133
134fn is_root_like(input: &str) -> bool {
135    if matches!(input, "/" | "//") {
136        return true;
137    }
138
139    if drive_root_prefix(input).is_some() && input.len() == 3 {
140        return true;
141    }
142
143    if let Some(remainder) = input.strip_prefix("//") {
144        let segments: Vec<_> = remainder
145            .split('/')
146            .filter(|segment| !segment.is_empty())
147            .collect();
148        return segments.len() == 2;
149    }
150
151    false
152}
153
154fn drive_root_prefix(input: &str) -> Option<&str> {
155    let bytes = input.as_bytes();
156    if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
157        Some(&input[..3])
158    } else {
159        None
160    }
161}
162
163fn split_root_and_segments(input: &str) -> (Option<String>, Vec<String>) {
164    if let Some(remainder) = input.strip_prefix("//") {
165        let segments: Vec<_> = remainder
166            .split('/')
167            .filter(|segment| !segment.is_empty())
168            .collect();
169        if segments.len() >= 2 {
170            let root = format!("//{}/{}", segments[0], segments[1]);
171            let rest = segments[2..]
172                .iter()
173                .map(|segment| (*segment).to_string())
174                .collect();
175            return (Some(root), rest);
176        }
177    }
178
179    if let Some(root) = drive_root_prefix(input) {
180        let rest = input[root.len()..]
181            .split('/')
182            .filter(|segment| !segment.is_empty())
183            .map(ToOwned::to_owned)
184            .collect();
185        return (Some(root.to_string()), rest);
186    }
187
188    if let Some(remainder) = input.strip_prefix('/') {
189        let rest = remainder
190            .split('/')
191            .filter(|segment| !segment.is_empty())
192            .map(ToOwned::to_owned)
193            .collect();
194        return (Some(String::from("/")), rest);
195    }
196
197    let segments = input
198        .split('/')
199        .filter(|segment| !segment.is_empty())
200        .map(ToOwned::to_owned)
201        .collect();
202    (None, segments)
203}
204
205fn looks_like_file_name(segment: &str) -> bool {
206    if matches!(segment, "." | "..") || segment.is_empty() {
207        return false;
208    }
209
210    if segment.starts_with('.') {
211        return true;
212    }
213
214    match segment.rfind('.') {
215        Some(index) if index > 0 && index + 1 < segment.len() => true,
216        _ => false,
217    }
218}