libroast/operations/roast/
mod.rs

1// SPDX-License-Identifier: MPL-2.0
2
3// Copyright (C) 2025 Soc Virnyl Estela and contributors
4
5// This Source Code Form is subject to the terms of the Mozilla Public
6// License, v. 2.0. If a copy of the MPL was not distributed with this
7// file, You can obtain one at https://mozilla.org/MPL/2.0/.
8pub mod helpers;
9use crate::{
10    compress,
11    operations::cli,
12    utils::{
13        process_globs,
14        start_tracing,
15    },
16};
17use helpers::{
18    filter_paths,
19    is_excluded,
20};
21use rayon::prelude::*;
22use std::{
23    fs::{
24        self,
25    },
26    io,
27    path::{
28        Path,
29        PathBuf,
30    },
31};
32#[allow(unused_imports)]
33use tracing::{
34    Level,
35    debug,
36    error,
37    info,
38    trace,
39    warn,
40};
41use walkdir::WalkDir;
42
43/// This function helps process a list of additional paths separated by commas.
44pub(crate) fn get_additional_paths(adtnl_path: &str, root: &Path) -> (PathBuf, PathBuf)
45{
46    if let Some((ar, tgt)) = adtnl_path.split_once(",")
47    {
48        debug!(?ar, ?tgt);
49        let tgt = if tgt.trim().is_empty() { root } else { &root.join(tgt) };
50        (PathBuf::from(&ar), tgt.to_path_buf())
51    }
52    else
53    {
54        (PathBuf::from(&adtnl_path), root.to_path_buf())
55    }
56}
57
58/// This function helps process additional paths
59/// during the archiving process.
60pub(crate) fn process_additional_paths(
61    additional_paths: &[String],
62    target_path: &Path,
63    exclude_canonicalized_paths: &[PathBuf],
64    setup_workdir: &Path,
65    roast_args: &cli::RoastArgs,
66) -> io::Result<()>
67{
68    additional_paths.par_iter().try_for_each(|adtnlp| {
69        debug!(?adtnlp);
70        let (additional_from_path, additional_to_path) =
71            get_additional_paths(adtnlp, setup_workdir);
72        debug!(?additional_from_path, ?additional_to_path);
73        let src_canonicalized =
74            additional_from_path.canonicalize().unwrap_or(additional_from_path.to_path_buf());
75        debug!(?src_canonicalized);
76
77        if src_canonicalized.is_file()
78        {
79            let tgt_stripped =
80                additional_to_path.strip_prefix(setup_workdir).unwrap_or(Path::new("/"));
81            let target_with_tgt = &target_path.join(tgt_stripped);
82            if is_excluded(target_with_tgt, exclude_canonicalized_paths)
83            {
84                warn!(
85                    "⚠️ Directory `{}` is WITHIN an EXCLUDED path. Added a file OUTSIDE of target \
86                     directory: {}",
87                    &target_with_tgt.display(),
88                    &src_canonicalized.display()
89                );
90            }
91            // create directory and warn if it's an excluded directory
92            fs::create_dir_all(&additional_to_path)?;
93            // Copy file to target path
94            fs::copy(
95                &src_canonicalized,
96                additional_to_path.join(additional_from_path.file_name().unwrap_or_default()),
97            )?;
98            Ok(())
99        }
100        else if src_canonicalized.is_dir()
101        {
102            let tgt_stripped =
103                additional_to_path.strip_prefix(setup_workdir).unwrap_or(Path::new("/"));
104            let target_with_tgt = &target_path.join(tgt_stripped);
105            if is_excluded(target_with_tgt, exclude_canonicalized_paths)
106            {
107                warn!(
108                    "⚠️ ADDITIONAL directory that was WITHIN one of the EXCLUDED paths was added \
109                     back from OUTSIDE target path: {}",
110                    &target_with_tgt.display()
111                );
112                warn!("⚠️ This may not contain the same contents!");
113            }
114            let new_additional_to_path =
115                additional_to_path.join(src_canonicalized.file_name().unwrap_or_default());
116            fs::create_dir_all(&new_additional_to_path)?;
117            filter_paths(
118                &src_canonicalized,
119                &new_additional_to_path,
120                roast_args.ignore_hidden,
121                roast_args.ignore_git,
122                &[],
123            )
124        }
125        else
126        {
127            Ok(())
128        }
129    })?;
130    Ok(())
131}
132
133/// This processes included paths and filters out excluded paths.
134/// Any included paths that are excluded are always excluded only if it matches
135/// equally. Any included path that has an excluded parent path are included.
136pub(crate) fn process_include_paths(
137    include_paths: &[PathBuf],
138    exclude_canonicalized_paths: &[PathBuf],
139    target_path: &Path,
140    setup_workdir: &Path,
141    roast_args: &cli::RoastArgs,
142) -> io::Result<()>
143{
144    include_paths.par_iter().try_for_each(|include_path| {
145        let include_from_path = &target_path.join(include_path);
146        let include_from_path =
147            include_from_path.canonicalize().unwrap_or(include_from_path.to_path_buf());
148        if !include_from_path.exists()
149        {
150            let err = io::Error::new(
151                io::ErrorKind::NotFound,
152                "Path does not exist. This means that this path is not WITHIN the target \
153                 directory.",
154            );
155            error!(?err);
156            return Err(err);
157        }
158
159        let include_to_path = &setup_workdir.join(include_path);
160        debug!(?include_path, ?include_from_path, ?include_to_path);
161        if include_from_path.is_dir()
162        {
163            if is_excluded(&include_from_path, exclude_canonicalized_paths)
164            {
165                warn!(
166                    "⚠️ INCLUDED directory that is EXCLUDED will be IGNORED: {}",
167                    &include_from_path.display()
168                );
169            }
170            else
171            {
172                filter_paths(
173                    &include_from_path,
174                    include_to_path,
175                    roast_args.ignore_hidden,
176                    roast_args.ignore_git,
177                    &[],
178                )?;
179            }
180        }
181        else if include_from_path.is_file()
182        {
183            let include_from_path_parent =
184                include_from_path.parent().unwrap_or(target_path).to_path_buf();
185            let include_to_path_parent =
186                include_to_path.parent().unwrap_or(setup_workdir).to_path_buf();
187            if is_excluded(&include_from_path_parent, exclude_canonicalized_paths)
188            {
189                warn!(
190                    "⚠️ Path `{}` WITHIN an EXCLUDED path has added a file IN target directory. \
191                     Added file: {}",
192                    &include_from_path_parent.display(),
193                    &include_from_path.display()
194                );
195            }
196            if is_excluded(&include_from_path, exclude_canonicalized_paths)
197            {
198                warn!(
199                    "⚠️ EXCLUDED file `{}` has also been declared INCLUDED. Adding file takes \
200                     precedence. Added file: {}",
201                    &include_from_path.display(),
202                    &include_from_path.display()
203                );
204            }
205            // create directory and warn if it's an excluded directory
206            fs::create_dir_all(&include_to_path_parent)?;
207            // Copy file to target path
208            fs::copy(include_from_path, include_to_path)?;
209        }
210        Ok(())
211    })?;
212    Ok(())
213}
214
215/// Processes CLI arguments that matches the fields in the `RoastArgs`
216/// constructor. There is an optional activation of tracing subscriber for logs
217/// as the second parameter which is useful for cases where you need to log the
218/// important values that are passed down in this function. NOTE: Always pass
219/// `false` to the `start_trace` parameter if there is already a global tracing
220/// activated in the environment.
221pub fn roast_opts(roast_args: &cli::RoastArgs, start_trace: bool) -> io::Result<()>
222{
223    if start_trace
224    {
225        start_tracing();
226    }
227
228    info!("❤️‍🔥 Starting Roast.");
229    debug!(?roast_args);
230    let target_path = process_globs(&roast_args.target)?;
231    let target_path = target_path.canonicalize().unwrap_or(target_path);
232    let tmp_binding = tempfile::Builder::new()
233        .prefix(".rooooooooooaaaaaaaasssst")
234        .rand_bytes(8)
235        .tempdir()
236        .inspect_err(|err| {
237            error!(?err, "Failed to create temporary directory");
238        })?;
239
240    let workdir = &tmp_binding.path();
241    let setup_workdir = if roast_args.preserve_root
242    {
243        workdir.join(target_path.file_name().unwrap_or_default())
244    }
245    else
246    {
247        workdir.to_path_buf()
248    };
249    fs::create_dir_all(&setup_workdir)?;
250
251    let outdir = match &roast_args.outdir
252    {
253        Some(v) => v,
254        None => &std::env::current_dir()?,
255    };
256
257    if !outdir.is_dir()
258    {
259        std::fs::create_dir_all(outdir)?;
260    }
261
262    let outpath = outdir.join(&roast_args.outfile);
263    let outpath = outpath.canonicalize().unwrap_or(outpath);
264
265    let mut exclude_canonicalized_paths: Vec<PathBuf> =
266        roast_args.exclude.clone().unwrap_or_default();
267
268    exclude_canonicalized_paths = exclude_canonicalized_paths
269        .iter()
270        .map(|p| target_path.join(p).canonicalize().unwrap_or_default())
271        // NOTE: This is important. as unwrap_or_default contains at least one element of
272        // Path::from("") or a PathBuf::new()
273        .filter(|p| !p.to_string_lossy().trim().is_empty())
274        .collect();
275
276    debug!(?exclude_canonicalized_paths);
277
278    if let Some(additional_paths) = &roast_args.additional_paths
279    {
280        process_additional_paths(
281            additional_paths,
282            &target_path,
283            &exclude_canonicalized_paths,
284            &setup_workdir,
285            roast_args,
286        )?;
287    }
288
289    if let Some(include_paths) = &roast_args.include
290    {
291        process_include_paths(
292            include_paths,
293            &exclude_canonicalized_paths,
294            &target_path,
295            &setup_workdir,
296            roast_args,
297        )?;
298    }
299
300    filter_paths(
301        &target_path,
302        &setup_workdir,
303        roast_args.ignore_hidden,
304        roast_args.ignore_git,
305        &exclude_canonicalized_paths,
306    )?;
307
308    let archive_files: Vec<PathBuf> = WalkDir::new(workdir)
309        .into_iter()
310        .par_bridge()
311        .flatten()
312        .map(|f| {
313            debug!(?f);
314            f.into_path()
315        })
316        .filter(|p| p.is_file())
317        .collect();
318
319    debug!(?archive_files);
320
321    let reproducible = roast_args.reproducible;
322
323    let outpath_str = outpath.as_os_str().to_string_lossy();
324    let result = if outpath_str.ends_with("tar.gz")
325    {
326        compress::targz(&outpath, workdir, &archive_files, reproducible)
327    }
328    else if outpath_str.ends_with("tar.xz")
329    {
330        compress::tarxz(&outpath, workdir, &archive_files, reproducible)
331    }
332    else if outpath_str.ends_with("tar.zst") | outpath_str.ends_with("tar.zstd")
333    {
334        compress::tarzst(&outpath, workdir, &archive_files, reproducible)
335    }
336    else if outpath_str.ends_with("tar.bz")
337    {
338        compress::tarbz2(&outpath, workdir, &archive_files, reproducible)
339    }
340    else if outpath_str.ends_with("tar")
341    {
342        compress::vanilla(&outpath, workdir, &archive_files, reproducible)
343    }
344    else
345    {
346        let msg = format!("Unsupported file: {}", outpath_str);
347        Err(io::Error::new(io::ErrorKind::Unsupported, msg))
348    };
349
350    // Do not return the error. Just inform the user.
351    // This will allow us to delete the temporary directory.
352    if let Err(err) = result
353    {
354        error!(?err);
355    }
356    else
357    {
358        info!("🧑‍🍳 Your new tarball is now in {}", &outpath.display());
359    }
360
361    tmp_binding.close().inspect_err(|e| {
362        error!(?e, "Failed to delete temporary directory!");
363    })?;
364
365    Ok(())
366}