Skip to main content

starbase_archive/
archive.rs

1use crate::archive_error::ArchiveError;
2use crate::tree_differ::TreeDiffer;
3use crate::{get_full_file_extension, join_file_name};
4use rustc_hash::{FxHashMap, FxHashSet};
5use starbase_utils::glob;
6use std::path::{Path, PathBuf};
7use tracing::{instrument, trace};
8
9/// Abstraction for packing archives.
10pub trait ArchivePacker {
11    /// Add the source file to the archive.
12    fn add_file(&mut self, name: &str, file: &Path) -> Result<(), ArchiveError>;
13
14    /// Add the source directory to the archive.
15    fn add_dir(&mut self, name: &str, dir: &Path) -> Result<(), ArchiveError>;
16
17    /// Create the archive and write all contents to disk.
18    fn pack(&mut self) -> Result<(), ArchiveError>;
19}
20
21/// Abstraction for unpacking archives.
22pub trait ArchiveUnpacker {
23    /// Unpack the archive to the destination directory. If a prefix is provided,
24    /// remove it from the start of all file paths within the archive.
25    fn unpack(&mut self, prefix: &str, differ: &mut TreeDiffer) -> Result<PathBuf, ArchiveError>;
26}
27
28/// An `Archiver` is an abstraction for packing and unpacking archives,
29/// that utilizes the same set of sources for both operations. For packing,
30/// the sources are the files that will be included in the archive. For unpacking,
31/// the sources are used for file tree diffing when extracting the archive.
32#[derive(Debug)]
33pub struct Archiver<'owner> {
34    /// The archive file itself (`.zip`, etc).
35    archive_file: &'owner Path,
36
37    /// Prefix to append to all files.
38    prefix: &'owner str,
39
40    /// Absolute file path to source, to relative file path in archive.
41    source_files: FxHashMap<PathBuf, String>,
42
43    /// Glob to finds files with.
44    source_globs: FxHashSet<String>,
45
46    /// For packing, the root to join source files with.
47    /// For unpacking, the root to extract files relative to.
48    pub source_root: &'owner Path,
49}
50
51impl<'owner> Archiver<'owner> {
52    /// Create a new archiver.
53    pub fn new(source_root: &'owner Path, archive_file: &'owner Path) -> Self {
54        Archiver {
55            archive_file,
56            prefix: "",
57            source_files: FxHashMap::default(),
58            source_globs: FxHashSet::default(),
59            source_root,
60        }
61    }
62
63    /// Add a source file to be used in the archiving process. The file path
64    /// can be relative from the source root, or absolute. A custom file path
65    /// can be used within the archive, otherwise the file will be placed
66    /// relative from the source root.
67    ///
68    /// For packing, this includes the file in the archive.
69    /// For unpacking, this diffs the file when extracting.
70    pub fn add_source_file<F: AsRef<Path>>(
71        &mut self,
72        source: F,
73        custom_name: Option<&str>,
74    ) -> &mut Self {
75        let source = source.as_ref();
76        let source = source.strip_prefix(self.source_root).unwrap_or(source);
77
78        self.source_files.insert(
79            self.source_root.join(source),
80            custom_name
81                .map(|n| n.to_owned())
82                .unwrap_or_else(|| source.to_string_lossy().to_string()),
83        );
84
85        self
86    }
87
88    /// Add a glob that'll find files, relative from the source root, to be
89    /// used in the archiving process.
90    ///
91    /// For packing, this finds files to include in the archive.
92    /// For unpacking, this finds files to diff against when extracting.
93    pub fn add_source_glob<G: AsRef<str>>(&mut self, glob: G) -> &mut Self {
94        self.source_globs.insert(glob.as_ref().to_owned());
95        self
96    }
97
98    /// Set the prefix to prepend to files wth when packing,
99    /// and to remove when unpacking.
100    pub fn set_prefix(&mut self, prefix: &'owner str) -> &mut Self {
101        self.prefix = prefix;
102        self
103    }
104
105    /// Pack and create the archive with the added source, using the
106    /// provided packer factory. The factory is passed an absolute
107    /// path to the destination archive file, which is also returned
108    /// from this method.
109    #[instrument(skip_all)]
110    pub fn pack<F, P>(&self, packer: F) -> Result<PathBuf, ArchiveError>
111    where
112        F: FnOnce(&Path) -> Result<P, ArchiveError>,
113        P: ArchivePacker,
114    {
115        trace!(
116            input_dir = ?self.source_root,
117            output_file = ?self.archive_file,
118            "Packing archive",
119        );
120
121        let mut archive = packer(self.archive_file)?;
122
123        for (source, file) in &self.source_files {
124            if !source.exists() {
125                trace!(source = ?source, "Source file does not exist, skipping");
126
127                continue;
128            }
129
130            let name = join_file_name([self.prefix, file]);
131
132            if source.is_file() {
133                archive.add_file(&name, source)?;
134            } else {
135                archive.add_dir(&name, source)?;
136            }
137        }
138
139        if !self.source_globs.is_empty() {
140            trace!(globs = ?self.source_globs, "Packing files using glob");
141
142            for file in glob::walk_files(self.source_root, &self.source_globs)? {
143                let file_name = file
144                    .strip_prefix(self.source_root)
145                    .unwrap()
146                    .to_str()
147                    .unwrap();
148
149                archive.add_file(&join_file_name([self.prefix, file_name]), &file)?;
150            }
151        }
152
153        archive.pack()?;
154
155        Ok(self.archive_file.to_path_buf())
156    }
157
158    /// Determine the packer to use based on the archive file extension,
159    /// then pack the archive using [`Archiver#pack`].
160    pub fn pack_from_ext(&self) -> Result<(String, PathBuf), ArchiveError> {
161        let ext = get_full_file_extension(self.archive_file);
162        let out = self.archive_file.to_path_buf();
163
164        match ext.as_deref() {
165            Some("gz") => {
166                #[cfg(feature = "gz")]
167                self.pack(crate::gz::GzPacker::new)?;
168
169                #[cfg(not(feature = "gz"))]
170                return Err(ArchiveError::FeatureNotEnabled {
171                    feature: "gz".into(),
172                    path: self.archive_file.to_path_buf(),
173                });
174            }
175            Some("tar") => {
176                #[cfg(feature = "tar")]
177                self.pack(crate::tar::TarPacker::new)?;
178
179                #[cfg(not(feature = "tar"))]
180                return Err(ArchiveError::FeatureNotEnabled {
181                    feature: "tar".into(),
182                    path: self.archive_file.to_path_buf(),
183                });
184            }
185            Some("tar.bz2" | "tz2" | "tbz" | "tbz2") => {
186                #[cfg(feature = "tar-bz2")]
187                self.pack(crate::tar::TarPacker::new_bz2)?;
188
189                #[cfg(not(feature = "tar-bz2"))]
190                return Err(ArchiveError::FeatureNotEnabled {
191                    feature: "tar-bz2".into(),
192                    path: self.archive_file.to_path_buf(),
193                });
194            }
195            Some("tar.gz" | "tgz") => {
196                #[cfg(feature = "tar-gz")]
197                self.pack(crate::tar::TarPacker::new_gz)?;
198
199                #[cfg(not(feature = "tar-gz"))]
200                return Err(ArchiveError::FeatureNotEnabled {
201                    feature: "tar-gz".into(),
202                    path: self.archive_file.to_path_buf(),
203                });
204            }
205            Some("tar.xz" | "txz") => {
206                #[cfg(feature = "tar-xz")]
207                self.pack(crate::tar::TarPacker::new_xz)?;
208
209                #[cfg(not(feature = "tar-xz"))]
210                return Err(ArchiveError::FeatureNotEnabled {
211                    feature: "tar-xz".into(),
212                    path: self.archive_file.to_path_buf(),
213                });
214            }
215            Some("zst" | "zstd") => {
216                #[cfg(feature = "tar-zstd")]
217                self.pack(crate::tar::TarPacker::new_zstd)?;
218
219                #[cfg(not(feature = "tar-zstd"))]
220                return Err(ArchiveError::FeatureNotEnabled {
221                    feature: "tar-zstd".into(),
222                    path: self.archive_file.to_path_buf(),
223                });
224            }
225            Some("zip") => {
226                #[cfg(feature = "zip")]
227                self.pack(crate::zip::ZipPacker::new)?;
228
229                #[cfg(not(feature = "zip"))]
230                return Err(ArchiveError::FeatureNotEnabled {
231                    feature: "zip".into(),
232                    path: self.archive_file.to_path_buf(),
233                });
234            }
235            Some(ext) => {
236                return Err(ArchiveError::UnsupportedFormat {
237                    format: ext.into(),
238                    path: self.archive_file.to_path_buf(),
239                });
240            }
241            None => {
242                return Err(ArchiveError::UnknownFormat {
243                    path: self.archive_file.to_path_buf(),
244                });
245            }
246        };
247
248        Ok((ext.unwrap(), out))
249    }
250
251    /// Unpack the archive to the destination root, using the provided
252    /// unpacker factory. The factory is passed an absolute path
253    /// to the output directory, and the input archive file. The unpacked
254    /// directory or file is returned from this method.
255    ///
256    /// When unpacking, we compare files at the destination to those
257    /// in the archive, and only unpack the files if they differ.
258    /// Furthermore, files at the destination that are not in the
259    /// archive are removed entirely.
260    #[instrument(skip_all)]
261    pub fn unpack<F, P>(&self, unpacker: F) -> Result<PathBuf, ArchiveError>
262    where
263        F: FnOnce(&Path, &Path) -> Result<P, ArchiveError>,
264        P: ArchiveUnpacker,
265    {
266        trace!(
267            output_dir = ?self.source_root,
268            input_file = ?self.archive_file,
269            "Unpacking archive",
270        );
271
272        let mut lookup_paths = vec![];
273        lookup_paths.extend(self.source_files.values());
274        lookup_paths.extend(&self.source_globs);
275
276        let mut differ = TreeDiffer::load(self.source_root, lookup_paths)?;
277        let mut archive = unpacker(self.source_root, self.archive_file)?;
278
279        let out = archive.unpack(self.prefix, &mut differ)?;
280        differ.remove_stale_tracked_files();
281
282        Ok(out)
283    }
284
285    /// Determine the unpacker to use based on the archive file extension,
286    /// then unpack the archive using [`Archiver#unpack`].
287    ///
288    /// Returns an absolute path to the directory or file that was created,
289    /// and the extension that was extracted from the input archive file.
290    pub fn unpack_from_ext(&self) -> Result<(String, PathBuf), ArchiveError> {
291        let ext = get_full_file_extension(self.archive_file);
292        let out;
293
294        match ext.as_deref() {
295            Some("gz") => {
296                #[cfg(feature = "gz")]
297                {
298                    out = self.unpack(crate::gz::GzUnpacker::new)?;
299                }
300
301                #[cfg(not(feature = "gz"))]
302                return Err(ArchiveError::FeatureNotEnabled {
303                    feature: "gz".into(),
304                    path: self.archive_file.to_path_buf(),
305                });
306            }
307            Some("tar") => {
308                #[cfg(feature = "tar")]
309                {
310                    out = self.unpack(crate::tar::TarUnpacker::new)?;
311                }
312
313                #[cfg(not(feature = "tar"))]
314                return Err(ArchiveError::FeatureNotEnabled {
315                    feature: "tar".into(),
316                    path: self.archive_file.to_path_buf(),
317                });
318            }
319            Some("tar.bz2" | "tz2" | "tbz" | "tbz2") => {
320                #[cfg(feature = "tar-bz2")]
321                {
322                    out = self.unpack(crate::tar::TarUnpacker::new_bz2)?;
323                }
324
325                #[cfg(not(feature = "tar-bz2"))]
326                return Err(ArchiveError::FeatureNotEnabled {
327                    feature: "tar-bz2".into(),
328                    path: self.archive_file.to_path_buf(),
329                });
330            }
331            Some("tar.gz" | "tgz") => {
332                #[cfg(feature = "tar-gz")]
333                {
334                    out = self.unpack(crate::tar::TarUnpacker::new_gz)?;
335                }
336
337                #[cfg(not(feature = "tar-gz"))]
338                return Err(ArchiveError::FeatureNotEnabled {
339                    feature: "tar-gz".into(),
340                    path: self.archive_file.to_path_buf(),
341                });
342            }
343            Some("tar.xz" | "txz") => {
344                #[cfg(feature = "tar-xz")]
345                {
346                    out = self.unpack(crate::tar::TarUnpacker::new_xz)?;
347                }
348
349                #[cfg(not(feature = "tar-xz"))]
350                return Err(ArchiveError::FeatureNotEnabled {
351                    feature: "tar-xz".into(),
352                    path: self.archive_file.to_path_buf(),
353                });
354            }
355            Some("zst" | "zstd") => {
356                #[cfg(feature = "tar-zstd")]
357                {
358                    out = self.unpack(crate::tar::TarUnpacker::new_zstd)?;
359                }
360
361                #[cfg(not(feature = "tar-zstd"))]
362                return Err(ArchiveError::FeatureNotEnabled {
363                    feature: "tar-zstd".into(),
364                    path: self.archive_file.to_path_buf(),
365                });
366            }
367            Some("zip") => {
368                #[cfg(feature = "zip")]
369                {
370                    out = self.unpack(crate::zip::ZipUnpacker::new)?;
371                }
372
373                #[cfg(not(feature = "zip"))]
374                return Err(ArchiveError::FeatureNotEnabled {
375                    feature: "zip".into(),
376                    path: self.archive_file.to_path_buf(),
377                });
378            }
379            Some(ext) => {
380                return Err(ArchiveError::UnsupportedFormat {
381                    format: ext.into(),
382                    path: self.archive_file.to_path_buf(),
383                });
384            }
385            None => {
386                return Err(ArchiveError::UnknownFormat {
387                    path: self.archive_file.to_path_buf(),
388                });
389            }
390        };
391
392        Ok((ext.unwrap(), out))
393    }
394}