Skip to main content

openjd_expr/
path_mapping.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//! Path format and mapping rules.
6//!
7//! Mirrors Python `openjd.expr._path_mapping`.
8
9use serde::{Deserialize, Serialize};
10
11/// Path format (POSIX, Windows, or URI).
12#[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/// A path mapping rule.
34///
35/// Serializes to/from the JSON format specified in the OpenJD spec:
36/// <https://github.com/OpenJobDescription/openjd-specifications/wiki/How-Jobs-Are-Run#path-mapping>
37#[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    /// Apply this rule using host-native output separators.
47    /// Equivalent to Python's behavior (uses `os.name` to pick separator).
48    pub fn apply(&self, path: &str) -> Option<String> {
49        self.apply_with_format(path, PathFormat::host())
50    }
51
52    /// Apply this rule, using `output_format` to determine the output separator.
53    /// - `Posix` / `Uri` → `/`
54    /// - `Windows` → `\`
55    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    /// Find the byte offset where the path component begins in a URI (after `scheme://authority`).
68    /// Returns `None` if there is no `://` separator.
69    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        // Per RFC 3986: scheme and authority are case-insensitive, path is case-sensitive.
80        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        // Scheme+authority must match case-insensitively
84        if !path[..inp_path_start].eq_ignore_ascii_case(&self.source_path[..src_path_start]) {
85            return None;
86        }
87        // Path portion must match case-sensitively
88        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
147/// Split a path into parts, handling both `/` and `\` separators.
148/// Preserves drive letters as the first part (e.g., "C:" from "C:\foo").
149fn split_path_parts(path: &str) -> Vec<&str> {
150    path.split(&['/', '\\'][..])
151        .filter(|s| !s.is_empty())
152        .collect()
153}
154
155/// Check if a path has a trailing slash.
156/// For Windows paths, both `\` and `/` count.
157fn 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/// Apply a list of path mapping rules to a path. First match wins.
166///
167/// Rules must be pre-sorted by decreasing `source_path` length (longest match first).
168/// The session layer handles this sorting; see `Session::new()`.
169#[must_use]
170pub fn apply_rules(rules: &[PathMappingRule], path: &str) -> String {
171    apply_rules_with_format(rules, path, PathFormat::host())
172}
173
174/// Apply rules with an explicit output format.
175///
176/// Rules must be pre-sorted by decreasing `source_path` length (longest match first).
177#[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
191/// Check if a string is a URI (has a scheme:// prefix).
192pub 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    /// Spec JSON from https://github.com/OpenJobDescription/openjd-specifications/wiki/How-Jobs-Are-Run#path-mapping
201    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}