Skip to main content

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