Skip to main content

openjd_expr/
uri_path.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//! URI-aware path operations.
6//!
7//! Mirrors Python `openjd.expr._uri_path`. When a path value contains a URI
8//! (scheme://authority/path), these functions handle path operations instead of
9//! `std::path`. The scheme+authority prefix is preserved as an opaque root.
10
11/// Parsed URI: authority (`scheme://host`) and path segments.
12#[derive(Debug, Clone)]
13pub struct UriParts {
14    pub authority: String,
15    pub path_parts: Vec<String>,
16}
17
18/// Return `true` if `path` has a `scheme://` prefix.
19pub fn is_uri(path: &str) -> bool {
20    parse(path).is_some()
21}
22
23/// Parse a URI into authority + path parts, or `None` if not a URI.
24pub fn parse(path: &str) -> Option<UriParts> {
25    let scheme_end = path.find("://")?;
26    let scheme = &path[..scheme_end];
27    if scheme.is_empty() || !scheme.as_bytes()[0].is_ascii_alphabetic() {
28        return None;
29    }
30    if !scheme
31        .chars()
32        .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
33    {
34        return None;
35    }
36    let after_scheme = &path[scheme_end + 3..];
37    let (authority_part, path_part) = match after_scheme.find('/') {
38        Some(i) => (&after_scheme[..i], &after_scheme[i + 1..]),
39        None => (after_scheme, ""),
40    };
41    let authority = format!("{}://{}", scheme, authority_part);
42    let path_parts = if path_part.is_empty() {
43        Vec::new()
44    } else {
45        path_part.split('/').map(|s| s.to_string()).collect()
46    };
47    Some(UriParts {
48        authority,
49        path_parts,
50    })
51}
52
53/// Final component of a URI path (empty string if no path segments).
54pub fn name(path: &str) -> String {
55    parse(path)
56        .and_then(|u| u.path_parts.last().cloned())
57        .unwrap_or_default()
58}
59
60/// Parent of a URI path.
61pub fn parent(path: &str) -> String {
62    let Some(uri) = parse(path) else {
63        return path.to_string();
64    };
65    if uri.path_parts.is_empty() {
66        return uri.authority;
67    }
68    let parent_parts = &uri.path_parts[..uri.path_parts.len() - 1];
69    if parent_parts.is_empty() {
70        uri.authority
71    } else {
72        format!("{}/{}", uri.authority, parent_parts.join("/"))
73    }
74}
75
76/// File extension of the final component (including the dot), or empty string.
77pub fn suffix(path: &str) -> String {
78    let n = name(path);
79    n.rfind('.')
80        .filter(|&i| i > 0)
81        .map(|i| n[i..].to_string())
82        .unwrap_or_default()
83}
84
85/// All file extensions of the final component.
86pub fn suffixes(path: &str) -> Vec<String> {
87    let n = name(path);
88    let parts: Vec<&str> = n.split('.').collect();
89    if parts.len() <= 1 {
90        return Vec::new();
91    }
92    parts[1..].iter().map(|p| format!(".{p}")).collect()
93}
94
95/// Final component without the last extension.
96pub fn stem(path: &str) -> String {
97    let n = name(path);
98    n.rfind('.')
99        .filter(|&i| i > 0)
100        .map(|i| n[..i].to_string())
101        .unwrap_or(n)
102}
103
104/// Split into parts: first element is `scheme://authority`, rest are path segments.
105pub fn parts(path: &str) -> Vec<String> {
106    let Some(uri) = parse(path) else {
107        return vec![path.to_string()];
108    };
109    let mut result = vec![uri.authority];
110    result.extend(uri.path_parts);
111    result
112}
113
114/// Join a URI path with child segments.
115pub fn join(path: &str, child: &str) -> String {
116    let Some(uri) = parse(path) else {
117        return format!("{path}/{child}");
118    };
119    let mut p = uri.path_parts;
120    // Remove trailing empty part (from trailing slash) before appending
121    if p.last().is_some_and(|s| s.is_empty()) {
122        p.pop();
123    }
124    format!("{}/{}/{child}", uri.authority, p.join("/"))
125}
126
127/// Reconstruct a URI from parts (first element is `scheme://authority`).
128pub fn from_parts(parts: &[String]) -> String {
129    if parts.is_empty() {
130        return String::new();
131    }
132    if parts.len() == 1 {
133        return parts[0].clone();
134    }
135    format!("{}/{}", parts[0], parts[1..].join("/"))
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn not_uri() {
144        assert!(!is_uri("/local/path"));
145    }
146    #[test]
147    fn not_uri_windows() {
148        assert!(!is_uri("C:\\path"));
149    }
150    #[test]
151    fn s3_is_uri() {
152        assert!(is_uri("s3://bucket/key"));
153    }
154    #[test]
155    fn https_is_uri() {
156        assert!(is_uri("https://host/path"));
157    }
158
159    #[test]
160    fn parse_s3() {
161        let u = parse("s3://bucket/dir/file.txt").unwrap();
162        assert_eq!(u.authority, "s3://bucket");
163        assert_eq!(u.path_parts, vec!["dir", "file.txt"]);
164    }
165    #[test]
166    fn parse_bare() {
167        let u = parse("s3://bucket").unwrap();
168        assert_eq!(u.authority, "s3://bucket");
169        assert!(u.path_parts.is_empty());
170    }
171
172    #[test]
173    fn name_basic() {
174        assert_eq!(name("s3://bucket/dir/file.txt"), "file.txt");
175    }
176    #[test]
177    fn name_bare() {
178        assert_eq!(name("s3://bucket"), "");
179    }
180    #[test]
181    fn name_trailing_slash() {
182        assert_eq!(name("s3://bucket/dir/"), "");
183    }
184
185    #[test]
186    fn parent_basic() {
187        assert_eq!(parent("s3://bucket/dir/file.txt"), "s3://bucket/dir");
188    }
189    #[test]
190    fn parent_single() {
191        assert_eq!(parent("s3://bucket/file.txt"), "s3://bucket");
192    }
193    #[test]
194    fn parent_bare() {
195        assert_eq!(parent("s3://bucket"), "s3://bucket");
196    }
197
198    #[test]
199    fn suffix_basic() {
200        assert_eq!(suffix("s3://bucket/file.tar.gz"), ".gz");
201    }
202    #[test]
203    fn suffix_none() {
204        assert_eq!(suffix("s3://bucket/file"), "");
205    }
206
207    #[test]
208    fn suffixes_compound() {
209        assert_eq!(suffixes("s3://bucket/file.tar.gz"), vec![".tar", ".gz"]);
210    }
211    #[test]
212    fn suffixes_none() {
213        assert_eq!(suffixes("s3://bucket/file"), Vec::<String>::new());
214    }
215
216    #[test]
217    fn stem_basic() {
218        assert_eq!(stem("s3://bucket/file.tar.gz"), "file.tar");
219    }
220    #[test]
221    fn stem_no_ext() {
222        assert_eq!(stem("s3://bucket/file"), "file");
223    }
224
225    #[test]
226    fn parts_basic() {
227        assert_eq!(
228            parts("s3://bucket/dir/file"),
229            vec!["s3://bucket", "dir", "file"]
230        );
231    }
232    #[test]
233    fn parts_bare() {
234        assert_eq!(parts("s3://bucket"), vec!["s3://bucket"]);
235    }
236
237    #[test]
238    fn from_parts_basic() {
239        assert_eq!(
240            from_parts(&["s3://bucket".into(), "dir".into(), "file".into()]),
241            "s3://bucket/dir/file"
242        );
243    }
244    #[test]
245    fn from_parts_bare() {
246        assert_eq!(from_parts(&["s3://bucket".into()]), "s3://bucket");
247    }
248}