rmrfrs_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_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    /// Deletes the project's artifact directories and their contents
277    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            // intentionally ignoring errors while iterating the ReadDir
352            // can't return them because we'll lose the context of where we are
353            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
447// TODO does this need to exist as is??
448pub 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}