wdl_engine/
path.rs

1//! Representation of evaluation paths that support URLs.
2
3use 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
16/// Determines if the given string is prefixed with a `file` URL scheme.
17pub 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
23/// Determines if the given string is prefixed with a supported URL scheme.
24pub 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
34/// Parses a string into a URL.
35///
36/// Returns `None` if the string is not a supported scheme or not a valid URL.
37pub fn parse_url(s: &str) -> Option<Url> {
38    if !is_url(s) {
39        return None;
40    }
41
42    s.parse().ok()
43}
44
45/// Represents a path used in evaluation that may be either local or remote.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum EvaluationPath {
48    /// The path is local (i.e. on the host).
49    Local(PathBuf),
50    /// The path is remote.
51    Remote(Url),
52}
53
54impl EvaluationPath {
55    /// Joins the given path to this path.
56    pub fn join(&self, path: &str) -> Result<Self> {
57        // URLs are absolute, so they can't be joined
58        if is_url(path) {
59            return path.parse();
60        }
61
62        // We can't join an absolute local path either
63        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    /// Gets a string representation of the path.
78    ///
79    /// Returns `None` if the path is local and cannot be represented in UTF-8.
80    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    /// Converts the path to a local path.
88    ///
89    /// Returns `None` if the path is remote.
90    pub fn as_local(&self) -> Option<&Path> {
91        match self {
92            Self::Local(path) => Some(path),
93            Self::Remote(_) => None,
94        }
95    }
96
97    /// Unwraps the path to a local path.
98    ///
99    /// # Panics
100    ///
101    /// Panics if the path is remote.
102    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    /// Converts the path to a remote URL.
110    ///
111    /// Returns `None` if the path is local.
112    pub fn as_remote(&self) -> Option<&Url> {
113        match self {
114            Self::Local(_) => None,
115            Self::Remote(url) => Some(url),
116        }
117    }
118
119    /// Unwraps the path to a remote URL.
120    ///
121    /// # Panics
122    ///
123    /// Panics if the path is local.
124    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    /// Gets the parent of the given path.
132    ///
133    /// Returns `None` if the evaluation path isn't valid or has no parent.
134    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    /// Gets the file name of the path.
153    ///
154    /// Returns `Ok(None)` if the path does not contain a file name (i.e. is
155    /// root).
156    ///
157    /// Returns an error if the file name is not UTF-8.
158    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    /// Returns a display implementation for the path.
173    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    /// Makes the evaluation path absolute if it is a local path.
189    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        // Store `file` schemed URLs as local paths.
204        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}