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