1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub struct DirectoryPath {
7 pub value: String,
8}
9
10#[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#[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#[must_use]
36pub fn is_current_dir(input: &str) -> bool {
37 trim_trailing_dir_separator(&normalize_path_like(input)) == "."
38}
39
40#[must_use]
42pub fn is_parent_dir(input: &str) -> bool {
43 trim_trailing_dir_separator(&normalize_path_like(input)) == ".."
44}
45
46#[must_use]
48pub fn normalize_dir_path(input: &str) -> String {
49 trim_trailing_dir_separator(&normalize_path_like(input))
50}
51
52#[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#[must_use]
65pub fn strip_dir_trailing_separator(input: &str) -> String {
66 trim_trailing_dir_separator(&normalize_path_like(input))
67}
68
69#[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#[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#[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}