putzen_cli/
lib.rs

1mod cleaner;
2mod decider;
3
4pub use crate::cleaner::*;
5pub use crate::decider::*;
6
7use jwalk::{ClientState, DirEntry, Parallelism};
8use std::convert::{TryFrom, TryInto};
9use std::fmt::{Display, Formatter};
10use std::io::{Error, ErrorKind, Result};
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14pub struct FileToFolderMatch {
15    file_to_check: &'static str,
16    folder_to_remove: &'static str,
17}
18
19pub enum FolderProcessed {
20    /// The folder was cleaned and the amount of bytes removed is given
21    Cleaned(usize),
22    /// The folder was not cleaned because it did not match any rule
23    NoRuleMatch,
24    /// The folder was skipped, e.g. user decided to skip it
25    Skipped,
26    /// The folder was aborted, e.g. user decided to abort the whole process
27    Abort,
28}
29
30impl FileToFolderMatch {
31    pub const fn new(file_to_check: &'static str, folder_to_remove: &'static str) -> Self {
32        Self {
33            file_to_check,
34            folder_to_remove,
35        }
36    }
37
38    /// builds the absolut path, that is to be removed, in the given folder
39    pub fn path_to_remove(&self, folder: impl AsRef<Path>) -> Option<impl AsRef<Path>> {
40        folder
41            .as_ref()
42            .canonicalize()
43            .map(|x| x.join(self.folder_to_remove))
44            .ok()
45    }
46}
47
48#[derive(Debug, Eq, PartialEq, Hash)]
49pub struct Folder(PathBuf);
50
51impl Folder {
52    pub fn accept(
53        &self,
54        ctx: &DecisionContext,
55        rule: &FileToFolderMatch,
56        cleaner: &dyn DoCleanUp,
57        decider: &mut impl Decide,
58    ) -> Result<FolderProcessed> {
59        // better double check here
60        if !rule.is_folder_to_remove(self) {
61            return Ok(FolderProcessed::NoRuleMatch);
62        }
63
64        let size_amount = self.calculate_size();
65        let size = size_amount.as_human_readable();
66        let folder = self.as_ref().display().to_string();
67        let folder = ctx
68            .working_dir
69            .components()
70            .take(ctx.working_dir.components().count() - 1)
71            .fold(folder, |acc, component| {
72                // take only the first letter of each component and add it to the string
73                if let Some(s) = component.as_os_str().to_str() {
74                    acc.replace(s, s.chars().next().unwrap_or(' ').to_string().as_str())
75                } else {
76                    acc
77                }
78            });
79
80        ctx.println(format!("Cleaning {folder} with {size}"));
81        ctx.println(format!(
82            "  ├─ because of {}",
83            PathBuf::from("..").join(rule.file_to_check).display()
84        ));
85
86        let result = match decider.obtain_decision(ctx, "├─ delete directory recursively?") {
87            Ok(Decision::Yes) => match cleaner.do_cleanup(self.as_ref())? {
88                Clean::Cleaned => {
89                    ctx.println(format!("  └─ deleted {size}"));
90                    FolderProcessed::Cleaned(size_amount)
91                }
92                Clean::NotCleaned => {
93                    ctx.println(format!(
94                        "  └─ not deleted{}{size}",
95                        if ctx.is_dry_run { " [dry-run] " } else { "" }
96                    ));
97                    FolderProcessed::Skipped
98                }
99            },
100            Ok(Decision::Quit) => {
101                ctx.println("  └─ quiting");
102                FolderProcessed::Abort
103            }
104            _ => {
105                ctx.println("  └─ skipped");
106                FolderProcessed::Skipped
107            }
108        };
109        ctx.println("");
110        Ok(result)
111    }
112
113    fn calculate_size(&self) -> usize {
114        jwalk::WalkDirGeneric::<((), Option<usize>)>::new(self.as_ref())
115            .skip_hidden(false)
116            .follow_links(false)
117            .parallelism(Parallelism::RayonDefaultPool {
118                busy_timeout: Duration::from_secs(60),
119            })
120            .process_read_dir(|_, _, _, dir_entry_results| {
121                dir_entry_results.iter_mut().for_each(|dir_entry_result| {
122                    if let Ok(dir_entry) = dir_entry_result {
123                        if !dir_entry.file_type.is_dir() {
124                            dir_entry.client_state = Some(
125                                dir_entry
126                                    .metadata()
127                                    .map(|m| m.len() as usize)
128                                    .unwrap_or_default(),
129                            );
130                        }
131                    }
132                })
133            })
134            .into_iter()
135            .filter_map(|f| f.ok())
136            .filter_map(|e| e.client_state)
137            .sum()
138    }
139}
140
141impl Display for Folder {
142    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
143        write!(f, "{}", self.0.display())
144    }
145}
146
147impl<A: ClientState> TryFrom<DirEntry<A>> for Folder {
148    type Error = std::io::Error;
149
150    fn try_from(value: DirEntry<A>) -> std::result::Result<Self, Self::Error> {
151        let path = value.path();
152        path.try_into() // see below..
153    }
154}
155
156impl TryFrom<PathBuf> for Folder {
157    type Error = std::io::Error;
158
159    fn try_from(path: PathBuf) -> std::result::Result<Self, Self::Error> {
160        if !path.is_dir() || path.eq(Path::new(".")) || path.eq(Path::new("..")) {
161            Err(Error::from(ErrorKind::Unsupported))
162        } else {
163            let p = path.canonicalize()?;
164            Ok(Self(p))
165        }
166    }
167}
168
169impl TryFrom<&str> for Folder {
170    type Error = std::io::Error;
171
172    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
173        Folder::try_from(PathBuf::from(value))
174    }
175}
176
177impl AsRef<Path> for Folder {
178    fn as_ref(&self) -> &Path {
179        self.0.as_ref()
180    }
181}
182
183#[deprecated(since = "2.0.0", note = "use trait `IsFolderToRemove` instead")]
184pub trait PathToRemoveResolver {
185    fn resolve_path_to_remove(&self, folder: impl AsRef<Path>) -> Result<Folder>;
186}
187
188#[allow(deprecated)]
189impl PathToRemoveResolver for FileToFolderMatch {
190    fn resolve_path_to_remove(&self, folder: impl AsRef<Path>) -> Result<Folder> {
191        let folder = folder.as_ref();
192        let file_to_check = folder.join(self.file_to_check);
193
194        if file_to_check.exists() {
195            let path_to_remove = folder.join(self.folder_to_remove);
196            if path_to_remove.exists() {
197                return path_to_remove.try_into();
198            }
199        }
200
201        Err(Error::from(ErrorKind::Unsupported))
202    }
203}
204
205/// Trait to check if a folder should be removed
206/// This is the successor of the deprecated `PathToRemoveResolver` and should be used instead.
207///
208/// The trait is implemented for `FileToFolderMatch` and can be used to check if a folder should be removed
209/// according to the rules defined in the `FileToFolderMatch` instance.
210pub trait IsFolderToRemove {
211    fn is_folder_to_remove(&self, folder: &Folder) -> bool;
212}
213
214impl IsFolderToRemove for FileToFolderMatch {
215    fn is_folder_to_remove(&self, folder: &Folder) -> bool {
216        folder.as_ref().parent().map_or_else(
217            || false,
218            |parent| {
219                parent.join(self.file_to_check).exists()
220                    && parent
221                        .join(self.folder_to_remove)
222                        .starts_with(folder.as_ref())
223            },
224        )
225    }
226}
227
228pub trait HumanReadable {
229    fn as_human_readable(&self) -> String;
230}
231
232impl HumanReadable for usize {
233    fn as_human_readable(&self) -> String {
234        const KIBIBYTE: usize = 1024;
235        const MEBIBYTE: usize = KIBIBYTE << 10;
236        const GIBIBYTE: usize = MEBIBYTE << 10;
237        const TEBIBYTE: usize = GIBIBYTE << 10;
238        const PEBIBYTE: usize = TEBIBYTE << 10;
239        const EXBIBYTE: usize = PEBIBYTE << 10;
240
241        let size = *self;
242        let (size, symbol) = match size {
243            size if size < KIBIBYTE => (size as f64, "B"),
244            size if size < MEBIBYTE => (size as f64 / KIBIBYTE as f64, "KiB"),
245            size if size < GIBIBYTE => (size as f64 / MEBIBYTE as f64, "MiB"),
246            size if size < TEBIBYTE => (size as f64 / GIBIBYTE as f64, "GiB"),
247            size if size < PEBIBYTE => (size as f64 / TEBIBYTE as f64, "TiB"),
248            size if size < EXBIBYTE => (size as f64 / PEBIBYTE as f64, "PiB"),
249            _ => (size as f64 / EXBIBYTE as f64, "EiB"),
250        };
251
252        format!("{size:.1}{symbol}")
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn should_size() {
262        assert_eq!(1_048_576, 1024 << 10);
263    }
264
265    #[test]
266    fn test_trait_is_folder_to_remove() {
267        let rule = FileToFolderMatch::new("Cargo.toml", "target");
268
269        let target_folder =
270            Folder::try_from(Path::new(".").canonicalize().unwrap().join("target")).unwrap();
271        assert!(rule.is_folder_to_remove(&target_folder));
272
273        let crate_root_folder = Folder::try_from(Path::new(".").canonicalize().unwrap()).unwrap();
274        assert!(!rule.is_folder_to_remove(&crate_root_folder));
275    }
276}