1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{anyhow, bail, Context, Result};
5use clap::Parser;
6use log::debug;
7use serde::Serialize;
8use tokio::fs;
9use walkdir::WalkDir;
10
11const DEFAULT_FILES: &'static str = r"
13Jenkinsfile,
14Makefile,
15Gulpfile.js,
16Gruntfile.js,
17gulpfile.js,
18.DS_Store,
19.tern-project,
20.gitattributes,
21.editorconfig,
22.eslintrc,
23eslint,
24.eslintrc.js,
25.eslintrc.json,
26.eslintrc.yml,
27.eslintignore,
28.stylelintrc,
29stylelint.config.js,
30.stylelintrc.json,
31.stylelintrc.yaml,
32.stylelintrc.yml,
33.stylelintrc.js,
34.htmllintrc,
35htmllint.js,
36.lint,
37.npmrc,
38.npmignore,
39.jshintrc,
40.flowconfig,
41.documentup.json,
42.yarn-metadata.json,
43.travis.yml,
44appveyor.yml,
45.gitlab-ci.yml,
46circle.yml,
47.coveralls.yml,
48CHANGES,
49changelog,
50License,
51LICENSE.txt,
52LICENSE,
53LICENSE-MIT,
54LICENSE.BSD,
55license,
56LICENCE.txt,
57LICENCE,
58LICENCE-MIT,
59LICENCE.BSD,
60licence,
61AUTHORS,
62CONTRIBUTORS,
63.yarn-integrity,
64.yarnclean,
65_config.yml,
66.babelrc,
67.yo-rc.json,
68jest.config.js,
69karma.conf.js,
70wallaby.js,
71wallaby.conf.js,
72.prettierrc,
73.prettierrc.yml,
74.prettierrc.toml,
75.prettierrc.js,
76.prettierrc.json,
77prettier.config.js,
78.appveyor.yml,
79tsconfig.json,
80tslint.json,
81";
82
83const DEFAULT_DIRS: &'static str = r"
85__tests__,
86test,
87tests,
88testing,
89benchmark,
90powered-test,
91docs,
92doc,
93.idea,
94.vscode,
95website,
96images,
97assets,
98example,
99examples,
100coverage,
101.nyc_output,
102.circleci,
103.github,
104";
105
106const DEFAULT_EXTS: &'static str = r"
108markdown,
109md,
110mkd,
111ts,
112jst,
113coffee,
114tgz,
115swp,
116";
117
118#[derive(Parser)]
119pub struct Config {
120 #[clap(
121 short = 'p',
122 long = "path",
123 parse(from_os_str),
124 default_value = "node_modules"
125 )]
126 pub path: PathBuf,
127
128 #[clap(short = 'v', long = "verbose")]
129 pub verbose: bool,
130}
131
132#[derive(Debug, Serialize, Default)]
133pub struct Stats {
134 pub files_total: u64,
135 pub files_removed: u64,
136 pub removed_size: u64,
137}
138
139#[derive(Debug)]
140pub struct Prune {
141 pub dir: PathBuf,
142 files: HashSet<String>,
143 exts: HashSet<String>,
144 dirs: HashSet<String>,
145}
146
147impl Prune {
148 pub fn new() -> Self {
149 Self {
150 dir: PathBuf::from("node_modules"),
151 files: split(DEFAULT_FILES),
152 dirs: split(DEFAULT_DIRS),
153 exts: split(DEFAULT_EXTS),
154 }
155 }
156
157 pub async fn run(&self) -> Result<Stats> {
158 let mut stats: Stats = Default::default();
159
160 let mut walker = WalkDir::new(&self.dir).into_iter();
161 loop {
162 let entry = match walker.next() {
163 Some(Ok(entry)) => entry,
164 Some(Err(err)) => {
165 bail!("access {} error", err.path().unwrap().display())
166 }
167 None => break,
168 };
169
170 let filepath = entry.path();
171 if !self.need_prune(filepath) {
172 debug!("skip: {}", filepath.display());
173 continue;
174 }
175
176 stats.files_total += 1;
177 stats.removed_size += entry.metadata().unwrap().len();
178
179 if filepath.is_dir() {
180 let s = dir_stats(filepath)?;
181 stats.files_total += s.files_total;
182 stats.files_removed += s.files_removed;
183 stats.removed_size += s.removed_size;
184
185 fs::remove_dir_all(filepath)
186 .await
187 .with_context(|| format!("removing directory {}", filepath.display()))?;
188
189 walker.skip_current_dir();
190 continue;
191 }
192
193 fs::remove_file(filepath)
194 .await
195 .with_context(|| format!("removing file {}", filepath.display()))?;
196 }
197
198 Ok(stats)
199 }
200
201 fn need_prune(&self, filepath: &Path) -> bool {
203 let filename = filepath.file_name().unwrap().to_str().unwrap();
204
205 if filepath.is_dir() {
206 return self.dirs.contains(filename);
207 }
208
209 if self.files.contains(filename) {
210 return true;
211 }
212
213 if let Some(extension) = filepath.extension() {
214 let ext = extension.to_str().unwrap();
215 if self.exts.contains(ext) {
216 return true;
217 }
218 }
219
220 false
221 }
222}
223
224fn dir_stats(dir: &Path) -> Result<Stats, walkdir::Error> {
226 let walker = WalkDir::new(dir).into_iter().filter_map(|e| e.ok());
227
228 let mut stats: Stats = Default::default();
229
230 for entry in walker {
231 let metadata = entry.metadata()?;
232 stats.files_total += 1;
233 stats.files_removed += 1;
234 stats.removed_size += metadata.len();
235 }
236
237 Ok(stats)
238}
239
240fn split(paths: &str) -> HashSet<String> {
244 paths
245 .split(",")
246 .map(|x| x.trim().to_string())
247 .filter(|x| !x.is_empty())
248 .collect()
249}
250
251#[cfg(test)]
252mod tests {
253
254 use super::{dir_stats, split};
255 use std::path::Path;
256
257 #[test]
258 fn dir_stats_happy_path() {
259 let path = Path::new("src");
260 let stats = dir_stats(path).unwrap();
261 assert_eq!(stats.files_total, 4);
262 assert_eq!(stats.files_removed, 4);
263 }
264
265 #[test]
266 fn dir_not_exits() {
267 let path = Path::new("not_exist");
268 let stats = dir_stats(path).unwrap();
269 assert_eq!(stats.files_removed, 0);
270 assert_eq!(stats.files_total, 0);
271 assert_eq!(stats.files_removed, 0);
272 }
273
274 #[test]
275 fn split_happypath() {
276 let paths = String::from("prettier,eslint,typescript,prettier");
277 let files = split(&paths);
278 assert_eq!(files.len(), 3);
279 }
280
281 #[test]
282 fn split_string_with_consecutive_commas() {
283 let paths = String::from("prettier,,");
284 let files = split(&paths[..]);
285 assert_eq!(files.len(), 1);
286 }
287
288 #[test]
289 fn split_string_with_trim() {
290 let paths = String::from(" prettier ,javascript es6");
291 let files = split(&paths[..]);
292 assert_eq!(files.len(), 2);
293 assert!(files.contains("prettier"));
294 assert!(files.contains("javascript es6"));
295 }
296}