1use std::fmt;
4use std::path::Path;
5use std::path::PathBuf;
6use std::path::absolute;
7use std::str::FromStr;
8
9use anyhow::Context;
10use anyhow::Result;
11use anyhow::anyhow;
12use anyhow::bail;
13use path_clean::PathClean;
14use url::Url;
15
16pub fn is_file_url(s: &str) -> bool {
18 s.get(0..7)
19 .map(|s| s.eq_ignore_ascii_case("file://"))
20 .unwrap_or(false)
21}
22
23pub fn is_url(s: &str) -> bool {
25 ["http://", "https://", "file://", "az://", "s3://", "gs://"]
26 .iter()
27 .any(|prefix| {
28 s.get(0..prefix.len())
29 .map(|s| s.eq_ignore_ascii_case(prefix))
30 .unwrap_or(false)
31 })
32}
33
34pub fn parse_url(s: &str) -> Option<Url> {
38 if !is_url(s) {
39 return None;
40 }
41
42 s.parse().ok()
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum EvaluationPath {
48 Local(PathBuf),
50 Remote(Url),
52}
53
54impl EvaluationPath {
55 pub fn join(&self, path: &str) -> Result<Self> {
57 if is_url(path) {
59 return path.parse();
60 }
61
62 let p = Path::new(path);
64 if p.is_absolute() {
65 return Ok(Self::Local(p.clean()));
66 }
67
68 match self {
69 Self::Local(dir) => Ok(Self::Local(dir.join(path).clean())),
70 Self::Remote(dir) => dir
71 .join(path)
72 .map(Self::Remote)
73 .with_context(|| format!("failed to join `{path}` to URL `{dir}`")),
74 }
75 }
76
77 pub fn to_str(&self) -> Option<&str> {
81 match self {
82 Self::Local(path) => path.to_str(),
83 Self::Remote(url) => Some(url.as_str()),
84 }
85 }
86
87 pub fn as_local(&self) -> Option<&Path> {
91 match self {
92 Self::Local(path) => Some(path),
93 Self::Remote(_) => None,
94 }
95 }
96
97 pub fn unwrap_local(self) -> PathBuf {
103 match self {
104 Self::Local(path) => path,
105 Self::Remote(_) => panic!("path is remote"),
106 }
107 }
108
109 pub fn as_remote(&self) -> Option<&Url> {
113 match self {
114 Self::Local(_) => None,
115 Self::Remote(url) => Some(url),
116 }
117 }
118
119 pub fn unwrap_remote(self) -> Url {
125 match self {
126 Self::Local(_) => panic!("path is local"),
127 Self::Remote(url) => url,
128 }
129 }
130
131 pub fn parent_of(path: &str) -> Option<EvaluationPath> {
135 let path = path.parse().ok()?;
136 match path {
137 Self::Local(path) => path.parent().map(|p| Self::Local(p.to_path_buf())),
138 Self::Remote(mut url) => {
139 if url.path() == "/" {
140 return None;
141 }
142
143 if let Ok(mut segments) = url.path_segments_mut() {
144 segments.pop_if_empty().pop();
145 }
146
147 Some(Self::Remote(url))
148 }
149 }
150 }
151
152 pub fn file_name(&self) -> Result<Option<&str>> {
159 match self {
160 Self::Local(path) => path
161 .file_name()
162 .map(|n| {
163 n.to_str().with_context(|| {
164 format!("path `{path}` is not UTF-8", path = path.display())
165 })
166 })
167 .transpose(),
168 Self::Remote(url) => Ok(url.path_segments().and_then(|mut s| s.next_back())),
169 }
170 }
171
172 pub fn display(&self) -> impl fmt::Display {
174 struct Display<'a>(&'a EvaluationPath);
175
176 impl fmt::Display for Display<'_> {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 match self.0 {
179 EvaluationPath::Local(path) => write!(f, "{path}", path = path.display()),
180 EvaluationPath::Remote(url) => write!(f, "{url}"),
181 }
182 }
183 }
184
185 Display(self)
186 }
187
188 pub fn make_absolute(&mut self) {
190 if let Self::Local(path) = self
191 && !path.is_absolute()
192 && let Ok(abs) = absolute(&path)
193 {
194 *path = abs;
195 }
196 }
197}
198
199impl FromStr for EvaluationPath {
200 type Err = anyhow::Error;
201
202 fn from_str(s: &str) -> Result<Self> {
203 if is_file_url(s) {
205 let url = s
206 .parse::<Url>()
207 .with_context(|| format!("invalid `file` schemed URL `{s}`"))?;
208 return url
209 .to_file_path()
210 .map(|p| Self::Local(p.clean()))
211 .map_err(|_| anyhow!("URL `{s}` cannot be represented as a local file path"));
212 }
213
214 if let Some(url) = parse_url(s) {
215 return Ok(Self::Remote(url));
216 }
217
218 Ok(Self::Local(Path::new(s).clean()))
219 }
220}
221
222impl TryFrom<&str> for EvaluationPath {
223 type Error = anyhow::Error;
224
225 fn try_from(value: &str) -> Result<Self> {
226 value.parse()
227 }
228}
229
230impl TryFrom<EvaluationPath> for String {
231 type Error = anyhow::Error;
232
233 fn try_from(path: EvaluationPath) -> Result<Self> {
234 match path {
235 EvaluationPath::Local(path) => match path.into_os_string().into_string() {
236 Ok(s) => Ok(s),
237 Err(path) => bail!(
238 "path `{path}` cannot be represented with UTF-8",
239 path = path.display()
240 ),
241 },
242 EvaluationPath::Remote(url) => Ok(url.into()),
243 }
244 }
245}
246
247#[cfg(test)]
248mod test {
249 use pretty_assertions::assert_eq;
250
251 use super::*;
252
253 #[test]
254 fn test_file_urls() {
255 assert!(is_file_url("file:///foo/bar/baz"));
256 assert!(is_file_url("FiLe:///foo/bar/baz"));
257 assert!(is_file_url("FILE:///foo/bar/baz"));
258 assert!(!is_file_url("https://example.com/bar/baz"));
259 assert!(!is_file_url("az://foo/bar/baz"));
260 }
261
262 #[test]
263 fn test_urls() {
264 assert!(is_url("http://example.com/foo/bar/baz"));
265 assert!(is_url("HtTp://example.com/foo/bar/baz"));
266 assert!(is_url("HTTP://example.com/foo/bar/baz"));
267 assert!(is_url("https://example.com/foo/bar/baz"));
268 assert!(is_url("HtTpS://example.com/foo/bar/baz"));
269 assert!(is_url("HTTPS://example.com/foo/bar/baz"));
270 assert!(is_url("file:///foo/bar/baz"));
271 assert!(is_url("FiLe:///foo/bar/baz"));
272 assert!(is_url("FILE:///foo/bar/baz"));
273 assert!(is_url("az://foo/bar/baz"));
274 assert!(is_url("aZ://foo/bar/baz"));
275 assert!(is_url("AZ://foo/bar/baz"));
276 assert!(is_url("s3://foo/bar/baz"));
277 assert!(is_url("S3://foo/bar/baz"));
278 assert!(is_url("gs://foo/bar/baz"));
279 assert!(is_url("gS://foo/bar/baz"));
280 assert!(is_url("GS://foo/bar/baz"));
281 assert!(!is_url("foo://foo/bar/baz"));
282 }
283
284 #[test]
285 fn test_url_parsing() {
286 assert_eq!(
287 parse_url("http://example.com/foo/bar/baz")
288 .map(String::from)
289 .as_deref(),
290 Some("http://example.com/foo/bar/baz")
291 );
292 assert_eq!(
293 parse_url("https://example.com/foo/bar/baz")
294 .map(String::from)
295 .as_deref(),
296 Some("https://example.com/foo/bar/baz")
297 );
298 assert_eq!(
299 parse_url("file:///foo/bar/baz")
300 .map(String::from)
301 .as_deref(),
302 Some("file:///foo/bar/baz")
303 );
304 assert_eq!(
305 parse_url("az://foo/bar/baz").map(String::from).as_deref(),
306 Some("az://foo/bar/baz")
307 );
308 assert_eq!(
309 parse_url("s3://foo/bar/baz").map(String::from).as_deref(),
310 Some("s3://foo/bar/baz")
311 );
312 assert_eq!(
313 parse_url("gs://foo/bar/baz").map(String::from).as_deref(),
314 Some("gs://foo/bar/baz")
315 );
316 assert_eq!(
317 parse_url("foo://foo/bar/baz").map(String::from).as_deref(),
318 None
319 );
320 }
321
322 #[test]
323 fn test_evaluation_path_parsing() {
324 let p: EvaluationPath = "/foo/bar/baz".parse().expect("should parse");
325 assert_eq!(
326 p.unwrap_local().to_str().unwrap().replace("\\", "/"),
327 "/foo/bar/baz"
328 );
329
330 let p: EvaluationPath = "foo".parse().expect("should parse");
331 assert_eq!(p.unwrap_local().as_os_str(), "foo");
332
333 #[cfg(unix)]
334 {
335 let p: EvaluationPath = "file:///foo/bar/baz".parse().expect("should parse");
336 assert_eq!(p.unwrap_local().as_os_str(), "/foo/bar/baz");
337 }
338
339 #[cfg(windows)]
340 {
341 let p: EvaluationPath = "file:///C:/foo/bar/baz".parse().expect("should parse");
342 assert_eq!(p.unwrap_local().as_os_str(), "C:\\foo\\bar\\baz");
343 }
344
345 let p: EvaluationPath = "https://example.com/foo/bar/baz"
346 .parse()
347 .expect("should parse");
348 assert_eq!(
349 p.unwrap_remote().as_str(),
350 "https://example.com/foo/bar/baz"
351 );
352
353 let p: EvaluationPath = "az://foo/bar/baz".parse().expect("should parse");
354 assert_eq!(p.unwrap_remote().as_str(), "az://foo/bar/baz");
355
356 let p: EvaluationPath = "s3://foo/bar/baz".parse().expect("should parse");
357 assert_eq!(p.unwrap_remote().as_str(), "s3://foo/bar/baz");
358
359 let p: EvaluationPath = "gs://foo/bar/baz".parse().expect("should parse");
360 assert_eq!(p.unwrap_remote().as_str(), "gs://foo/bar/baz");
361 }
362
363 #[test]
364 fn test_evaluation_path_join() {
365 let p: EvaluationPath = "/foo/bar/baz".parse().expect("should parse");
366 assert_eq!(
367 p.join("qux/../quux")
368 .expect("should join")
369 .unwrap_local()
370 .to_str()
371 .unwrap()
372 .replace("\\", "/"),
373 "/foo/bar/baz/quux"
374 );
375
376 let p: EvaluationPath = "foo".parse().expect("should parse");
377 assert_eq!(
378 p.join("qux/../quux")
379 .expect("should join")
380 .unwrap_local()
381 .to_str()
382 .unwrap()
383 .replace("\\", "/"),
384 "foo/quux"
385 );
386
387 #[cfg(unix)]
388 {
389 let p: EvaluationPath = "file:///foo/bar/baz".parse().expect("should parse");
390 assert_eq!(
391 p.join("qux/../quux")
392 .expect("should join")
393 .unwrap_local()
394 .as_os_str(),
395 "/foo/bar/baz/quux"
396 );
397 }
398
399 #[cfg(windows)]
400 {
401 let p: EvaluationPath = "file:///C:/foo/bar/baz".parse().expect("should parse");
402 assert_eq!(
403 p.join("qux/../quux")
404 .expect("should join")
405 .unwrap_local()
406 .as_os_str(),
407 "C:\\foo\\bar\\baz\\quux"
408 );
409 }
410
411 let p: EvaluationPath = "https://example.com/foo/bar/baz"
412 .parse()
413 .expect("should parse");
414 assert_eq!(
415 p.join("qux/../quux")
416 .expect("should join")
417 .unwrap_remote()
418 .as_str(),
419 "https://example.com/foo/bar/quux"
420 );
421
422 let p: EvaluationPath = "https://example.com/foo/bar/baz/"
423 .parse()
424 .expect("should parse");
425 assert_eq!(
426 p.join("qux/../quux")
427 .expect("should join")
428 .unwrap_remote()
429 .as_str(),
430 "https://example.com/foo/bar/baz/quux"
431 );
432
433 let p: EvaluationPath = "az://foo/bar/baz/".parse().expect("should parse");
434 assert_eq!(
435 p.join("qux/../quux")
436 .expect("should join")
437 .unwrap_remote()
438 .as_str(),
439 "az://foo/bar/baz/quux"
440 );
441
442 let p: EvaluationPath = "s3://foo/bar/baz/".parse().expect("should parse");
443 assert_eq!(
444 p.join("qux/../quux")
445 .expect("should join")
446 .unwrap_remote()
447 .as_str(),
448 "s3://foo/bar/baz/quux"
449 );
450
451 let p: EvaluationPath = "gs://foo/bar/baz/".parse().expect("should parse");
452 assert_eq!(
453 p.join("qux/../quux")
454 .expect("should join")
455 .unwrap_remote()
456 .as_str(),
457 "gs://foo/bar/baz/quux"
458 );
459 }
460}