sapphire_framework_sync/
paths.rs1use std::path::{Component, Path, PathBuf};
4
5pub const CASE_INSENSITIVE_FS: bool = cfg!(any(windows, target_os = "macos"));
7
8pub fn to_native(root: &Path, rel: &str) -> PathBuf {
10 let mut out = root.to_path_buf();
11 for segment in rel.split('/') {
12 out.push(segment);
13 }
14 out
15}
16
17pub fn rel_from_native(root: &Path, abs: &Path) -> Option<String> {
19 let rel = abs.strip_prefix(root).ok()?;
20 let mut parts = Vec::new();
21 for component in rel.components() {
22 match component {
23 Component::Normal(s) => parts.push(s.to_str()?.to_owned()),
24 _ => return None,
25 }
26 }
27 if parts.is_empty() {
28 None
29 } else {
30 Some(parts.join("/"))
31 }
32}
33
34pub fn is_valid_rel(rel: &str) -> bool {
38 if rel.is_empty() || rel.starts_with('/') || rel.contains('\\') {
39 return false;
40 }
41 rel.split('/').all(|seg| {
42 if seg.is_empty() || seg == "." || seg == ".." {
43 return false;
44 }
45 let bytes = seg.as_bytes();
46 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
47 return false;
48 }
49 true
50 })
51}
52
53pub fn representable(rel: &str) -> bool {
55 representable_on(rel, cfg!(windows))
56}
57
58pub fn representable_on(rel: &str, windows: bool) -> bool {
60 if rel.contains('\0') {
61 return false;
62 }
63 if !windows {
64 return true;
65 }
66 rel.split('/').all(|seg| {
67 !seg.chars()
68 .any(|c| matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*') || (c as u32) < 32)
69 && !seg.ends_with('.')
70 && !seg.ends_with(' ')
71 && !is_reserved_windows_name(seg)
72 })
73}
74
75fn is_reserved_windows_name(segment: &str) -> bool {
76 let stem = segment
77 .split('.')
78 .next()
79 .unwrap_or(segment)
80 .to_ascii_uppercase();
81 match stem.as_str() {
82 "CON" | "PRN" | "AUX" | "NUL" => true,
83 s if s.len() == 4 && (s.starts_with("COM") || s.starts_with("LPT")) => {
84 matches!(s.as_bytes()[3], b'1'..=b'9')
85 }
86 _ => false,
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn native_round_trip() {
96 let root = Path::new("root");
97 let abs = to_native(root, "a/b/c.txt");
98 assert_eq!(abs, Path::new("root").join("a").join("b").join("c.txt"));
99 assert_eq!(rel_from_native(root, &abs).as_deref(), Some("a/b/c.txt"));
100 assert_eq!(rel_from_native(root, root), None);
101 assert_eq!(rel_from_native(root, Path::new("elsewhere/x")), None);
102 }
103
104 #[test]
105 fn validity() {
106 for ok in ["a", "a/b.md", "2026-08-25T10:00.md", ".app/x"] {
107 assert!(is_valid_rel(ok), "{ok}");
108 }
109 for bad in [
110 "", "/a", "a\\b", "C:/x", "c:x", "a//b", "./a", "a/../b", "a/.", "a/C:/b", "a/c:x",
111 "dir/Z:",
112 ] {
113 assert!(!is_valid_rel(bad), "{bad}");
114 }
115 }
116
117 #[test]
118 fn windows_representability() {
119 assert!(representable_on("a/b.txt", true));
120 for bad in [
121 "a:b.txt",
122 "x/what?.md",
123 "trailing.",
124 "space ",
125 "CON",
126 "con.txt",
127 "dir/LPT1.log",
128 "a\u{1}b",
129 ] {
130 assert!(!representable_on(bad, true), "{bad}");
131 assert!(
132 representable_on(bad, false) || bad.contains('\u{0}'),
133 "{bad} is fine elsewhere"
134 );
135 }
136 assert!(representable_on("COM0", true), "COM0 is not reserved");
137 assert!(representable_on("CONSOLE.txt", true));
138 assert!(!representable_on("nul\u{0}", false));
139 }
140}