1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum PathCaseSensitivity {
4 Sensitive,
5 Insensitive,
6}
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct PathPolicy {
11 pub separator: char,
12 pub case_sensitivity: PathCaseSensitivity,
13}
14
15impl PathPolicy {
16 #[inline]
17 pub const fn new(separator: char, case_sensitivity: PathCaseSensitivity) -> Self {
18 Self {
19 separator,
20 case_sensitivity,
21 }
22 }
23
24 #[inline]
25 pub const fn posix() -> Self {
26 Self::new('/', PathCaseSensitivity::Sensitive)
27 }
28}
29
30pub fn path_matches_or_contains(path: &str, target: &str, policy: &PathPolicy) -> bool {
32 let normalized_path = normalized_for_compare(path, policy.separator);
33 let normalized_target = normalized_for_compare(target, policy.separator);
34 if compare_with_policy(normalized_path, normalized_target, policy) {
35 return true;
36 }
37 match strip_prefix_with_policy(normalized_path, normalized_target, policy) {
38 Some(remainder) => starts_with_separator(remainder, policy.separator),
39 None => false,
40 }
41}
42
43pub fn parent_path<'a>(path: &'a str, policy: &PathPolicy) -> Option<&'a str> {
45 let normalized = trim_trailing_separators(path, policy.separator);
46 if normalized.is_empty() {
47 return None;
48 }
49 if is_root_path(normalized, policy.separator) {
50 return None;
51 }
52 let last_index = normalized.rfind(policy.separator)?;
53 if last_index == 0 {
54 return Some(&normalized[..1]);
55 }
56 Some(&normalized[..last_index])
57}
58
59pub fn join_path(base: &str, name: &str, policy: &PathPolicy) -> String {
61 let normalized_base = trim_trailing_separators(base, policy.separator);
62 let normalized_name = trim_leading_separators(name, policy.separator);
63 if normalized_base.is_empty() {
64 return normalized_name.to_string();
65 }
66 if normalized_name.is_empty() {
67 return normalized_base.to_string();
68 }
69 if is_root_path(normalized_base, policy.separator) {
70 return format!("{}{normalized_name}", policy.separator);
71 }
72 format!("{normalized_base}{}{normalized_name}", policy.separator)
73}
74
75pub fn remap_path_for_rename(
77 path: &str,
78 source: &str,
79 target: &str,
80 policy: &PathPolicy,
81) -> Option<String> {
82 let normalized_path = normalized_for_compare(path, policy.separator);
83 let normalized_source = normalized_for_compare(source, policy.separator);
84 let normalized_target = normalized_for_compare(target, policy.separator);
85 if compare_with_policy(normalized_path, normalized_source, policy) {
86 return Some(normalized_target.to_string());
87 }
88 let remainder = strip_prefix_with_policy(normalized_path, normalized_source, policy)?;
89 if !starts_with_separator(remainder, policy.separator) {
90 return None;
91 }
92 let stripped_remainder = trim_leading_separators(remainder, policy.separator);
93 Some(join_path(normalized_target, stripped_remainder, policy))
94}
95
96fn compare_with_policy(left: &str, right: &str, policy: &PathPolicy) -> bool {
97 match policy.case_sensitivity {
98 PathCaseSensitivity::Sensitive => left == right,
99 PathCaseSensitivity::Insensitive => left.to_lowercase() == right.to_lowercase(),
100 }
101}
102
103fn strip_prefix_with_policy<'a>(
104 path: &'a str,
105 prefix: &str,
106 policy: &PathPolicy,
107) -> Option<&'a str> {
108 match policy.case_sensitivity {
109 PathCaseSensitivity::Sensitive => path.strip_prefix(prefix),
110 PathCaseSensitivity::Insensitive => {
111 let prefix_len = prefix.len();
112 let path_prefix = path.get(..prefix_len)?;
113 if path_prefix.to_lowercase() == prefix.to_lowercase() {
114 path.get(prefix_len..)
115 } else {
116 None
117 }
118 }
119 }
120}
121
122fn normalized_for_compare(path: &str, separator: char) -> &str {
123 let trimmed = trim_trailing_separators(path, separator);
124 if trimmed.is_empty() && path.starts_with(separator) {
125 root_slice(path, separator)
126 } else {
127 trimmed
128 }
129}
130
131fn trim_trailing_separators(path: &str, separator: char) -> &str {
132 if path.is_empty() {
133 return path;
134 }
135 let trimmed = path.trim_end_matches(separator);
136 if trimmed.is_empty() && path.starts_with(separator) {
137 root_slice(path, separator)
138 } else {
139 trimmed
140 }
141}
142
143fn trim_leading_separators(path: &str, separator: char) -> &str {
144 path.trim_start_matches(separator)
145}
146
147fn starts_with_separator(path: &str, separator: char) -> bool {
148 path.starts_with(separator)
149}
150
151fn is_root_path(path: &str, separator: char) -> bool {
152 let mut chars = path.chars();
153 matches!(chars.next(), Some(ch) if ch == separator) && chars.next().is_none()
154}
155
156fn root_slice(path: &str, separator: char) -> &str {
157 let len = separator.len_utf8();
158 if path.starts_with(separator) && path.len() >= len {
159 &path[..len]
160 } else {
161 ""
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{
168 join_path, parent_path, path_matches_or_contains, remap_path_for_rename,
169 PathCaseSensitivity, PathPolicy,
170 };
171
172 fn posix() -> PathPolicy {
173 PathPolicy::posix()
174 }
175
176 fn insensitive() -> PathPolicy {
177 PathPolicy::new('/', PathCaseSensitivity::Insensitive)
178 }
179
180 #[test]
181 fn exact_match_is_true() {
182 assert!(path_matches_or_contains(
183 "/workspace/src",
184 "/workspace/src",
185 &posix(),
186 ));
187 }
188
189 #[test]
190 fn descendant_match_is_true() {
191 assert!(path_matches_or_contains(
192 "/workspace/src/lib.rs",
193 "/workspace/src",
194 &posix(),
195 ));
196 }
197
198 #[test]
199 fn prefix_collision_is_not_treated_as_descendant() {
200 assert!(!path_matches_or_contains(
201 "/workspace/barista",
202 "/workspace/bar",
203 &posix(),
204 ));
205 }
206
207 #[test]
208 fn trailing_separator_difference_is_ignored_for_comparison() {
209 assert!(path_matches_or_contains(
210 "/workspace/src/lib.rs",
211 "/workspace/src/",
212 &posix(),
213 ));
214 }
215
216 #[test]
217 fn case_sensitivity_is_policy_driven() {
218 assert!(!path_matches_or_contains(
219 "/Workspace/Src",
220 "/workspace/src",
221 &posix()
222 ));
223 assert!(path_matches_or_contains(
224 "/Workspace/Src",
225 "/workspace/src",
226 &insensitive(),
227 ));
228 }
229
230 #[test]
231 fn parent_path_returns_none_for_root_and_single_segment() {
232 assert_eq!(parent_path("/", &posix()), None);
233 assert_eq!(parent_path("workspace", &posix()), None);
234 }
235
236 #[test]
237 fn parent_path_returns_parent_slice_without_allocating() {
238 assert_eq!(
239 parent_path("/workspace/src/lib.rs", &posix()),
240 Some("/workspace/src")
241 );
242 assert_eq!(parent_path("/workspace/src/", &posix()), Some("/workspace"));
243 }
244
245 #[test]
246 fn join_path_inserts_one_separator() {
247 assert_eq!(
248 join_path("/workspace/src/", "lib.rs", &posix()),
249 "/workspace/src/lib.rs"
250 );
251 assert_eq!(
252 join_path("/workspace/src", "/lib.rs", &posix()),
253 "/workspace/src/lib.rs"
254 );
255 }
256
257 #[test]
258 fn join_path_handles_root_and_empty_segments() {
259 assert_eq!(join_path("/", "lib.rs", &posix()), "/lib.rs");
260 assert_eq!(join_path("/workspace/src/", "", &posix()), "/workspace/src");
261 assert_eq!(join_path("", "/lib.rs", &posix()), "lib.rs");
262 }
263
264 #[test]
265 fn rename_remap_matches_file_exactly() {
266 assert_eq!(
267 remap_path_for_rename(
268 "/workspace/old.txt",
269 "/workspace/old.txt",
270 "/workspace/new.txt",
271 &posix()
272 ),
273 Some("/workspace/new.txt".to_string())
274 );
275 }
276
277 #[test]
278 fn rename_remap_updates_subtree_descendants() {
279 assert_eq!(
280 remap_path_for_rename(
281 "/workspace/dir/nested/file.txt",
282 "/workspace/dir",
283 "/workspace/renamed",
284 &posix(),
285 ),
286 Some("/workspace/renamed/nested/file.txt".to_string())
287 );
288 }
289
290 #[test]
291 fn rename_remap_rejects_prefix_collision() {
292 assert_eq!(
293 remap_path_for_rename(
294 "/workspace/barista/file.txt",
295 "/workspace/bar",
296 "/workspace/renamed",
297 &posix(),
298 ),
299 None
300 );
301 }
302
303 #[test]
304 fn rename_remap_ignores_trailing_separator_mismatch() {
305 assert_eq!(
306 remap_path_for_rename(
307 "/workspace/dir/file.txt",
308 "/workspace/dir/",
309 "/workspace/renamed/",
310 &posix(),
311 ),
312 Some("/workspace/renamed/file.txt".to_string())
313 );
314 }
315}