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#[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 pub fn from_string(
100 input: &str,
101 current_config_path: Option<&ReadablePath>,
102 ) -> Result<ReadablePath, Error> {
103 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 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 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 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 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 if input.starts_with("/") {
210 return Ok(WritablePath::new(PathBuf::from(input)));
211 }
212
213 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 }
479}