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 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 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
505pub 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}