check_config/
uri.rs

1use derive_more::{AsRef, Display, From};
2use dirs;
3use std::{
4    hash::{Hash, Hasher},
5    io::Write,
6    path::PathBuf,
7};
8use url::Url;
9
10#[derive(Debug, From, Display)]
11pub enum Error {
12    InvalidUrl,
13    UnknownUrlScheme,
14    NoValidPythonURL,
15    #[from]
16    IO(std::io::Error),
17    #[from]
18    Parse(url::ParseError),
19    #[from]
20    Reqwest(reqwest::Error),
21}
22impl std::error::Error for Error {}
23
24/// parse uri of files to read to or write from
25/// readable:
26/// - http(s) uri
27/// - python path
28/// - relative to config (ie any other readable uri)
29/// - + writable path
30///
31/// writable
32/// - local filesystem
33///   - relative to home dir
34///   - relative to check file dir
35///   - absolute pat
36///
37
38#[derive(thiserror::Error, Debug)]
39pub enum PathError {
40    #[error("unsupported scheme: {0}")]
41    UnsupportedScheme(String),
42
43    #[error("reqwest error: {0}")]
44    Http(#[from] reqwest::Error),
45
46    #[error("io error: {0}")]
47    Io(#[from] std::io::Error),
48
49    #[error("url parsing error: {0}")]
50    UrlParse(#[from] url::ParseError),
51
52    #[error("content is not a string")]
53    ContentIsNoString,
54}
55
56#[derive(AsRef, Clone, Debug, Display)]
57pub struct ReadablePath(Url);
58
59pub trait ReadPath {
60    fn exists(&self) -> Result<bool, PathError>;
61
62    fn read_to_string(&self) -> Result<String, PathError> {
63        let bytes = self.read_to_bytes()?;
64        String::from_utf8(bytes).map_err(|_| PathError::ContentIsNoString)
65    }
66
67    fn is_utf8(&self) -> Result<bool, PathError> {
68        match self.read_to_string() {
69            Err(PathError::ContentIsNoString) => Ok(false),
70            Ok(_) => Ok(true),
71            Err(e) => Err(e),
72        }
73    }
74
75    fn read_to_bytes(&self) -> Result<Vec<u8>, PathError>;
76
77    fn copy(&self, dest: &WritablePath) -> Result<(), PathError>;
78
79    fn hash(&self) -> Result<u64, PathError> {
80        let mut hasher = std::hash::DefaultHasher::new();
81        let content = self.read_to_bytes()?;
82        content.hash(&mut hasher);
83        Ok(hasher.finish())
84    }
85}
86
87impl ReadablePath {
88    pub fn from_url(url: Url) -> ReadablePath {
89        ReadablePath(url)
90    }
91    /// get a readable path
92    /// - config:<path>  - relative to current_config_path
93    /// - https://<path> - uri
94    /// - file://<path>  - absolute path
95    /// - py://<package>/<path>
96    /// - ~/<path>       - relative to home dir
97    /// - <path>         - relative to cwd
98    /// - /<path>        - absolute path
99    pub fn from_string(
100        input: &str,
101        current_config_path: Option<&ReadablePath>,
102    ) -> Result<ReadablePath, Error> {
103        // Case: config:<path>
104        if let Some(config_file_path) = current_config_path
105            && input.starts_with("config:")
106        {
107            let input = input.replacen("config:", "", 1);
108            return Ok(ReadablePath::from_url(
109                config_file_path
110                    .as_ref()
111                    .join(input.as_str())
112                    .map_err(|_e| Error::InvalidUrl)?,
113            ));
114        }
115
116        // Case file / http(s) / py
117        if let Ok(url) = Url::parse(input) {
118            if url.scheme() == "py" {
119                return py_url_to_url(url).map(ReadablePath::from_url);
120            }
121            return Ok(ReadablePath::from_url(url));
122        }
123
124        // case: absolute dir or relative to cwd /home dir
125        Ok(ReadablePath::from_url(
126            Url::from_file_path(WritablePath::from_string(input)?.as_ref())
127                .map_err(|_| Error::InvalidUrl)?,
128        ))
129    }
130
131    pub fn join(&self, file: &str) -> ReadablePath {
132        ReadablePath::from_url(self.as_ref().join(file).unwrap())
133    }
134}
135
136impl ReadPath for ReadablePath {
137    fn copy(&self, dest: &WritablePath) -> Result<(), PathError> {
138        // TODO: create parent dir if needed
139
140        match self.as_ref().scheme() {
141            "file" => {
142                let path = self
143                    .as_ref()
144                    .to_file_path()
145                    .map_err(|_| PathError::UnsupportedScheme("invalid file path".into()))?;
146                std::fs::copy(&path, dest.as_ref())?;
147                Ok(())
148            }
149            "http" | "https" => {
150                let resp = reqwest::blocking::get(self.as_ref().clone())?;
151                let bytes = resp.bytes()?;
152                let mut out = std::fs::File::create(dest.as_ref())?;
153                out.write_all(&bytes)?;
154                Ok(())
155            }
156            other => Err(PathError::UnsupportedScheme(other.into())),
157        }
158    }
159
160    fn read_to_bytes(&self) -> Result<Vec<u8>, PathError> {
161        match self.as_ref().scheme() {
162            "file" => Ok(std::fs::read(
163                self.as_ref()
164                    .to_file_path()
165                    .expect("an url with a file scheme is a valid file path"),
166            )?),
167            "http" | "https" => Ok(reqwest::blocking::get(self.as_ref().clone())?
168                .bytes()?
169                .into()),
170            other => Err(PathError::UnsupportedScheme(other.into())),
171        }
172    }
173
174    fn exists(&self) -> Result<bool, PathError> {
175        match self.as_ref().scheme() {
176            "file" => Ok(self
177                .as_ref()
178                .to_file_path()
179                .map_err(|_| PathError::UnsupportedScheme("invalid file path".into()))?
180                .exists()),
181            "http" | "https" => {
182                let resp = reqwest::blocking::get(self.as_ref().clone())?;
183                Ok(resp.status().is_success())
184            }
185            other => Err(PathError::UnsupportedScheme(other.into())),
186        }
187    }
188}
189
190#[derive(AsRef, Clone, Debug)]
191pub struct WritablePath(PathBuf);
192
193impl WritablePath {
194    pub fn new(path: PathBuf) -> WritablePath {
195        WritablePath(path)
196    }
197
198    pub fn from_string(input: &str) -> Result<WritablePath, Error> {
199        // case: relative to home dir
200        if input.starts_with("~") {
201            if let Some(home) = dirs::home_dir() {
202                let expanded = input.replacen("~", home.to_str().unwrap(), 1);
203                return Ok(WritablePath::new(PathBuf::from(expanded)));
204            }
205            return Err(Error::InvalidUrl);
206        }
207
208        // case: absolute
209        if input.starts_with("/") {
210            return Ok(WritablePath::new(PathBuf::from(input)));
211        }
212
213        // case: relative to cwd
214        let cwd = std::env::current_dir()
215            .map_err(|e| e.to_string())
216            .map_err(|_| Error::InvalidUrl)?;
217        let full_path = cwd.join(input);
218        Ok(WritablePath::new(full_path))
219    }
220
221    pub fn write_from_string(&self, content: &str) -> Result<(), Error> {
222        Ok(std::fs::write(self.as_ref(), content)?)
223    }
224
225    pub fn exists(&self) -> bool {
226        self.as_ref().exists()
227    }
228}
229
230impl std::fmt::Display for WritablePath {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        write!(f, "{}", self.as_ref().to_string_lossy())
233    }
234}
235
236impl ReadPath for WritablePath {
237    fn read_to_bytes(&self) -> Result<Vec<u8>, PathError> {
238        Ok(std::fs::read(self.as_ref())?)
239    }
240
241    fn exists(&self) -> Result<bool, PathError> {
242        Ok(self.as_ref().exists())
243    }
244
245    fn copy(&self, dest: &WritablePath) -> Result<(), PathError> {
246        std::fs::copy(self.as_ref(), dest.as_ref())?;
247        Ok(())
248    }
249}
250
251#[cfg(test)]
252fn get_python_package_path(module: &str) -> Option<Url> {
253    Url::parse(format!("file:///path/to/python/lib/site-packages/{module}").as_str()).ok()
254}
255
256#[cfg(not(test))]
257fn get_python_package_path(module: &str) -> Option<Url> {
258    let output = match std::process::Command::new("python")
259        .args([
260            "-c",
261            format!("import importlib; print(importlib.import_module('{module}').__file__)")
262                .as_str(),
263        ])
264        .output()
265    {
266        Err(_) => {
267            log::error!("Python can not be called");
268            std::process::exit(1);
269        }
270        Ok(output) => output,
271    };
272    let path = String::from_utf8(output.stdout)
273        .expect("Read output from Python command")
274        .trim()
275        .to_string();
276    let path = path.rsplit_once('/').unwrap().0;
277
278    Url::parse(&format!("file://{path}/")).ok()
279}
280
281fn py_url_to_url(package_uri: Url) -> Result<Url, Error> {
282    let package_name = package_uri.host().expect("host is present");
283
284    let module_url = match get_python_package_path(package_name.to_string().as_str()) {
285        Some(url) => url,
286        None => {
287            log::error!("{package_name} is not a valid python package");
288            return Err(Error::NoValidPythonURL);
289        }
290    };
291    let path_inside_package_without_leading_slash = package_uri
292        .path()
293        .split_once('/')
294        .expect("valid path with a leading slash")
295        .1;
296
297    Ok(module_url.join(path_inside_package_without_leading_slash)?)
298}
299
300#[cfg(test)]
301mod test {
302    use tempfile::tempdir;
303
304    use super::*;
305
306    #[test]
307    fn test_config_readable_path() {
308        let path = ReadablePath::from_string(
309            "config:test.toml",
310            Some(&ReadablePath::from_url(
311                Url::from_file_path("/some/base/dir/config.toml").unwrap(),
312            )),
313        )
314        .expect("path is ok");
315
316        assert_eq!(path.as_ref().path(), "/some/base/dir/test.toml");
317    }
318
319    #[test]
320    fn test_config_readable_path_with_url_base() {
321        let path = ReadablePath::from_string(
322            "config:test.toml",
323            Some(
324                &ReadablePath::from_string("https://test.nl/some/base/dir/config.toml", None)
325                    .unwrap(),
326            ),
327        )
328        .expect("path is ok");
329
330        assert_eq!(path.as_ref().path(), "/some/base/dir/test.toml");
331
332        let path = ReadablePath::from_string(
333            "config:sub/test.toml",
334            Some(
335                &ReadablePath::from_string("https://test.nl/some/base/dir/config.toml", None)
336                    .unwrap(),
337            ),
338        )
339        .expect("path is ok");
340
341        assert_eq!(path.as_ref().path(), "/some/base/dir/sub/test.toml");
342    }
343
344    #[test]
345    fn test_paths_copy_and_read() {
346        let dir = tempdir().unwrap();
347        let destination = dir.path().join("destination");
348        let destination = WritablePath::new(destination);
349
350        let source = ReadablePath::from_string(
351            "https://rust-lang.org/static/images/rust-logo-blk.svg",
352            Some(&ReadablePath::from_url(
353                Url::from_file_path(dir.path()).expect("valid path"),
354            )),
355        )
356        .expect("valid url");
357
358        source.copy(&destination).unwrap();
359
360        assert_eq!(
361            destination.read_to_string().unwrap(),
362            source.read_to_string().unwrap()
363        )
364    }
365
366    #[test]
367    fn test_exists() {
368        let dir = tempdir().unwrap();
369        assert!(
370            ReadablePath::from_string(
371                "https://rust-lang.org/static/images/rust-logo-blk.svg",
372                Some(&ReadablePath::from_url(
373                    Url::from_file_path(dir.path()).expect("valid path")
374                )),
375            )
376            .unwrap()
377            .exists()
378            .unwrap()
379        );
380
381        assert!(
382            !ReadablePath::from_string(
383                "https://rust-lang.org/non_existing",
384                Some(&ReadablePath::from_url(
385                    Url::from_file_path(dir.path()).expect("valid path")
386                )),
387            )
388            .unwrap()
389            .exists()
390            .unwrap()
391        );
392
393        let tmp_path = dir.path().join("tmp_file");
394
395        assert!(!WritablePath::new(tmp_path.clone()).exists());
396
397        std::fs::File::create(&tmp_path).unwrap();
398
399        assert!(WritablePath::new(tmp_path).exists());
400    }
401
402    #[test]
403    fn test_uris() {
404        assert_eq!(
405            ReadablePath::from_string("file:///path/to/test", None)
406                .unwrap()
407                .as_ref()
408                .path(),
409            "/path/to/test"
410        );
411        assert_eq!(
412            ReadablePath::from_string("https://path/to/test", None)
413                .unwrap()
414                .as_ref()
415                .path(),
416            "/to/test"
417        );
418        assert_eq!(
419            ReadablePath::from_string("py://pathlib/to/test", None)
420                .unwrap()
421                .as_ref()
422                .path(),
423            "/path/to/python/lib/site-packages/to/test"
424        );
425        assert!(
426            ReadablePath::from_string("pathlib", None)
427                .unwrap()
428                .as_ref()
429                .path()
430                .ends_with("check-config/pathlib")
431        );
432        assert_eq!(
433            ReadablePath::from_string("/path/to/test", None)
434                .unwrap()
435                .as_ref()
436                .path(),
437            "/path/to/test"
438        );
439
440        assert_eq!(
441            ReadablePath::from_string(
442                "https://domain/to/test",
443                Some(&ReadablePath::from_string("https://domain/other/path", None).unwrap())
444            )
445            .unwrap()
446            .as_ref()
447            .path(),
448            "/to/test"
449        );
450
451        assert_eq!(
452            ReadablePath::from_string(
453                "config:test",
454                Some(&ReadablePath::from_string("https://domain/other/path", None).unwrap())
455            )
456            .unwrap()
457            .as_ref()
458            .path(),
459            "/other/test"
460        );
461
462        assert_eq!(
463            ReadablePath::from_string(
464                "config:test",
465                Some(&ReadablePath::from_string("https://domain/other/path/", None).unwrap())
466            )
467            .unwrap()
468            .as_ref()
469            .path(),
470            "/other/path/test"
471        );
472        // assert!(py_url_to_url("py://pathlib".to_string()).is_none(),);
473
474        // assert_eq!(
475        //     py_url_to_url("py://pathlib:asset/file.txt".to_string()).unwrap(),
476        //     path::PathBuf::from("/path/to/python/lib/site-packages/asset/file.txt")
477        // );
478    }
479}