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        println!("{self} ({size})");
67        println!(
68            "  ├─ because of {}",
69            PathBuf::from("..").join(rule.file_to_check).display()
70        );
71
72        let result = match decider.obtain_decision(ctx, "├─ delete directory recursively?") {
73            Ok(Decision::Yes) => match cleaner.do_cleanup(self.as_ref())? {
74                Clean::Cleaned => {
75                    println!("  └─ deleted {size}");
76                    FolderProcessed::Cleaned(size_amount)
77                }
78                Clean::NotCleaned => {
79                    println!(
80                        "  └─ not deleted{}{size}",
81                        if ctx.is_dry_run { " [dry-run] " } else { "" }
82                    );
83                    FolderProcessed::Skipped
84                }
85            },
86            Ok(Decision::Quit) => {
87                println!("  └─ quiting");
88                FolderProcessed::Abort
89            }
90            _ => {
91                println!("  └─ skipped");
92                FolderProcessed::Skipped
93            }
94        };
95        println!();
96        Ok(result)
97    }
98
99    fn calculate_size(&self) -> usize {
100        jwalk::WalkDirGeneric::<((), Option<usize>)>::new(self.as_ref())
101            .skip_hidden(false)
102            .follow_links(false)
103            .parallelism(Parallelism::RayonDefaultPool {
104                busy_timeout: Duration::from_secs(60),
105            })
106            .process_read_dir(|_, _, _, dir_entry_results| {
107                dir_entry_results.iter_mut().for_each(|dir_entry_result| {
108                    if let Ok(dir_entry) = dir_entry_result {
109                        if !dir_entry.file_type.is_dir() {
110                            dir_entry.client_state = Some(
111                                dir_entry
112                                    .metadata()
113                                    .map(|m| m.len() as usize)
114                                    .unwrap_or_default(),
115                            );
116                        }
117                    }
118                })
119            })
120            .into_iter()
121            .filter_map(|f| f.ok())
122            .filter_map(|e| e.client_state)
123            .sum()
124    }
125}
126
127impl Display for Folder {
128    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
129        write!(f, "{}", self.0.display())
130    }
131}
132
133impl<A: ClientState> TryFrom<DirEntry<A>> for Folder {
134    type Error = std::io::Error;
135
136    fn try_from(value: DirEntry<A>) -> std::result::Result<Self, Self::Error> {
137        let path = value.path();
138        path.try_into() // see below..
139    }
140}
141
142impl TryFrom<PathBuf> for Folder {
143    type Error = std::io::Error;
144
145    fn try_from(path: PathBuf) -> std::result::Result<Self, Self::Error> {
146        if !path.is_dir() || path.eq(Path::new(".")) || path.eq(Path::new("..")) {
147            Err(Error::from(ErrorKind::Unsupported))
148        } else {
149            let p = path.canonicalize()?;
150            Ok(Self(p))
151        }
152    }
153}
154
155impl TryFrom<&str> for Folder {
156    type Error = std::io::Error;
157
158    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
159        Folder::try_from(PathBuf::from(value))
160    }
161}
162
163impl AsRef<Path> for Folder {
164    fn as_ref(&self) -> &Path {
165        self.0.as_ref()
166    }
167}
168
169#[deprecated(since = "2.0.0", note = "use trait `IsFolderToRemove` instead")]
170pub trait PathToRemoveResolver {
171    fn resolve_path_to_remove(&self, folder: impl AsRef<Path>) -> Result<Folder>;
172}
173
174#[allow(deprecated)]
175impl PathToRemoveResolver for FileToFolderMatch {
176    fn resolve_path_to_remove(&self, folder: impl AsRef<Path>) -> Result<Folder> {
177        let folder = folder.as_ref();
178        let file_to_check = folder.join(self.file_to_check);
179
180        if file_to_check.exists() {
181            let path_to_remove = folder.join(self.folder_to_remove);
182            if path_to_remove.exists() {
183                return path_to_remove.try_into();
184            }
185        }
186
187        Err(Error::from(ErrorKind::Unsupported))
188    }
189}
190
191/// Trait to check if a folder should be removed
192/// This is the successor of the deprecated `PathToRemoveResolver` and should be used instead.
193///
194/// The trait is implemented for `FileToFolderMatch` and can be used to check if a folder should be removed
195/// according to the rules defined in the `FileToFolderMatch` instance.
196pub trait IsFolderToRemove {
197    fn is_folder_to_remove(&self, folder: &Folder) -> bool;
198}
199
200impl IsFolderToRemove for FileToFolderMatch {
201    fn is_folder_to_remove(&self, folder: &Folder) -> bool {
202        folder.as_ref().parent().map_or_else(
203            || false,
204            |parent| {
205                parent.join(self.file_to_check).exists()
206                    && parent
207                        .join(self.folder_to_remove)
208                        .starts_with(folder.as_ref())
209            },
210        )
211    }
212}
213
214pub trait HumanReadable {
215    fn as_human_readable(&self) -> String;
216}
217
218impl HumanReadable for usize {
219    fn as_human_readable(&self) -> String {
220        const KIBIBYTE: usize = 1024;
221        const MEBIBYTE: usize = KIBIBYTE << 10;
222        const GIBIBYTE: usize = MEBIBYTE << 10;
223        const TEBIBYTE: usize = GIBIBYTE << 10;
224        const PEBIBYTE: usize = TEBIBYTE << 10;
225        const EXBIBYTE: usize = PEBIBYTE << 10;
226
227        let size = *self;
228        let (size, symbol) = match size {
229            size if size < KIBIBYTE => (size as f64, "B"),
230            size if size < MEBIBYTE => (size as f64 / KIBIBYTE as f64, "KiB"),
231            size if size < GIBIBYTE => (size as f64 / MEBIBYTE as f64, "MiB"),
232            size if size < TEBIBYTE => (size as f64 / GIBIBYTE as f64, "GiB"),
233            size if size < PEBIBYTE => (size as f64 / TEBIBYTE as f64, "TiB"),
234            size if size < EXBIBYTE => (size as f64 / PEBIBYTE as f64, "PiB"),
235            _ => (size as f64 / EXBIBYTE as f64, "EiB"),
236        };
237
238        format!("{size:.1}{symbol}")
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn should_size() {
248        assert_eq!(1_048_576, 1024 << 10);
249    }
250
251    #[test]
252    fn test_trait_is_folder_to_remove() {
253        let rule = FileToFolderMatch::new("Cargo.toml", "target");
254
255        let target_folder =
256            Folder::try_from(Path::new(".").canonicalize().unwrap().join("target")).unwrap();
257        assert!(rule.is_folder_to_remove(&target_folder));
258
259        let crate_root_folder = Folder::try_from(Path::new(".").canonicalize().unwrap()).unwrap();
260        assert!(!rule.is_folder_to_remove(&crate_root_folder));
261    }
262}