kondo_lib/
lib.rs

1use std::{
2    borrow::Cow,
3    error::{self, Error},
4    fs,
5    path::{self, Path},
6    time::SystemTime,
7};
8
9const FILE_CARGO_TOML: &str = "Cargo.toml";
10const FILE_PACKAGE_JSON: &str = "package.json";
11const FILE_ASSEMBLY_CSHARP: &str = "Assembly-CSharp.csproj";
12const FILE_STACK_HASKELL: &str = "stack.yaml";
13const FILE_SBT_BUILD: &str = "build.sbt";
14const FILE_MVN_BUILD: &str = "pom.xml";
15const FILE_BUILD_GRADLE: &str = "build.gradle";
16const FILE_BUILD_GRADLE_KTS: &str = "build.gradle.kts";
17const FILE_CMAKE_BUILD: &str = "CMakeLists.txt";
18const FILE_UNREAL_SUFFIX: &str = ".uproject";
19const FILE_JUPYTER_SUFFIX: &str = ".ipynb";
20const FILE_PYTHON_SUFFIX: &str = ".py";
21const FILE_COMPOSER_JSON: &str = "composer.json";
22const FILE_PUBSPEC_YAML: &str = "pubspec.yaml";
23const FILE_ELIXIR_MIX: &str = "mix.exs";
24const FILE_SWIFT_PACKAGE: &str = "Package.swift";
25const FILE_BUILD_ZIG: &str = "build.zig";
26const FILE_GODOT_4_PROJECT: &str = "project.godot";
27const FILE_CSPROJ_SUFFIX: &str = ".csproj";
28const FILE_FSPROJ_SUFFIX: &str = ".fsproj";
29
30const PROJECT_CARGO_DIRS: [&str; 2] = ["target", ".xwin-cache"];
31const PROJECT_NODE_DIRS: [&str; 2] = ["node_modules", ".angular"];
32const PROJECT_UNITY_DIRS: [&str; 7] = [
33    "Library",
34    "Temp",
35    "Obj",
36    "Logs",
37    "MemoryCaptures",
38    "Build",
39    "Builds",
40];
41const PROJECT_STACK_DIRS: [&str; 1] = [".stack-work"];
42const PROJECT_SBT_DIRS: [&str; 2] = ["target", "project/target"];
43const PROJECT_MVN_DIRS: [&str; 1] = ["target"];
44const PROJECT_GRADLE_DIRS: [&str; 2] = ["build", ".gradle"];
45const PROJECT_CMAKE_DIRS: [&str; 3] = ["build", "cmake-build-debug", "cmake-build-release"];
46const PROJECT_UNREAL_DIRS: [&str; 5] = [
47    "Binaries",
48    "Build",
49    "Saved",
50    "DerivedDataCache",
51    "Intermediate",
52];
53const PROJECT_JUPYTER_DIRS: [&str; 1] = [".ipynb_checkpoints"];
54const PROJECT_PYTHON_DIRS: [&str; 8] = [
55    ".mypy_cache",
56    ".nox",
57    ".pytest_cache",
58    ".ruff_cache",
59    ".tox",
60    ".venv",
61    "__pycache__",
62    "__pypackages__",
63];
64const PROJECT_COMPOSER_DIRS: [&str; 1] = ["vendor"];
65const PROJECT_PUB_DIRS: [&str; 4] = [
66    "build",
67    ".dart_tool",
68    "linux/flutter/ephemeral",
69    "windows/flutter/ephemeral",
70];
71const PROJECT_ELIXIR_DIRS: [&str; 4] = ["_build", ".elixir-tools", ".elixir_ls", ".lexical"];
72const PROJECT_SWIFT_DIRS: [&str; 2] = [".build", ".swiftpm"];
73const PROJECT_ZIG_DIRS: [&str; 1] = ["zig-cache"];
74const PROJECT_GODOT_4_DIRS: [&str; 1] = [".godot"];
75const PROJECT_DOTNET_DIRS: [&str; 2] = ["bin", "obj"];
76
77const PROJECT_CARGO_NAME: &str = "Cargo";
78const PROJECT_NODE_NAME: &str = "Node";
79const PROJECT_UNITY_NAME: &str = "Unity";
80const PROJECT_STACK_NAME: &str = "Stack";
81const PROJECT_SBT_NAME: &str = "SBT";
82const PROJECT_MVN_NAME: &str = "Maven";
83const PROJECT_GRADLE_NAME: &str = "Gradle";
84const PROJECT_CMAKE_NAME: &str = "CMake";
85const PROJECT_UNREAL_NAME: &str = "Unreal";
86const PROJECT_JUPYTER_NAME: &str = "Jupyter";
87const PROJECT_PYTHON_NAME: &str = "Python";
88const PROJECT_COMPOSER_NAME: &str = "Composer";
89const PROJECT_PUB_NAME: &str = "Pub";
90const PROJECT_ELIXIR_NAME: &str = "Elixir";
91const PROJECT_SWIFT_NAME: &str = "Swift";
92const PROJECT_ZIG_NAME: &str = "Zig";
93const PROJECT_GODOT_4_NAME: &str = "Godot 4.x";
94const PROJECT_DOTNET_NAME: &str = ".NET";
95
96#[derive(Debug, Clone)]
97pub enum ProjectType {
98    Cargo,
99    Node,
100    Unity,
101    Stack,
102    #[allow(clippy::upper_case_acronyms)]
103    SBT,
104    Maven,
105    Gradle,
106    CMake,
107    Unreal,
108    Jupyter,
109    Python,
110    Composer,
111    Pub,
112    Elixir,
113    Swift,
114    Zig,
115    Godot4,
116    Dotnet,
117}
118
119#[derive(Debug, Clone)]
120pub struct Project {
121    pub project_type: ProjectType,
122    pub path: path::PathBuf,
123}
124
125#[derive(Debug, Clone)]
126pub struct ProjectSize {
127    pub artifact_size: u64,
128    pub non_artifact_size: u64,
129    pub dirs: Vec<(String, u64, bool)>,
130}
131
132impl Project {
133    pub fn artifact_dirs(&self) -> &[&str] {
134        match self.project_type {
135            ProjectType::Cargo => &PROJECT_CARGO_DIRS,
136            ProjectType::Node => &PROJECT_NODE_DIRS,
137            ProjectType::Unity => &PROJECT_UNITY_DIRS,
138            ProjectType::Stack => &PROJECT_STACK_DIRS,
139            ProjectType::SBT => &PROJECT_SBT_DIRS,
140            ProjectType::Maven => &PROJECT_MVN_DIRS,
141            ProjectType::Unreal => &PROJECT_UNREAL_DIRS,
142            ProjectType::Jupyter => &PROJECT_JUPYTER_DIRS,
143            ProjectType::Python => &PROJECT_PYTHON_DIRS,
144            ProjectType::CMake => &PROJECT_CMAKE_DIRS,
145            ProjectType::Composer => &PROJECT_COMPOSER_DIRS,
146            ProjectType::Pub => &PROJECT_PUB_DIRS,
147            ProjectType::Elixir => &PROJECT_ELIXIR_DIRS,
148            ProjectType::Swift => &PROJECT_SWIFT_DIRS,
149            ProjectType::Gradle => &PROJECT_GRADLE_DIRS,
150            ProjectType::Zig => &PROJECT_ZIG_DIRS,
151            ProjectType::Godot4 => &PROJECT_GODOT_4_DIRS,
152            ProjectType::Dotnet => &PROJECT_DOTNET_DIRS,
153        }
154    }
155
156    pub fn name(&self) -> Cow<str> {
157        self.path.to_string_lossy()
158    }
159
160    pub fn size(&self, options: &ScanOptions) -> u64 {
161        self.artifact_dirs()
162            .iter()
163            .copied()
164            .map(|p| dir_size(&self.path.join(p), options))
165            .sum()
166    }
167
168    pub fn last_modified(&self, options: &ScanOptions) -> Result<SystemTime, std::io::Error> {
169        let top_level_modified = fs::metadata(&self.path)?.modified()?;
170        let most_recent_modified = ignore::WalkBuilder::new(&self.path)
171            .follow_links(options.follow_symlinks)
172            .same_file_system(options.same_file_system)
173            .build()
174            .fold(top_level_modified, |acc, e| {
175                if let Ok(e) = e {
176                    if let Ok(e) = e.metadata() {
177                        if let Ok(modified) = e.modified() {
178                            if modified > acc {
179                                return modified;
180                            }
181                        }
182                    }
183                }
184                acc
185            });
186        Ok(most_recent_modified)
187    }
188
189    pub fn size_dirs(&self, options: &ScanOptions) -> ProjectSize {
190        let mut artifact_size = 0;
191        let mut non_artifact_size = 0;
192        let mut dirs = Vec::new();
193
194        let project_root = match fs::read_dir(&self.path) {
195            Err(_) => {
196                return ProjectSize {
197                    artifact_size,
198                    non_artifact_size,
199                    dirs,
200                }
201            }
202            Ok(rd) => rd,
203        };
204
205        for entry in project_root.filter_map(|rd| rd.ok()) {
206            let file_type = match entry.file_type() {
207                Err(_) => continue,
208                Ok(file_type) => file_type,
209            };
210
211            if file_type.is_file() {
212                if let Ok(metadata) = entry.metadata() {
213                    non_artifact_size += metadata.len();
214                }
215                continue;
216            }
217
218            if file_type.is_dir() {
219                let file_name = match entry.file_name().into_string() {
220                    Err(_) => continue,
221                    Ok(file_name) => file_name,
222                };
223                let size = dir_size(&entry.path(), options);
224                let artifact_dir = self.artifact_dirs().contains(&file_name.as_str());
225                if artifact_dir {
226                    artifact_size += size;
227                } else {
228                    non_artifact_size += size;
229                }
230                dirs.push((file_name, size, artifact_dir));
231            }
232        }
233
234        ProjectSize {
235            artifact_size,
236            non_artifact_size,
237            dirs,
238        }
239    }
240
241    pub fn type_name(&self) -> &'static str {
242        match self.project_type {
243            ProjectType::Cargo => PROJECT_CARGO_NAME,
244            ProjectType::Node => PROJECT_NODE_NAME,
245            ProjectType::Unity => PROJECT_UNITY_NAME,
246            ProjectType::Stack => PROJECT_STACK_NAME,
247            ProjectType::SBT => PROJECT_SBT_NAME,
248            ProjectType::Maven => PROJECT_MVN_NAME,
249            ProjectType::Unreal => PROJECT_UNREAL_NAME,
250            ProjectType::Jupyter => PROJECT_JUPYTER_NAME,
251            ProjectType::Python => PROJECT_PYTHON_NAME,
252            ProjectType::CMake => PROJECT_CMAKE_NAME,
253            ProjectType::Composer => PROJECT_COMPOSER_NAME,
254            ProjectType::Pub => PROJECT_PUB_NAME,
255            ProjectType::Elixir => PROJECT_ELIXIR_NAME,
256            ProjectType::Swift => PROJECT_SWIFT_NAME,
257            ProjectType::Gradle => PROJECT_GRADLE_NAME,
258            ProjectType::Zig => PROJECT_ZIG_NAME,
259            ProjectType::Godot4 => PROJECT_GODOT_4_NAME,
260            ProjectType::Dotnet => PROJECT_DOTNET_NAME,
261        }
262    }
263
264    /// Deletes the project's artifact directories and their contents
265    pub fn clean(&self) {
266        for artifact_dir in self
267            .artifact_dirs()
268            .iter()
269            .copied()
270            .map(|ad| self.path.join(ad))
271            .filter(|ad| ad.exists())
272        {
273            if let Err(e) = fs::remove_dir_all(&artifact_dir) {
274                eprintln!("error removing directory {:?}: {:?}", artifact_dir, e);
275            }
276        }
277    }
278}
279
280pub fn print_elapsed(secs: u64) -> String {
281    const MINUTE: u64 = 60;
282    const HOUR: u64 = MINUTE * 60;
283    const DAY: u64 = HOUR * 24;
284    const WEEK: u64 = DAY * 7;
285    const MONTH: u64 = WEEK * 4;
286    const YEAR: u64 = DAY * 365;
287
288    let (unit, fstring) = match secs {
289        secs if secs < MINUTE => (secs as f64, "second"),
290        secs if secs < HOUR * 2 => (secs as f64 / MINUTE as f64, "minute"),
291        secs if secs < DAY * 2 => (secs as f64 / HOUR as f64, "hour"),
292        secs if secs < WEEK * 2 => (secs as f64 / DAY as f64, "day"),
293        secs if secs < MONTH * 2 => (secs as f64 / WEEK as f64, "week"),
294        secs if secs < YEAR * 2 => (secs as f64 / MONTH as f64, "month"),
295        secs => (secs as f64 / YEAR as f64, "year"),
296    };
297
298    let unit = unit.round();
299
300    let plural = if unit == 1.0 { "" } else { "s" };
301
302    format!("{unit:.0} {fstring}{plural} ago")
303}
304
305fn is_hidden(entry: &walkdir::DirEntry) -> bool {
306    entry.file_name().to_string_lossy().starts_with('.')
307}
308
309struct ProjectIter {
310    it: walkdir::IntoIter,
311}
312
313pub enum Red {
314    IOError(::std::io::Error),
315    WalkdirError(walkdir::Error),
316}
317
318impl Iterator for ProjectIter {
319    type Item = Result<Project, Red>;
320
321    fn next(&mut self) -> Option<Self::Item> {
322        loop {
323            let entry: walkdir::DirEntry = match self.it.next() {
324                None => return None,
325                Some(Err(e)) => return Some(Err(Red::WalkdirError(e))),
326                Some(Ok(entry)) => entry,
327            };
328            if !entry.file_type().is_dir() {
329                continue;
330            }
331            if is_hidden(&entry) {
332                self.it.skip_current_dir();
333                continue;
334            }
335            let rd = match entry.path().read_dir() {
336                Err(e) => return Some(Err(Red::IOError(e))),
337                Ok(rd) => rd,
338            };
339            // intentionally ignoring errors while iterating the ReadDir
340            // can't return them because we'll lose the context of where we are
341            for dir_entry in rd
342                .filter_map(|rd| rd.ok())
343                .filter(|de| de.file_type().map(|ft| ft.is_file()).unwrap_or(false))
344                .map(|de| de.file_name())
345            {
346                let file_name = match dir_entry.to_str() {
347                    None => continue,
348                    Some(file_name) => file_name,
349                };
350                let p_type = match file_name {
351                    FILE_CARGO_TOML => Some(ProjectType::Cargo),
352                    FILE_PACKAGE_JSON => Some(ProjectType::Node),
353                    FILE_ASSEMBLY_CSHARP => Some(ProjectType::Unity),
354                    FILE_STACK_HASKELL => Some(ProjectType::Stack),
355                    FILE_SBT_BUILD => Some(ProjectType::SBT),
356                    FILE_MVN_BUILD => Some(ProjectType::Maven),
357                    FILE_CMAKE_BUILD => Some(ProjectType::CMake),
358                    FILE_COMPOSER_JSON => Some(ProjectType::Composer),
359                    FILE_PUBSPEC_YAML => Some(ProjectType::Pub),
360                    FILE_ELIXIR_MIX => Some(ProjectType::Elixir),
361                    FILE_SWIFT_PACKAGE => Some(ProjectType::Swift),
362                    FILE_BUILD_GRADLE => Some(ProjectType::Gradle),
363                    FILE_BUILD_GRADLE_KTS => Some(ProjectType::Gradle),
364                    FILE_BUILD_ZIG => Some(ProjectType::Zig),
365                    FILE_GODOT_4_PROJECT => Some(ProjectType::Godot4),
366                    file_name if file_name.ends_with(FILE_UNREAL_SUFFIX) => {
367                        Some(ProjectType::Unreal)
368                    }
369                    file_name if file_name.ends_with(FILE_JUPYTER_SUFFIX) => {
370                        Some(ProjectType::Jupyter)
371                    }
372                    file_name if file_name.ends_with(FILE_PYTHON_SUFFIX) => {
373                        Some(ProjectType::Python)
374                    }
375                    file_name
376                        if file_name.ends_with(FILE_CSPROJ_SUFFIX)
377                            || file_name.ends_with(FILE_FSPROJ_SUFFIX) =>
378                    {
379                        if dir_contains_file(entry.path(), FILE_GODOT_4_PROJECT) {
380                            Some(ProjectType::Godot4)
381                        } else if dir_contains_file(entry.path(), FILE_ASSEMBLY_CSHARP) {
382                            Some(ProjectType::Unity)
383                        } else {
384                            Some(ProjectType::Dotnet)
385                        }
386                    }
387                    _ => None,
388                };
389                if let Some(project_type) = p_type {
390                    self.it.skip_current_dir();
391                    return Some(Ok(Project {
392                        project_type,
393                        path: entry.path().to_path_buf(),
394                    }));
395                }
396            }
397        }
398    }
399}
400
401fn dir_contains_file(path: &Path, file: &str) -> bool {
402    path.read_dir()
403        .map(|rd| {
404            rd.filter_map(|rd| rd.ok()).any(|de| {
405                de.file_type().is_ok_and(|t| t.is_file()) && de.file_name().to_str() == Some(file)
406            })
407        })
408        .unwrap_or(false)
409}
410
411#[derive(Clone, Debug)]
412pub struct ScanOptions {
413    pub follow_symlinks: bool,
414    pub same_file_system: bool,
415}
416
417fn build_walkdir_iter<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> ProjectIter {
418    ProjectIter {
419        it: walkdir::WalkDir::new(path)
420            .follow_links(options.follow_symlinks)
421            .same_file_system(options.same_file_system)
422            .into_iter(),
423    }
424}
425
426pub fn scan<P: AsRef<path::Path>>(
427    path: &P,
428    options: &ScanOptions,
429) -> impl Iterator<Item = Result<Project, Red>> {
430    build_walkdir_iter(path, options)
431}
432
433// TODO does this need to exist as is??
434pub fn dir_size<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> u64 {
435    build_walkdir_iter(path, options)
436        .it
437        .filter_map(|e| e.ok())
438        .filter(|e| e.file_type().is_file())
439        .filter_map(|e| e.metadata().ok())
440        .map(|e| e.len())
441        .sum()
442}
443
444pub fn pretty_size(size: u64) -> String {
445    const KIBIBYTE: u64 = 1024;
446    const MEBIBYTE: u64 = 1_048_576;
447    const GIBIBYTE: u64 = 1_073_741_824;
448    const TEBIBYTE: u64 = 1_099_511_627_776;
449    const PEBIBYTE: u64 = 1_125_899_906_842_624;
450    const EXBIBYTE: u64 = 1_152_921_504_606_846_976;
451
452    let (size, symbol) = match size {
453        size if size < KIBIBYTE => (size as f64, "B"),
454        size if size < MEBIBYTE => (size as f64 / KIBIBYTE as f64, "KiB"),
455        size if size < GIBIBYTE => (size as f64 / MEBIBYTE as f64, "MiB"),
456        size if size < TEBIBYTE => (size as f64 / GIBIBYTE as f64, "GiB"),
457        size if size < PEBIBYTE => (size as f64 / TEBIBYTE as f64, "TiB"),
458        size if size < EXBIBYTE => (size as f64 / PEBIBYTE as f64, "PiB"),
459        _ => (size as f64 / EXBIBYTE as f64, "EiB"),
460    };
461
462    format!("{:.1}{}", size, symbol)
463}
464
465pub fn clean(project_path: &str) -> Result<(), Box<dyn error::Error>> {
466    let project = fs::read_dir(project_path)?
467        .filter_map(|rd| rd.ok())
468        .find_map(|dir_entry| {
469            let file_name = dir_entry.file_name().into_string().ok()?;
470            let p_type = match file_name.as_str() {
471                FILE_CARGO_TOML => Some(ProjectType::Cargo),
472                FILE_PACKAGE_JSON => Some(ProjectType::Node),
473                FILE_ASSEMBLY_CSHARP => Some(ProjectType::Unity),
474                FILE_STACK_HASKELL => Some(ProjectType::Stack),
475                FILE_SBT_BUILD => Some(ProjectType::SBT),
476                FILE_MVN_BUILD => Some(ProjectType::Maven),
477                FILE_CMAKE_BUILD => Some(ProjectType::CMake),
478                FILE_COMPOSER_JSON => Some(ProjectType::Composer),
479                FILE_PUBSPEC_YAML => Some(ProjectType::Pub),
480                FILE_ELIXIR_MIX => Some(ProjectType::Elixir),
481                FILE_SWIFT_PACKAGE => Some(ProjectType::Swift),
482                FILE_BUILD_ZIG => Some(ProjectType::Zig),
483                FILE_GODOT_4_PROJECT => Some(ProjectType::Godot4),
484                _ => None,
485            };
486            if let Some(project_type) = p_type {
487                return Some(Project {
488                    project_type,
489                    path: project_path.into(),
490                });
491            }
492            None
493        });
494
495    if let Some(project) = project {
496        for artifact_dir in project
497            .artifact_dirs()
498            .iter()
499            .copied()
500            .map(|ad| path::PathBuf::from(project_path).join(ad))
501            .filter(|ad| ad.exists())
502        {
503            if let Err(e) = fs::remove_dir_all(&artifact_dir) {
504                eprintln!("error removing directory {:?}: {:?}", artifact_dir, e);
505            }
506        }
507    }
508
509    Ok(())
510}
511pub fn path_canonicalise(
512    base: &path::Path,
513    tail: path::PathBuf,
514) -> Result<path::PathBuf, Box<dyn Error>> {
515    if tail.is_absolute() {
516        Ok(tail)
517    } else {
518        Ok(base.join(tail).canonicalize()?)
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::print_elapsed;
525
526    #[test]
527    fn elapsed() {
528        assert_eq!(print_elapsed(0), "0 seconds ago");
529        assert_eq!(print_elapsed(1), "1 second ago");
530        assert_eq!(print_elapsed(2), "2 seconds ago");
531        assert_eq!(print_elapsed(59), "59 seconds ago");
532        assert_eq!(print_elapsed(60), "1 minute ago");
533        assert_eq!(print_elapsed(61), "1 minute ago");
534        assert_eq!(print_elapsed(119), "2 minutes ago");
535        assert_eq!(print_elapsed(120), "2 minutes ago");
536        assert_eq!(print_elapsed(121), "2 minutes ago");
537        assert_eq!(print_elapsed(3599), "60 minutes ago");
538        assert_eq!(print_elapsed(3600), "60 minutes ago");
539        assert_eq!(print_elapsed(3601), "60 minutes ago");
540        assert_eq!(print_elapsed(7199), "120 minutes ago");
541        assert_eq!(print_elapsed(7200), "2 hours ago");
542        assert_eq!(print_elapsed(7201), "2 hours ago");
543        assert_eq!(print_elapsed(86399), "24 hours ago");
544        assert_eq!(print_elapsed(86400), "24 hours ago");
545        assert_eq!(print_elapsed(86401), "24 hours ago");
546        assert_eq!(print_elapsed(172799), "48 hours ago");
547        assert_eq!(print_elapsed(172800), "2 days ago");
548        assert_eq!(print_elapsed(172801), "2 days ago");
549        assert_eq!(print_elapsed(604799), "7 days ago");
550        assert_eq!(print_elapsed(604800), "7 days ago");
551        assert_eq!(print_elapsed(604801), "7 days ago");
552        assert_eq!(print_elapsed(1209599), "14 days ago");
553        assert_eq!(print_elapsed(1209600), "2 weeks ago");
554        assert_eq!(print_elapsed(1209601), "2 weeks ago");
555        assert_eq!(print_elapsed(2419199), "4 weeks ago");
556        assert_eq!(print_elapsed(2419200), "4 weeks ago");
557        assert_eq!(print_elapsed(2419201), "4 weeks ago");
558        assert_eq!(print_elapsed(2419200 * 2), "2 months ago");
559        assert_eq!(print_elapsed(2419200 * 3), "3 months ago");
560        assert_eq!(print_elapsed(2419200 * 12), "12 months ago");
561        assert_eq!(print_elapsed(2419200 * 25), "25 months ago");
562        assert_eq!(print_elapsed(2419200 * 48), "4 years ago");
563    }
564}