Skip to main content

openjd_expr/functions/
path_parse.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// Copyright by contributors to this project.
3// SPDX-License-Identifier: (Apache-2.0 OR MIT)
4
5//! Format-aware path parsing — replaces `std::path::Path` for cross-platform correctness.
6//!
7//! `std::path::Path` uses the host OS's path rules, which gives wrong results when
8//! parsing POSIX paths on Windows or vice versa. These functions use the path's
9//! `PathFormat` to determine separator handling.
10
11use crate::path_mapping::PathFormat;
12
13/// Check if a character is a path separator for the given format.
14fn is_sep(c: char, fmt: PathFormat) -> bool {
15    match fmt {
16        PathFormat::Windows => c == '\\' || c == '/',
17        PathFormat::Posix | PathFormat::Uri => c == '/',
18    }
19}
20
21/// Get the canonical separator for the given format.
22pub fn sep(fmt: PathFormat) -> char {
23    match fmt {
24        PathFormat::Windows => '\\',
25        _ => '/',
26    }
27}
28
29/// Strip trailing separators, but not if they are part of the root/anchor.
30/// Returns the normalized path and the anchor length (bytes that must not be stripped).
31fn normalize(path: &str, fmt: PathFormat) -> &str {
32    if path.is_empty() || path == "." {
33        return path;
34    }
35    let anchor_len = anchor_len(path, fmt);
36    let mut end = path.len();
37    while end > anchor_len && is_sep(path.as_bytes()[end - 1] as char, fmt) {
38        end -= 1;
39    }
40    &path[..end]
41}
42
43/// Return the byte length of the anchor (root/drive/UNC prefix) in a path.
44fn anchor_len(path: &str, fmt: PathFormat) -> usize {
45    let bytes = path.as_bytes();
46    match fmt {
47        PathFormat::Windows => {
48            if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
49                // C:\ or C:
50                if bytes.len() > 2 && is_sep(bytes[2] as char, fmt) {
51                    3
52                } else {
53                    2
54                }
55            } else if bytes.len() >= 2
56                && is_sep(bytes[0] as char, fmt)
57                && is_sep(bytes[1] as char, fmt)
58            {
59                // UNC: \\server\share\ is the full anchor
60                let rest = &path[2..];
61                let server_end = rest.find(|c: char| is_sep(c, fmt)).unwrap_or(rest.len());
62                let after_server = 2 + server_end;
63                if after_server < path.len() {
64                    // Have share part
65                    let share_rest = &path[after_server + 1..];
66                    let share_end = share_rest
67                        .find(|c: char| is_sep(c, fmt))
68                        .unwrap_or(share_rest.len());
69                    let end = after_server + 1 + share_end;
70                    // anchor = \\server\share\ — always include trailing sep conceptually
71                    // If there's a sep after share, include it; otherwise anchor extends to end
72                    // (the trailing sep will be added by parts/parent as needed)
73                    if end < path.len() {
74                        end + 1
75                    } else {
76                        end
77                    }
78                } else {
79                    // Just \\server, no share
80                    after_server
81                }
82            } else if !bytes.is_empty() && is_sep(bytes[0] as char, fmt) {
83                1
84            } else {
85                0
86            }
87        }
88        PathFormat::Posix | PathFormat::Uri => {
89            if bytes.len() >= 2
90                && bytes[0] == b'/'
91                && bytes[1] == b'/'
92                && (bytes.len() < 3 || bytes[2] != b'/')
93            {
94                2 // POSIX // is special
95            } else if !bytes.is_empty() && bytes[0] == b'/' {
96                1
97            } else {
98                0
99            }
100        }
101    }
102}
103
104/// Split a path into (parent, file_name), matching pathlib behavior.
105pub fn split(path: &str, fmt: PathFormat) -> (&str, &str) {
106    let path = normalize(path, fmt);
107    if path == "." {
108        return (".", "");
109    }
110    let anchor = anchor_len(path, fmt);
111    // For Windows UNC, the anchor includes the trailing sep; clamp to path length
112    let anchor = anchor.min(path.len());
113
114    // If path is entirely the anchor, name is empty
115    if path.len() <= anchor {
116        return (path, "");
117    }
118
119    let last_sep = path[anchor..].rfind(|c: char| is_sep(c, fmt));
120    match last_sep {
121        Some(i) => {
122            let sep_pos = anchor + i;
123            let parent = &path[..sep_pos];
124            let name = &path[sep_pos + 1..];
125            // If parent would be empty or shorter than anchor, use anchor
126            if parent.len() < anchor {
127                (&path[..anchor], name)
128            } else {
129                (parent, name)
130            }
131        }
132        None => {
133            // No separator after anchor — name is everything after anchor
134            (&path[..anchor], &path[anchor..])
135        }
136    }
137}
138
139/// Get the file name (last component after the last separator).
140pub fn file_name(path: &str, fmt: PathFormat) -> &str {
141    split(path, fmt).1
142}
143
144/// Get the parent (everything before the last separator).
145/// Returns the path itself if there's no parent (like "/" returns "/").
146pub fn parent(path: &str, fmt: PathFormat) -> String {
147    let (p, _name) = split(path, fmt);
148    let base = p;
149    // For Windows UNC with share, ensure trailing backslash
150    if fmt == PathFormat::Windows {
151        let s = base.replace('/', "\\");
152        if s.starts_with("\\\\") && s.matches('\\').count() >= 3 && !s.ends_with('\\') {
153            return format!("{}\\", s);
154        }
155        return s;
156    }
157    base.to_string()
158}
159
160/// Get the file stem (file_name without the last extension).
161pub fn file_stem(path: &str, fmt: PathFormat) -> &str {
162    let name = file_name(path, fmt);
163    match name.rfind('.') {
164        Some(0) | None => name, // ".hidden" or no dot → whole name is stem
165        Some(i) => &name[..i],
166    }
167}
168
169/// Get the extension (last ".ext" including the dot).
170pub fn extension(path: &str, fmt: PathFormat) -> &str {
171    let name = file_name(path, fmt);
172    match name.rfind('.') {
173        Some(0) | None => "", // ".hidden" or no dot → no extension
174        Some(i) => &name[i..],
175    }
176}
177
178/// Get the extension without the dot (for compatibility with std::path).
179pub fn extension_no_dot(path: &str, fmt: PathFormat) -> &str {
180    let ext = extension(path, fmt);
181    ext.strip_prefix('.').unwrap_or("")
182}
183
184/// Split a path into its components (like Python's PurePath.parts).
185///
186/// For POSIX: "/mnt/renders/scene.exr" → ["/", "mnt", "renders", "scene.exr"]
187/// For Windows: "C:\mnt\file" → ["C:\", "mnt", "file"]
188/// For Windows: "\mnt\file" → ["\", "mnt", "file"]
189pub fn parts(path: &str, fmt: PathFormat) -> Vec<String> {
190    let path = normalize(path, fmt);
191    if path.is_empty() || path == "." {
192        return Vec::new();
193    }
194
195    let mut result = Vec::new();
196    let anchor = anchor_len(path, fmt).min(path.len());
197
198    if anchor > 0 {
199        let mut anchor_str = path[..anchor].to_string();
200        // Normalize separators in anchor for Windows
201        if fmt == PathFormat::Windows {
202            anchor_str = anchor_str.replace('/', "\\");
203            // UNC root (\\server\share) must have trailing backslash
204            if anchor_str.starts_with("\\\\")
205                && anchor_str.matches('\\').count() >= 3
206                && !anchor_str.ends_with('\\')
207            {
208                anchor_str.push('\\');
209            }
210        }
211        result.push(anchor_str);
212    }
213
214    let remaining = &path[anchor..];
215    for part in remaining.split(|c: char| is_sep(c, fmt)) {
216        if !part.is_empty() {
217            result.push(part.to_string());
218        }
219    }
220
221    result
222}
223
224/// Get all suffixes (like Python's PurePath.suffixes).
225/// "file.tar.gz" → [".tar", ".gz"]
226pub fn suffixes(path: &str, fmt: PathFormat) -> Vec<String> {
227    let name = file_name(path, fmt);
228    let mut result = Vec::new();
229    if let Some(first_dot) = name.find('.') {
230        if first_dot == 0 {
231            // Dotfile like ".hidden" — check for more dots
232            if let Some(second_dot) = name[1..].find('.') {
233                let mut remaining = &name[1 + second_dot..];
234                while let Some(dot_pos) = remaining[1..].find('.') {
235                    result.push(remaining[..dot_pos + 1].to_string());
236                    remaining = &remaining[dot_pos + 1..];
237                }
238                result.push(remaining.to_string());
239            }
240            // else: just ".hidden" with no further dots → no suffixes
241        } else {
242            let mut remaining = &name[first_dot..];
243            while let Some(dot_pos) = remaining[1..].find('.') {
244                result.push(remaining[..dot_pos + 1].to_string());
245                remaining = &remaining[dot_pos + 1..];
246            }
247            result.push(remaining.to_string());
248        }
249    }
250    result
251}
252
253/// Join path parts using Python pathlib constructor semantics.
254///
255/// Matches `PurePosixPath(*parts)` or `PureWindowsPath(*parts)` behavior:
256/// - Absolute components reset the accumulator
257/// - Empty strings and `.` segments are removed
258/// - Duplicate separators are collapsed
259/// - `..` is preserved (not resolved)
260pub fn join_pathlib(parts: &[String], fmt: PathFormat) -> String {
261    match fmt {
262        PathFormat::Posix | PathFormat::Uri => join_pathlib_posix(parts),
263        PathFormat::Windows => join_pathlib_windows(parts),
264    }
265}
266
267fn join_pathlib_posix(parts: &[String]) -> String {
268    let mut segments: Vec<&str> = Vec::new();
269    let mut is_absolute = false;
270
271    for part in parts {
272        if part.is_empty() {
273            continue;
274        }
275        let sub_components: Vec<&str> = part.split('/').collect();
276        for (i, c) in sub_components.iter().enumerate() {
277            if c.is_empty() && i == 0 {
278                // Leading empty = this part starts with '/'
279                is_absolute = true;
280                segments.clear();
281            } else if *c == "." {
282                // Skip '.' segments
283            } else if !c.is_empty() {
284                segments.push(c);
285            }
286        }
287    }
288
289    if is_absolute {
290        if segments.is_empty() {
291            "/".to_string()
292        } else {
293            format!("/{}", segments.join("/"))
294        }
295    } else if segments.is_empty() {
296        ".".to_string()
297    } else {
298        segments.join("/")
299    }
300}
301
302/// Parse Windows drive from a path string. Returns (drive, rest).
303/// Drive can be "C:" or "\\\\server\\share" (UNC).
304fn win_parse_drive(s: &str) -> (&str, &str) {
305    let bytes = s.as_bytes();
306    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
307        (&s[..2], &s[2..])
308    } else if bytes.len() >= 2
309        && is_sep(bytes[0] as char, PathFormat::Windows)
310        && is_sep(bytes[1] as char, PathFormat::Windows)
311    {
312        // UNC: \\server\share
313        let rest = &s[2..];
314        let server_end = rest
315            .find(|c: char| is_sep(c, PathFormat::Windows))
316            .unwrap_or(rest.len());
317        let after_server = 2 + server_end;
318        if after_server < s.len() {
319            let share_rest = &s[after_server + 1..];
320            let share_end = share_rest
321                .find(|c: char| is_sep(c, PathFormat::Windows))
322                .unwrap_or(share_rest.len());
323            let end = after_server + 1 + share_end;
324            (&s[..end], &s[end..])
325        } else {
326            (s, "")
327        }
328    } else {
329        ("", s)
330    }
331}
332
333fn join_pathlib_windows(parts: &[String]) -> String {
334    // Track accumulated drive, root, and relative parts separately.
335    // For each new part, parse its drive and root, then apply pathlib rules.
336    let mut drive = String::new();
337    let mut root = String::new();
338    let mut segments: Vec<String> = Vec::new();
339
340    for part in parts {
341        let (new_drive, after_drive) = win_parse_drive(part);
342        let has_root = !after_drive.is_empty()
343            && is_sep(after_drive.as_bytes()[0] as char, PathFormat::Windows);
344        let new_root = if has_root { "\\" } else { "" };
345        let rel = if has_root {
346            &after_drive[1..]
347        } else {
348            after_drive
349        };
350
351        if !new_drive.is_empty() {
352            if !new_drive.is_empty() && !drive.is_empty() && !new_drive.eq_ignore_ascii_case(&drive)
353            {
354                // Different drive → replace everything
355                drive = new_drive.to_string();
356                root = new_root.to_string();
357                segments.clear();
358            } else {
359                // Same drive (or first drive)
360                drive = new_drive.to_string();
361                if !new_root.is_empty() {
362                    root = new_root.to_string();
363                    segments.clear();
364                }
365            }
366        } else if !new_root.is_empty() {
367            // Root without drive → keep existing drive, replace from root
368            root = new_root.to_string();
369            segments.clear();
370        }
371        // Append relative components, filtering empty and '.'
372        for c in rel.split(|c: char| is_sep(c, PathFormat::Windows)) {
373            if c == "." || c.is_empty() {
374                continue;
375            }
376            segments.push(c.to_string());
377        }
378    }
379
380    // Reconstruct
381    let mut result = format!("{}{}", drive, root);
382    if !segments.is_empty() {
383        if !result.is_empty() && !result.ends_with('\\') && !result.ends_with(':') {
384            result.push('\\');
385        }
386        result.push_str(&segments.join("\\"));
387    }
388
389    if result.is_empty() {
390        ".".to_string()
391    } else {
392        result
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    // ── Existing tests ──
401
402    #[test]
403    fn posix_parts_absolute() {
404        assert_eq!(
405            parts("/mnt/renders/scene.exr", PathFormat::Posix),
406            vec!["/", "mnt", "renders", "scene.exr"]
407        );
408    }
409
410    #[test]
411    fn posix_parts_relative() {
412        assert_eq!(
413            parts("sub/file.exr", PathFormat::Posix),
414            vec!["sub", "file.exr"]
415        );
416    }
417
418    #[test]
419    fn posix_parts_root() {
420        assert_eq!(parts("/", PathFormat::Posix), vec!["/"]);
421    }
422
423    #[test]
424    fn windows_parts_drive() {
425        assert_eq!(
426            parts(r"C:\mnt\file.txt", PathFormat::Windows),
427            vec![r"C:\", "mnt", "file.txt"]
428        );
429    }
430
431    #[test]
432    fn windows_parts_root_backslash() {
433        assert_eq!(
434            parts(r"\mnt\data\file.txt", PathFormat::Windows),
435            vec![r"\", "mnt", "data", "file.txt"]
436        );
437    }
438
439    #[test]
440    fn windows_parts_unc() {
441        assert_eq!(
442            parts(r"\\server\share\dir", PathFormat::Windows),
443            vec![r"\\server\share\", "dir"]
444        );
445    }
446
447    #[test]
448    fn posix_file_name() {
449        assert_eq!(
450            file_name("/mnt/renders/scene.exr", PathFormat::Posix),
451            "scene.exr"
452        );
453    }
454
455    #[test]
456    fn posix_parent() {
457        assert_eq!(
458            parent("/mnt/renders/scene.exr", PathFormat::Posix),
459            "/mnt/renders"
460        );
461    }
462
463    #[test]
464    fn posix_parent_root() {
465        assert_eq!(parent("/", PathFormat::Posix), "/");
466    }
467
468    #[test]
469    fn posix_file_stem() {
470        assert_eq!(
471            file_stem("/mnt/renders/scene.exr", PathFormat::Posix),
472            "scene"
473        );
474    }
475
476    #[test]
477    fn posix_extension() {
478        assert_eq!(
479            extension("/mnt/renders/scene.exr", PathFormat::Posix),
480            ".exr"
481        );
482    }
483
484    #[test]
485    fn no_extension() {
486        assert_eq!(extension("/mnt/renders/Makefile", PathFormat::Posix), "");
487    }
488
489    #[test]
490    fn posix_suffixes_single() {
491        assert_eq!(suffixes("scene.exr", PathFormat::Posix), vec![".exr"]);
492    }
493
494    #[test]
495    fn posix_suffixes_compound() {
496        assert_eq!(
497            suffixes("archive.tar.gz", PathFormat::Posix),
498            vec![".tar", ".gz"]
499        );
500    }
501
502    #[test]
503    fn posix_suffixes_none() {
504        assert_eq!(
505            suffixes("Makefile", PathFormat::Posix),
506            Vec::<String>::new()
507        );
508    }
509
510    #[test]
511    fn windows_parent_backslash() {
512        assert_eq!(
513            parent(r"\mnt\renders\scene.exr", PathFormat::Windows),
514            r"\mnt\renders"
515        );
516    }
517
518    #[test]
519    fn windows_file_name_mixed_sep() {
520        assert_eq!(
521            file_name(r"C:\mnt/renders\scene.exr", PathFormat::Windows),
522            "scene.exr"
523        );
524    }
525
526    // ── POSIX parts: pathlib ground truth ──
527
528    #[test]
529    fn posix_parts_single_component() {
530        assert_eq!(parts("/mnt", PathFormat::Posix), vec!["/", "mnt"]);
531    }
532
533    #[test]
534    fn posix_parts_dot() {
535        // PurePosixPath('.').parts == ()
536        let empty: Vec<String> = vec![];
537        assert_eq!(parts(".", PathFormat::Posix), empty);
538    }
539
540    #[test]
541    fn posix_parts_dotdot() {
542        assert_eq!(parts("..", PathFormat::Posix), vec![".."]);
543    }
544
545    #[test]
546    fn posix_parts_dotdot_foo() {
547        assert_eq!(parts("../foo", PathFormat::Posix), vec!["..", "foo"]);
548    }
549
550    #[test]
551    fn posix_parts_repeated_separators() {
552        // Collapses repeated /
553        assert_eq!(
554            parts("/mnt//renders///scene.exr", PathFormat::Posix),
555            vec!["/", "mnt", "renders", "scene.exr"]
556        );
557    }
558
559    #[test]
560    fn posix_parts_double_slash_root() {
561        // pathlib treats // as a special root
562        assert_eq!(
563            parts("//mnt/file", PathFormat::Posix),
564            vec!["//", "mnt", "file"]
565        );
566    }
567
568    #[test]
569    fn posix_parts_trailing_slash() {
570        // Trailing slash stripped
571        assert_eq!(
572            parts("/mnt/renders/", PathFormat::Posix),
573            vec!["/", "mnt", "renders"]
574        );
575    }
576
577    #[test]
578    fn posix_parts_deep() {
579        assert_eq!(
580            parts("/a/b/c/d/e", PathFormat::Posix),
581            vec!["/", "a", "b", "c", "d", "e"]
582        );
583    }
584
585    #[test]
586    fn posix_parts_bare_file() {
587        assert_eq!(parts("file.txt", PathFormat::Posix), vec!["file.txt"]);
588    }
589
590    #[test]
591    fn posix_parts_empty() {
592        let empty: Vec<String> = vec![];
593        assert_eq!(parts("", PathFormat::Posix), empty);
594    }
595
596    // ── POSIX properties: pathlib ground truth ──
597
598    #[test]
599    fn posix_dot_name() {
600        assert_eq!(file_name(".", PathFormat::Posix), "");
601    }
602
603    #[test]
604    fn posix_dot_stem() {
605        assert_eq!(file_stem(".", PathFormat::Posix), "");
606    }
607
608    #[test]
609    fn posix_dot_suffix() {
610        assert_eq!(extension(".", PathFormat::Posix), "");
611    }
612
613    #[test]
614    fn posix_dot_parent() {
615        assert_eq!(parent(".", PathFormat::Posix), ".");
616    }
617
618    #[test]
619    fn posix_trailing_slash_name() {
620        // PurePosixPath('/mnt/renders/').name == 'renders'
621        assert_eq!(file_name("/mnt/renders/", PathFormat::Posix), "renders");
622    }
623
624    #[test]
625    fn posix_trailing_slash_stem() {
626        assert_eq!(file_stem("/mnt/renders/", PathFormat::Posix), "renders");
627    }
628
629    #[test]
630    fn posix_trailing_slash_suffix() {
631        assert_eq!(extension("/mnt/renders/", PathFormat::Posix), "");
632    }
633
634    #[test]
635    fn posix_trailing_slash_parent() {
636        // PurePosixPath('/mnt/renders/').parent == PurePosixPath('/mnt')
637        assert_eq!(parent("/mnt/renders/", PathFormat::Posix), "/mnt");
638    }
639
640    #[test]
641    fn posix_hidden_tar_gz_suffixes() {
642        assert_eq!(
643            suffixes(".hidden.tar.gz", PathFormat::Posix),
644            vec![".tar", ".gz"]
645        );
646    }
647
648    #[test]
649    fn posix_hidden_tar_gz_stem() {
650        assert_eq!(
651            file_stem(".hidden.tar.gz", PathFormat::Posix),
652            ".hidden.tar"
653        );
654    }
655
656    #[test]
657    fn posix_hidden_tar_gz_suffix() {
658        assert_eq!(extension(".hidden.tar.gz", PathFormat::Posix), ".gz");
659    }
660
661    // ── Windows parts: pathlib ground truth ──
662
663    #[test]
664    fn windows_parts_drive_root() {
665        // PureWindowsPath('C:\\').parts == ('C:\\',)
666        assert_eq!(parts(r"C:\", PathFormat::Windows), vec![r"C:\"]);
667    }
668
669    #[test]
670    fn windows_parts_drive_file() {
671        assert_eq!(
672            parts(r"C:\mnt\file.txt", PathFormat::Windows),
673            vec![r"C:\", "mnt", "file.txt"]
674        );
675    }
676
677    #[test]
678    fn windows_parts_forward_slash() {
679        // Forward slashes accepted, normalized to backslash in root
680        assert_eq!(
681            parts("C:/path/to/file", PathFormat::Windows),
682            vec![r"C:\", "path", "to", "file"]
683        );
684    }
685
686    #[test]
687    fn windows_parts_repeated_separators() {
688        assert_eq!(
689            parts("C:/path//to///file", PathFormat::Windows),
690            vec![r"C:\", "path", "to", "file"]
691        );
692    }
693
694    #[test]
695    fn windows_parts_unc_root() {
696        // PureWindowsPath('\\\\server\\share').parts == ('\\\\server\\share\\',)
697        assert_eq!(
698            parts(r"\\server\share", PathFormat::Windows),
699            vec![r"\\server\share\"]
700        );
701    }
702
703    #[test]
704    fn windows_parts_unc_dir() {
705        assert_eq!(
706            parts(r"\\server\share\dir", PathFormat::Windows),
707            vec![r"\\server\share\", "dir"]
708        );
709    }
710
711    #[test]
712    fn windows_parts_unc_dir_file() {
713        assert_eq!(
714            parts(r"\\server\share\dir\file.txt", PathFormat::Windows),
715            vec![r"\\server\share\", "dir", "file.txt"]
716        );
717    }
718
719    #[test]
720    fn windows_parts_root_only() {
721        // PureWindowsPath('\\mnt\\data\\file.txt').parts == ('\\', 'mnt', 'data', 'file.txt')
722        assert_eq!(
723            parts(r"\mnt\data\file.txt", PathFormat::Windows),
724            vec![r"\", "mnt", "data", "file.txt"]
725        );
726    }
727
728    #[test]
729    fn windows_parts_unc_no_share() {
730        // PureWindowsPath('\\\\server').parts == ('\\\\server',)
731        // Note: no trailing backslash when there's no share
732        assert_eq!(parts(r"\\server", PathFormat::Windows), vec![r"\\server"]);
733    }
734
735    #[test]
736    fn windows_parts_relative_drive() {
737        // PureWindowsPath('C:').parts == ('C:',)
738        assert_eq!(parts("C:", PathFormat::Windows), vec!["C:"]);
739    }
740
741    #[test]
742    fn windows_parts_trailing_slash() {
743        // PureWindowsPath('C:\\mnt\\').parts == ('C:\\', 'mnt')
744        assert_eq!(parts(r"C:\mnt\", PathFormat::Windows), vec![r"C:\", "mnt"]);
745    }
746
747    #[test]
748    fn windows_parts_unc_trailing_slash() {
749        // PureWindowsPath('\\\\server\\share\\').parts == ('\\\\server\\share\\',)
750        assert_eq!(
751            parts(r"\\server\share\", PathFormat::Windows),
752            vec![r"\\server\share\"]
753        );
754    }
755
756    // ── Windows properties: pathlib ground truth ──
757
758    #[test]
759    fn windows_drive_root_name() {
760        // PureWindowsPath('C:\\').name == ''
761        assert_eq!(file_name(r"C:\", PathFormat::Windows), "");
762    }
763
764    #[test]
765    fn windows_drive_root_parent() {
766        // PureWindowsPath('C:\\').parent == PureWindowsPath('C:\\')
767        assert_eq!(parent(r"C:\", PathFormat::Windows), r"C:\");
768    }
769
770    #[test]
771    fn windows_unc_name() {
772        // PureWindowsPath('\\\\server\\share').name == ''
773        assert_eq!(file_name(r"\\server\share", PathFormat::Windows), "");
774    }
775
776    #[test]
777    fn windows_unc_parent() {
778        // PureWindowsPath('\\\\server\\share').parent == PureWindowsPath('\\\\server\\share\\')
779        // The parent of UNC root is itself (with trailing backslash)
780        assert_eq!(
781            parent(r"\\server\share", PathFormat::Windows),
782            r"\\server\share\"
783        );
784    }
785
786    #[test]
787    fn windows_unc_dir_name() {
788        // PureWindowsPath('\\\\server\\share\\dir').name == 'dir'
789        assert_eq!(file_name(r"\\server\share\dir", PathFormat::Windows), "dir");
790    }
791
792    #[test]
793    fn windows_unc_dir_parent() {
794        // PureWindowsPath('\\\\server\\share\\dir').parent == PureWindowsPath('\\\\server\\share\\')
795        assert_eq!(
796            parent(r"\\server\share\dir", PathFormat::Windows),
797            r"\\server\share\"
798        );
799    }
800
801    // ── Forward-slash paths with Windows format ──
802    // Forward slashes are valid separators on Windows. These paths must
803    // parse identically to their backslash equivalents.
804
805    #[test]
806    fn windows_forward_slash_file_name() {
807        assert_eq!(
808            file_name("/input/scene.exr", PathFormat::Windows),
809            "scene.exr"
810        );
811    }
812
813    #[test]
814    fn windows_forward_slash_file_stem() {
815        assert_eq!(file_stem("/input/scene.exr", PathFormat::Windows), "scene");
816    }
817
818    #[test]
819    fn windows_forward_slash_extension() {
820        assert_eq!(extension("/input/scene.exr", PathFormat::Windows), ".exr");
821    }
822
823    #[test]
824    fn windows_forward_slash_parent() {
825        assert_eq!(parent("/input/scene.exr", PathFormat::Windows), r"\input");
826    }
827
828    #[test]
829    fn windows_forward_slash_parts() {
830        assert_eq!(
831            parts("/input/scene.exr", PathFormat::Windows),
832            vec![r"\", "input", "scene.exr"]
833        );
834    }
835}