1#[derive(Debug, Clone)]
13pub struct UriParts {
14 pub authority: String,
15 pub path_parts: Vec<String>,
16}
17
18pub fn is_uri(path: &str) -> bool {
20 parse(path).is_some()
21}
22
23pub 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
53pub fn name(path: &str) -> String {
55 parse(path)
56 .and_then(|u| u.path_parts.last().cloned())
57 .unwrap_or_default()
58}
59
60pub 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
76pub 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
85pub 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
95pub 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
104pub 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
114pub 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 if p.last().is_some_and(|s| s.is_empty()) {
122 p.pop();
123 }
124 format!("{}/{}/{child}", uri.authority, p.join("/"))
125}
126
127pub 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}