1#![warn(
29 clippy::all,
30 clippy::pedantic,
31 clippy::nursery,
32 )]
34
35use std::{
36 error::Error as StdError,
37 fmt::{Display, Formatter, Result as FmtResult},
38 fs::{create_dir_all, remove_dir_all, remove_file, File},
39 io::{copy, Cursor, Error as IoError, Read},
40 path::{Path, PathBuf},
41 result::Result as StdResult,
42};
43
44use libflate::gzip::Decoder as GzipDecoder;
45use tar::{Archive as TarArchive, EntryType as TarEntryType};
46use ureq::{get as http_get, Error as HttpError};
47
48pub type Result<T> = StdResult<T, Error>;
50
51pub type Status = Result<()>;
55
56#[derive(Debug)]
58pub enum Error {
59 Http(String),
61
62 Io(IoError),
64}
65
66impl StdError for Error {}
67
68impl Display for Error {
69 fn fmt(&self, f: &mut Formatter) -> FmtResult {
70 match self {
71 Self::Http(error) => {
72 "Http error: ".fmt(f)?;
73 error.fmt(f)
74 }
75 Self::Io(error) => {
76 "IO error: ".fmt(f)?;
77 error.fmt(f)
78 }
79 }
80 }
81}
82
83impl From<&HttpError> for Error {
84 #[must_use]
85 fn from(error: &HttpError) -> Self {
86 Self::Http(match error {
88 HttpError::Status(code, _) => {
89 format!("Invalid status: {}", code)
90 }
91 HttpError::Transport(transport) => {
92 format!("Transport error: {}", transport)
93 }
94 })
95 }
96}
97
98impl From<IoError> for Error {
99 #[must_use]
100 fn from(error: IoError) -> Self {
101 Self::Io(error)
102 }
103}
104
105type Flag = u8;
106
107const CREATE_DEST_PATH: Flag = 1 << 0;
108const FORCE_OVERWRITE: Flag = 1 << 1;
109const FIX_INVALID_DEST: Flag = 1 << 2;
110const CLEANUP_ON_ERROR: Flag = 1 << 3;
111const CLEANUP_DEST_DIR: Flag = 1 << 4;
112const STRIP_WHEN_ALONE: Flag = 1 << 5;
113
114const DEFAULT_SAVE_FLAGS: Flag =
115 CREATE_DEST_PATH | FORCE_OVERWRITE | FIX_INVALID_DEST | CLEANUP_ON_ERROR;
116const DEFAULT_UNROLL_FLAGS: Flag =
117 CREATE_DEST_PATH | FIX_INVALID_DEST | CLEANUP_ON_ERROR | CLEANUP_DEST_DIR;
118
119macro_rules! flag {
120 ($($var:ident).* [$key:ident]) => {
122 ($($var).* & $key) == $key
123 };
124
125 ($($var:ident).* [$key:ident] = $val:expr) => {
127 if $val {
128 $($var).* |= $key;
129 } else {
130 $($var).* &= !$key;
131 }
132 };
133}
134
135pub struct Fetch<R> {
137 source: Result<R>,
138}
139
140#[allow(clippy::use_self)]
141impl Fetch<()> {
142 pub fn from<U>(url: U) -> Fetch<impl Read>
144 where
145 U: AsRef<str>,
146 {
147 Fetch {
148 source: http_fetch(url.as_ref()),
149 }
150 }
151}
152
153fn http_fetch(url: &str) -> Result<impl Read> {
154 match http_get(url).call() {
155 Ok(response) => Ok(response.into_reader()),
156 Err(error) => {
157 Err(Error::from(&error))
159 }
160 }
161}
162
163impl<R> Fetch<R>
164where
165 R: Read,
166{
167 pub fn save(self) -> Save<impl Read> {
169 Save::from(self.source)
170 }
171
172 pub fn unroll(self) -> Unroll<impl Read> {
174 Unroll::from(self.source)
175 }
176}
177
178pub struct Save<R> {
180 source: Result<R>,
181 options: SaveOptions,
182}
183
184struct SaveOptions {
185 flags: Flag,
186}
187
188impl Default for SaveOptions {
189 fn default() -> Self {
190 Self {
191 flags: DEFAULT_SAVE_FLAGS,
192 }
193 }
194}
195
196impl<R> From<Result<R>> for Save<R> {
197 fn from(source: Result<R>) -> Self {
198 Self {
199 source,
200 options: SaveOptions::default(),
201 }
202 }
203}
204
205impl<R> Save<R> {
206 pub const fn create_dest_path(mut self, flag: bool) -> Self {
210 flag! { self.options.flags[CREATE_DEST_PATH] = flag }
211 self
212 }
213
214 pub const fn force_overwrite(mut self, flag: bool) -> Self {
218 flag! { self.options.flags[FORCE_OVERWRITE] = flag }
219 self
220 }
221
222 pub const fn fix_invalid_dest(mut self, flag: bool) -> Self {
229 flag! { self.options.flags[FIX_INVALID_DEST] = flag }
230 self
231 }
232
233 pub const fn cleanup_on_error(mut self, flag: bool) -> Self {
237 flag! { self.options.flags[CLEANUP_ON_ERROR] = flag }
238 self
239 }
240}
241
242impl<R> Save<R> {
243 pub fn to<D>(self, path: D) -> Status
250 where
251 R: Read,
252 D: AsRef<Path>,
253 {
254 let Self { source, options } = self;
255
256 let mut source = source?;
257
258 let path = path.as_ref();
259
260 if path.is_file() {
261 if flag!(options.flags[FORCE_OVERWRITE]) {
262 remove_file(path)?;
263 } else {
264 return Ok(());
265 }
266 } else if path.is_dir() {
267 if flag!(options.flags[FIX_INVALID_DEST]) {
268 remove_dir_all(path)?;
269 }
270 } else {
271 if flag!(options.flags[CREATE_DEST_PATH]) {
273 if let Some(path) = path.parent() {
274 create_dir_all(path)?;
275 }
276 }
277 }
278
279 copy(&mut source, &mut File::create(path)?)
280 .map(|_| ())
281 .or_else(|error| {
282 if flag!(options.flags[CLEANUP_ON_ERROR]) && path.is_file() {
283 remove_file(path)?;
284 }
285 Err(error)
286 })?;
287
288 Ok(())
289 }
290}
291
292pub struct Unroll<R> {
296 source: Result<R>,
297 options: UnrollOptions,
298}
299
300struct UnrollOptions {
301 strip_components: usize,
302 flags: Flag,
303}
304
305impl Default for UnrollOptions {
306 fn default() -> Self {
307 Self {
308 strip_components: 0,
309 flags: DEFAULT_UNROLL_FLAGS,
310 }
311 }
312}
313
314impl<R> From<Result<R>> for Unroll<R> {
315 fn from(source: Result<R>) -> Self {
316 Self {
317 source,
318 options: UnrollOptions::default(),
319 }
320 }
321}
322
323impl<R> Unroll<R> {
324 pub const fn create_dest_path(mut self, flag: bool) -> Self {
328 flag! { self.options.flags[CREATE_DEST_PATH] = flag }
329 self
330 }
331
332 pub const fn cleanup_dest_dir(mut self, flag: bool) -> Self {
336 flag! { self.options.flags[CLEANUP_DEST_DIR] = flag }
337 self
338 }
339
340 pub const fn fix_invalid_dest(mut self, flag: bool) -> Self {
347 flag! { self.options.flags[FIX_INVALID_DEST] = flag }
348 self
349 }
350
351 pub const fn cleanup_on_error(mut self, flag: bool) -> Self {
355 flag! { self.options.flags[CLEANUP_ON_ERROR] = flag }
356 self
357 }
358
359 pub const fn strip_components(mut self, num_of_components: usize) -> Self {
363 self.options.strip_components = num_of_components;
364 self
365 }
366
367 pub const fn strip_when_alone(mut self, flag: bool) -> Self {
371 flag! { self.options.flags[STRIP_WHEN_ALONE] = flag }
372 self
373 }
374}
375
376impl<R> Unroll<R> {
377 pub fn to<D>(self, path: D) -> Status
385 where
386 R: Read,
387 D: AsRef<Path>,
388 {
389 let Self { source, options } = self;
390
391 let source = source?;
392
393 let path = path.as_ref();
394 let mut dest_already_exists = false;
395
396 if path.is_dir() {
397 dest_already_exists = true;
398
399 if flag!(options.flags[CLEANUP_DEST_DIR]) {
400 remove_dir_entries(path)?;
401 }
402 } else if path.is_file() {
403 if flag!(options.flags[FIX_INVALID_DEST]) {
406 remove_file(path)?;
407
408 if flag!(options.flags[CREATE_DEST_PATH]) {
409 create_dir_all(path)?;
410 }
411 }
412 } else {
413 if flag!(options.flags[CREATE_DEST_PATH]) {
415 create_dir_all(path)?;
416 }
417 }
418
419 unroll_archive_to(source, &options, path).or_else(|error| {
420 if flag!(options.flags[CLEANUP_ON_ERROR]) && path.is_dir() {
421 if dest_already_exists {
422 remove_dir_entries(path)?;
423 } else {
424 remove_dir_all(path)?;
425 }
426 }
427 Err(error)
428 })
429 }
430}
431
432fn unroll_archive_to<R>(source: R, options: &UnrollOptions, destin: &Path) -> Status
433where
434 R: Read,
435{
436 let mut decoder = GzipDecoder::new(source)?;
437
438 if options.strip_components < 1 {
439 let mut archive = TarArchive::new(decoder);
440 archive.unpack(destin)?;
441 Ok(())
442 } else {
443 let mut decoded_data = Vec::new();
444 decoder.read_to_end(&mut decoded_data)?;
445
446 let strip_components = if flag!(options.flags[STRIP_WHEN_ALONE]) {
447 let mut archive = TarArchive::new(Cursor::new(&decoded_data));
448 options
449 .strip_components
450 .min(count_common_components(&mut archive)?)
451 } else {
452 options.strip_components
453 };
454
455 let mut archive = TarArchive::new(Cursor::new(decoded_data));
456 let entries = archive.entries()?;
457
458 for entry in entries {
459 let mut entry = entry?;
460 let type_ = entry.header().entry_type();
461
462 {
463 let entry_path = entry.path()?;
464
465 match type_ {
466 TarEntryType::Directory => {
467 let stripped_path = entry_path
468 .iter()
469 .skip(strip_components)
470 .collect::<PathBuf>();
471 if stripped_path.iter().count() < 1 {
472 continue;
473 }
474 let dest_path = destin.join(stripped_path);
475
476 entry.unpack(dest_path)?;
478 }
479 TarEntryType::Regular => {
480 let strip_components = strip_components.min(entry_path.iter().count() - 1);
481 let stripped_path = entry_path
482 .iter()
483 .skip(strip_components)
484 .collect::<PathBuf>();
485 let dest_path = destin.join(stripped_path);
486
487 entry.unpack(dest_path)?;
488 }
489 _ => println!("other: {:?}", entry_path),
490 }
491 }
492 }
493
494 Ok(())
495 }
496}
497
498fn count_common_components<R>(archive: &mut TarArchive<R>) -> StdResult<usize, IoError>
499where
500 R: Read,
501{
502 let mut common_ancestor = None;
503
504 for entry in archive.entries()? {
505 let entry = entry?;
506 let entry_path = entry.path()?;
507
508 match entry.header().entry_type() {
509 TarEntryType::Directory | TarEntryType::Regular => {
510 if common_ancestor.is_none() {
511 common_ancestor = Some(entry_path.to_path_buf());
512 } else {
513 let common_ancestor = common_ancestor.as_mut().unwrap();
514
515 *common_ancestor = common_ancestor
516 .iter()
517 .zip(entry_path.iter())
518 .take_while(|(common_component, entry_component)| {
519 common_component == entry_component
520 })
521 .map(|(common_component, _)| common_component)
522 .collect();
523 }
524 }
525 _ => (),
526 }
527 }
528
529 Ok(common_ancestor.map_or(0, |path| path.iter().count()))
530}
531
532fn remove_dir_entries(path: &Path) -> StdResult<(), IoError> {
533 for entry in path.read_dir()? {
534 let path = entry?.path();
535 if path.is_file() {
536 remove_file(path)?;
537 } else {
538 remove_dir_all(path)?;
539 }
540 }
541 Ok(())
542}
543
544#[cfg(test)]
545mod test {
546 use super::*;
547
548 #[test]
549 fn github_archive_new() {
550 let src_url = format!(
551 "{base}/{user}/{repo}/archive/{ver}.tar.gz",
552 base = "https://github.com",
553 user = "katyo",
554 repo = "fluidlite",
555 ver = "1.2.0",
556 );
557
558 let dst_dir = "target/test_archive_new";
559
560 Fetch::from(src_url)
562 .unroll()
563 .strip_components(1)
564 .strip_when_alone(true)
565 .to(dst_dir)
566 .unwrap();
567
568 }
570}