1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "UPPERCASE")]
14pub enum PathFormat {
15 #[serde(alias = "posix", alias = "Posix")]
16 Posix,
17 #[serde(alias = "windows", alias = "Windows")]
18 Windows,
19 #[serde(alias = "URI", alias = "uri", alias = "Uri")]
20 Uri,
21}
22
23impl PathFormat {
24 pub fn host() -> Self {
25 if cfg!(windows) {
26 PathFormat::Windows
27 } else {
28 PathFormat::Posix
29 }
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct PathMappingRule {
40 pub source_path_format: PathFormat,
41 pub source_path: String,
42 pub destination_path: String,
43}
44
45impl PathMappingRule {
46 pub fn apply(&self, path: &str) -> Option<String> {
49 self.apply_with_format(path, PathFormat::host())
50 }
51
52 pub fn apply_with_format(&self, path: &str, output_format: PathFormat) -> Option<String> {
56 let sep = match output_format {
57 PathFormat::Windows => '\\',
58 _ => '/',
59 };
60 match self.source_path_format {
61 PathFormat::Uri => self.apply_uri(path, sep),
62 PathFormat::Posix => self.apply_filesystem(path, false, sep),
63 PathFormat::Windows => self.apply_filesystem(path, true, sep),
64 }
65 }
66
67 fn uri_path_start(uri: &str) -> Option<usize> {
70 let authority_start = uri.find("://")? + 3;
71 Some(
72 uri[authority_start..]
73 .find('/')
74 .map_or(uri.len(), |i| authority_start + i),
75 )
76 }
77
78 fn apply_uri(&self, path: &str, sep: char) -> Option<String> {
79 let src_path_start = Self::uri_path_start(&self.source_path).unwrap_or(0);
81 let inp_path_start = Self::uri_path_start(path).unwrap_or(0);
82
83 if !path[..inp_path_start].eq_ignore_ascii_case(&self.source_path[..src_path_start]) {
85 return None;
86 }
87 let src_path = &self.source_path[src_path_start..];
89 let inp_path = &path[inp_path_start..];
90 if !inp_path.starts_with(src_path) {
91 return None;
92 }
93 let remainder = &inp_path[src_path.len()..];
94 if !remainder.is_empty() && !remainder.starts_with('/') {
95 return None;
96 }
97 let child_parts: Vec<&str> = if remainder.is_empty() {
98 Vec::new()
99 } else {
100 remainder[1..].split('/').collect()
101 };
102 let mut result = self.destination_path.clone();
103 for part in &child_parts {
104 result.push(sep);
105 result.push_str(part);
106 }
107 if path.ends_with('/') && !result.ends_with(sep) {
108 result.push(sep);
109 }
110 Some(result)
111 }
112
113 fn apply_filesystem(&self, path: &str, case_insensitive: bool, sep: char) -> Option<String> {
114 let source_parts = split_path_parts(&self.source_path);
115 let path_parts = split_path_parts(path);
116
117 if path_parts.len() < source_parts.len() {
118 return None;
119 }
120
121 for (sp, pp) in source_parts.iter().zip(path_parts.iter()) {
122 let matches = if case_insensitive {
123 sp.eq_ignore_ascii_case(pp)
124 } else {
125 sp == pp
126 };
127 if !matches {
128 return None;
129 }
130 }
131
132 let remaining = &path_parts[source_parts.len()..];
133 let mut result = self.destination_path.clone();
134 for part in remaining {
135 result.push(sep);
136 result.push_str(part);
137 }
138
139 if has_trailing_slash(path, case_insensitive) && !result.ends_with(sep) {
140 result.push(sep);
141 }
142
143 Some(result)
144 }
145}
146
147fn split_path_parts(path: &str) -> Vec<&str> {
150 path.split(&['/', '\\'][..])
151 .filter(|s| !s.is_empty())
152 .collect()
153}
154
155fn has_trailing_slash(path: &str, windows: bool) -> bool {
158 if windows {
159 path.ends_with('\\') || path.ends_with('/')
160 } else {
161 path.ends_with('/')
162 }
163}
164
165#[must_use]
170pub fn apply_rules(rules: &[PathMappingRule], path: &str) -> String {
171 apply_rules_with_format(rules, path, PathFormat::host())
172}
173
174#[must_use]
178pub fn apply_rules_with_format(
179 rules: &[PathMappingRule],
180 path: &str,
181 output_format: PathFormat,
182) -> String {
183 for rule in rules {
184 if let Some(mapped) = rule.apply_with_format(path, output_format) {
185 return mapped;
186 }
187 }
188 path.to_string()
189}
190
191pub fn is_uri(path: &str) -> bool {
193 crate::uri_path::is_uri(path)
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 const SPEC_JSON: &str = r#"{
202 "version": "pathmapping-1.0",
203 "path_mapping_rules": [
204 {
205 "source_path_format": "POSIX",
206 "source_path": "/home/user",
207 "destination_path": "/mnt/shared/user"
208 },
209 {
210 "source_path_format": "WINDOWS",
211 "source_path": "C:\\Users\\user",
212 "destination_path": "/mnt/shared/user"
213 }
214 ]
215 }"#;
216
217 #[derive(Serialize, Deserialize)]
218 struct PathMappingFile {
219 version: String,
220 path_mapping_rules: Vec<PathMappingRule>,
221 }
222
223 #[test]
224 fn deserialize_spec_json() {
225 let file: PathMappingFile = serde_json::from_str(SPEC_JSON).unwrap();
226 assert_eq!(file.version, "pathmapping-1.0");
227 assert_eq!(file.path_mapping_rules.len(), 2);
228
229 assert_eq!(
230 file.path_mapping_rules[0].source_path_format,
231 PathFormat::Posix
232 );
233 assert_eq!(file.path_mapping_rules[0].source_path, "/home/user");
234 assert_eq!(
235 file.path_mapping_rules[0].destination_path,
236 "/mnt/shared/user"
237 );
238
239 assert_eq!(
240 file.path_mapping_rules[1].source_path_format,
241 PathFormat::Windows
242 );
243 assert_eq!(file.path_mapping_rules[1].source_path, "C:\\Users\\user");
244 assert_eq!(
245 file.path_mapping_rules[1].destination_path,
246 "/mnt/shared/user"
247 );
248 }
249
250 #[test]
251 fn serialize_roundtrip() {
252 let file: PathMappingFile = serde_json::from_str(SPEC_JSON).unwrap();
253 let json = serde_json::to_string(&file).unwrap();
254 let roundtrip: PathMappingFile = serde_json::from_str(&json).unwrap();
255
256 assert_eq!(
257 roundtrip.path_mapping_rules.len(),
258 file.path_mapping_rules.len()
259 );
260 for (a, b) in file
261 .path_mapping_rules
262 .iter()
263 .zip(roundtrip.path_mapping_rules.iter())
264 {
265 assert_eq!(a.source_path_format, b.source_path_format);
266 assert_eq!(a.source_path, b.source_path);
267 assert_eq!(a.destination_path, b.destination_path);
268 }
269 }
270
271 #[test]
272 fn serialize_posix_format() {
273 let rule = PathMappingRule {
274 source_path_format: PathFormat::Posix,
275 source_path: "/src".into(),
276 destination_path: "/dst".into(),
277 };
278 let json = serde_json::to_value(&rule).unwrap();
279 assert_eq!(json["source_path_format"], "POSIX");
280 }
281
282 #[test]
283 fn serialize_windows_format() {
284 let rule = PathMappingRule {
285 source_path_format: PathFormat::Windows,
286 source_path: "C:\\src".into(),
287 destination_path: "/dst".into(),
288 };
289 let json = serde_json::to_value(&rule).unwrap();
290 assert_eq!(json["source_path_format"], "WINDOWS");
291 }
292
293 #[test]
294 fn serialize_uri_format() {
295 let rule = PathMappingRule {
296 source_path_format: PathFormat::Uri,
297 source_path: "s3://bucket/assets".into(),
298 destination_path: "/mnt/assets".into(),
299 };
300 let json = serde_json::to_value(&rule).unwrap();
301 assert_eq!(json["source_path_format"], "URI");
302 }
303
304 #[test]
305 fn deserialize_uri_format() {
306 let json =
307 r#"{"source_path_format":"URI","source_path":"s3://bucket","destination_path":"/mnt"}"#;
308 let rule: PathMappingRule = serde_json::from_str(json).unwrap();
309 assert_eq!(rule.source_path_format, PathFormat::Uri);
310 }
311
312 #[test]
313 fn serialize_field_names_match_spec() {
314 let rule = PathMappingRule {
315 source_path_format: PathFormat::Posix,
316 source_path: "/a".into(),
317 destination_path: "/b".into(),
318 };
319 let json = serde_json::to_value(&rule).unwrap();
320 let obj = json.as_object().unwrap();
321 assert!(obj.contains_key("source_path_format"));
322 assert!(obj.contains_key("source_path"));
323 assert!(obj.contains_key("destination_path"));
324 assert_eq!(obj.len(), 3, "no extra fields");
325 }
326}