Skip to main content

neco_pathrel/
lib.rs

1/// Case handling used when comparing path segments.
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum PathCaseSensitivity {
4    Sensitive,
5    Insensitive,
6}
7
8/// Path comparison policy shared by relation helpers.
9#[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
30/// Return whether `path` is equal to `target` or lies under `target`.
31pub 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
43/// Return the direct parent slice when one exists under the given policy.
44pub 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
59/// Join `base` and `name` using the policy separator.
60pub 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
75/// Remap `path` from the renamed `source` subtree into `target`.
76pub 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}