Skip to main content

clean_dev_dirs/
filtering.rs

1//! Project filtering functionality.
2//!
3//! This module provides functions for filtering projects based on various criteria
4//! such as size and modification time.
5
6use anyhow::Result;
7use chrono::{DateTime, Local};
8use rayon::prelude::*;
9use std::cmp::Reverse;
10use std::fs;
11use std::time::SystemTime;
12
13use crate::config::filter::SortCriteria;
14use crate::config::{FilterOptions, SortOptions};
15use crate::project::{Project, ProjectType};
16use crate::utils::parse_size;
17
18/// Filter projects based on size and modification time criteria.
19///
20/// This function applies parallel filtering to remove projects that don't meet
21/// the specified criteria:
22/// - Projects smaller than the minimum size threshold
23/// - Projects modified more recently than the specified number of days
24///
25/// # Arguments
26///
27/// * `projects` - Vector of projects to filter
28/// * `filter_opts` - Filtering options containing size and time criteria
29///
30/// # Returns
31///
32/// - `Ok(Vec<Project>)` - Filtered list of projects that meet all criteria
33/// - `Err(anyhow::Error)` - If size parsing fails, or file system errors occur
34///
35/// # Errors
36///
37/// This function can return errors if:
38/// - The size string in `filter_opts.keep_size` cannot be parsed (invalid format)
39/// - Size value overflow occurs during parsing
40///
41/// # Examples
42///
43/// ```no_run
44/// # use clean_dev_dirs::{filtering::filter_projects, config::FilterOptions, project::Project};
45/// # use anyhow::Result;
46/// # fn example(projects: Vec<Project>) -> Result<()> {
47/// let filter_opts = FilterOptions {
48///     keep_size: "100MB".to_string(),
49///     keep_days: 30,
50/// };
51/// let filtered = filter_projects(projects, &filter_opts)?;
52/// # Ok(())
53/// # }
54/// ```
55pub fn filter_projects(
56    projects: Vec<Project>,
57    filter_opts: &FilterOptions,
58) -> Result<Vec<Project>> {
59    let keep_size_bytes = parse_size(&filter_opts.keep_size)?;
60    let keep_days = filter_opts.keep_days;
61
62    Ok(projects
63        .into_par_iter()
64        .filter(|project| meets_size_criteria(project, keep_size_bytes))
65        .filter(|project| meets_time_criteria(project, keep_days))
66        .collect())
67}
68
69/// Check if a project meets the size criteria.
70fn meets_size_criteria(project: &Project, min_size: u64) -> bool {
71    project.total_size() >= min_size
72}
73
74/// Check if a project meets the time criteria.
75fn meets_time_criteria(project: &Project, keep_days: u32) -> bool {
76    if keep_days == 0 {
77        return true;
78    }
79
80    is_project_old_enough(project, keep_days)
81}
82
83/// Check if a project is old enough based on its modification time.
84fn is_project_old_enough(project: &Project, keep_days: u32) -> bool {
85    let Some(primary) = project.build_arts.first() else {
86        return true;
87    };
88    let Result::Ok(metadata) = fs::metadata(&primary.path) else {
89        return true; // If we can't read metadata, don't filter it out
90    };
91
92    let Result::Ok(modified) = metadata.modified() else {
93        return true; // If we can't read modification time, don't filter it out
94    };
95
96    let modified_time: DateTime<Local> = modified.into();
97    let cutoff_time = Local::now() - chrono::Duration::days(i64::from(keep_days));
98
99    modified_time <= cutoff_time
100}
101
102/// Sort projects in place according to the given sorting options.
103///
104/// When `sort_opts.criteria` is `None`, the list is left in its current order.
105/// Each criterion has a natural default direction:
106/// - `Size`: largest first (descending)
107/// - `Age`: oldest first (ascending)
108/// - `Name`: alphabetical, case-insensitive (ascending)
109/// - `Type`: grouped by type name alphabetically
110///
111/// Setting `sort_opts.reverse` to `true` flips the resulting order.
112///
113/// For the `Age` criterion a Schwartzian transform is used to avoid
114/// repeated filesystem calls inside the comparator.
115///
116/// # Arguments
117///
118/// * `projects` - Mutable reference to the vector of projects to sort
119/// * `sort_opts` - Sorting options specifying criterion and direction
120///
121/// # Examples
122///
123/// ```no_run
124/// # use clean_dev_dirs::{filtering::sort_projects, config::{SortOptions, SortCriteria}};
125/// # use clean_dev_dirs::project::Project;
126/// # fn example(mut projects: Vec<Project>) {
127/// let sort_opts = SortOptions {
128///     criteria: Some(SortCriteria::Size),
129///     reverse: false,
130/// };
131/// sort_projects(&mut projects, &sort_opts);
132/// # }
133/// ```
134pub fn sort_projects(projects: &mut Vec<Project>, sort_opts: &SortOptions) {
135    let Some(criteria) = sort_opts.criteria else {
136        return;
137    };
138
139    match criteria {
140        SortCriteria::Size => {
141            projects.sort_by_key(|p| Reverse(p.total_size()));
142        }
143        SortCriteria::Age => {
144            sort_by_age(projects);
145        }
146        SortCriteria::Name => {
147            projects.sort_by(|a, b| {
148                let name_a = a.name.as_deref().unwrap_or("");
149                let name_b = b.name.as_deref().unwrap_or("");
150                name_a.to_lowercase().cmp(&name_b.to_lowercase())
151            });
152        }
153        SortCriteria::Type => {
154            projects.sort_by(|a, b| type_order(&a.kind).cmp(&type_order(&b.kind)));
155        }
156    }
157
158    if sort_opts.reverse {
159        projects.reverse();
160    }
161}
162
163/// Sort projects by build artifacts modification time (oldest first).
164///
165/// Uses a Schwartzian transform: each project is paired with its modification
166/// time (fetched once), sorted, then the timestamps are discarded.
167fn sort_by_age(projects: &mut Vec<Project>) {
168    let mut decorated: Vec<(Project, SystemTime)> = projects
169        .drain(..)
170        .map(|p| {
171            let mtime = p
172                .build_arts
173                .first()
174                .and_then(|a| fs::metadata(&a.path).ok())
175                .and_then(|m| m.modified().ok())
176                .unwrap_or(SystemTime::UNIX_EPOCH);
177            (p, mtime)
178        })
179        .collect();
180
181    decorated.sort_by(|a, b| a.1.cmp(&b.1));
182
183    projects.extend(decorated.into_iter().map(|(p, _)| p));
184}
185
186/// Map a `ProjectType` to an ordering index for type-based sorting.
187///
188/// Types are ordered alphabetically by their display name:
189/// C/C++, Dart, Deno, .NET, Elixir, Go, Haskell, Java, Node, PHP, Python, Ruby, Rust, Scala, Swift, Zig
190const fn type_order(kind: &ProjectType) -> u8 {
191    match kind {
192        ProjectType::Cpp => 0,
193        ProjectType::Dart => 1,
194        ProjectType::Deno => 2,
195        ProjectType::DotNet => 3,
196        ProjectType::Elixir => 4,
197        ProjectType::Go => 5,
198        ProjectType::Haskell => 6,
199        ProjectType::Java => 7,
200        ProjectType::Node => 8,
201        ProjectType::Php => 9,
202        ProjectType::Python => 10,
203        ProjectType::Ruby => 11,
204        ProjectType::Rust => 12,
205        ProjectType::Scala => 13,
206        ProjectType::Swift => 14,
207        ProjectType::Zig => 15,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::project::{BuildArtifacts, Project, ProjectType};
215    use std::path::PathBuf;
216
217    /// Helper function to create a test project
218    fn create_test_project(
219        kind: ProjectType,
220        root_path: &str,
221        build_path: &str,
222        size: u64,
223        name: Option<String>,
224    ) -> Project {
225        Project::new(
226            kind,
227            PathBuf::from(root_path),
228            vec![BuildArtifacts {
229                path: PathBuf::from(build_path),
230                size,
231            }],
232            name,
233        )
234    }
235
236    #[test]
237    fn test_meets_size_criteria() {
238        let project = create_test_project(
239            ProjectType::Rust,
240            "/test",
241            "/test/target",
242            1_000_000, // 1MB
243            Some("test".to_string()),
244        );
245
246        assert!(meets_size_criteria(&project, 500_000)); // 0.5MB - should pass
247        assert!(meets_size_criteria(&project, 1_000_000)); // Exactly 1MB - should pass
248        assert!(!meets_size_criteria(&project, 2_000_000)); // 2MB - should fail
249    }
250
251    #[test]
252    fn test_meets_time_criteria_disabled() {
253        let project = create_test_project(
254            ProjectType::Rust,
255            "/test",
256            "/test/target",
257            1_000_000,
258            Some("test".to_string()),
259        );
260
261        // When keep_days is 0, should always return true
262        assert!(meets_time_criteria(&project, 0));
263    }
264
265    // ── Sorting tests ───────────────────────────────────────────────────
266
267    #[test]
268    fn test_sort_by_size_descending() {
269        let mut projects = vec![
270            create_test_project(
271                ProjectType::Rust,
272                "/a",
273                "/a/target",
274                100,
275                Some("small".into()),
276            ),
277            create_test_project(
278                ProjectType::Rust,
279                "/b",
280                "/b/target",
281                300,
282                Some("large".into()),
283            ),
284            create_test_project(
285                ProjectType::Rust,
286                "/c",
287                "/c/target",
288                200,
289                Some("medium".into()),
290            ),
291        ];
292
293        let sort_opts = SortOptions {
294            criteria: Some(SortCriteria::Size),
295            reverse: false,
296        };
297        sort_projects(&mut projects, &sort_opts);
298
299        assert_eq!(projects[0].total_size(), 300);
300        assert_eq!(projects[1].total_size(), 200);
301        assert_eq!(projects[2].total_size(), 100);
302    }
303
304    #[test]
305    fn test_sort_by_size_reversed() {
306        let mut projects = vec![
307            create_test_project(
308                ProjectType::Rust,
309                "/a",
310                "/a/target",
311                100,
312                Some("small".into()),
313            ),
314            create_test_project(
315                ProjectType::Rust,
316                "/b",
317                "/b/target",
318                300,
319                Some("large".into()),
320            ),
321            create_test_project(
322                ProjectType::Rust,
323                "/c",
324                "/c/target",
325                200,
326                Some("medium".into()),
327            ),
328        ];
329
330        let sort_opts = SortOptions {
331            criteria: Some(SortCriteria::Size),
332            reverse: true,
333        };
334        sort_projects(&mut projects, &sort_opts);
335
336        assert_eq!(projects[0].total_size(), 100);
337        assert_eq!(projects[1].total_size(), 200);
338        assert_eq!(projects[2].total_size(), 300);
339    }
340
341    #[test]
342    fn test_sort_by_name_alphabetical() {
343        let mut projects = vec![
344            create_test_project(
345                ProjectType::Rust,
346                "/c",
347                "/c/target",
348                100,
349                Some("charlie".into()),
350            ),
351            create_test_project(
352                ProjectType::Rust,
353                "/a",
354                "/a/target",
355                100,
356                Some("alpha".into()),
357            ),
358            create_test_project(
359                ProjectType::Rust,
360                "/b",
361                "/b/target",
362                100,
363                Some("bravo".into()),
364            ),
365        ];
366
367        let sort_opts = SortOptions {
368            criteria: Some(SortCriteria::Name),
369            reverse: false,
370        };
371        sort_projects(&mut projects, &sort_opts);
372
373        assert_eq!(projects[0].name.as_deref(), Some("alpha"));
374        assert_eq!(projects[1].name.as_deref(), Some("bravo"));
375        assert_eq!(projects[2].name.as_deref(), Some("charlie"));
376    }
377
378    #[test]
379    fn test_sort_by_name_case_insensitive() {
380        let mut projects = vec![
381            create_test_project(
382                ProjectType::Rust,
383                "/c",
384                "/c/target",
385                100,
386                Some("Charlie".into()),
387            ),
388            create_test_project(
389                ProjectType::Rust,
390                "/a",
391                "/a/target",
392                100,
393                Some("alpha".into()),
394            ),
395            create_test_project(
396                ProjectType::Rust,
397                "/b",
398                "/b/target",
399                100,
400                Some("Bravo".into()),
401            ),
402        ];
403
404        let sort_opts = SortOptions {
405            criteria: Some(SortCriteria::Name),
406            reverse: false,
407        };
408        sort_projects(&mut projects, &sort_opts);
409
410        assert_eq!(projects[0].name.as_deref(), Some("alpha"));
411        assert_eq!(projects[1].name.as_deref(), Some("Bravo"));
412        assert_eq!(projects[2].name.as_deref(), Some("Charlie"));
413    }
414
415    #[test]
416    fn test_sort_by_name_none_names_first() {
417        let mut projects = vec![
418            create_test_project(
419                ProjectType::Rust,
420                "/c",
421                "/c/target",
422                100,
423                Some("charlie".into()),
424            ),
425            create_test_project(ProjectType::Rust, "/a", "/a/target", 100, None),
426            create_test_project(
427                ProjectType::Rust,
428                "/b",
429                "/b/target",
430                100,
431                Some("alpha".into()),
432            ),
433        ];
434
435        let sort_opts = SortOptions {
436            criteria: Some(SortCriteria::Name),
437            reverse: false,
438        };
439        sort_projects(&mut projects, &sort_opts);
440
441        // None name sorts as "" which comes before any alphabetical name
442        assert_eq!(projects[0].name.as_deref(), None);
443        assert_eq!(projects[1].name.as_deref(), Some("alpha"));
444        assert_eq!(projects[2].name.as_deref(), Some("charlie"));
445    }
446
447    #[test]
448    fn test_sort_by_type() {
449        let mut projects = vec![
450            create_test_project(
451                ProjectType::Rust,
452                "/r",
453                "/r/target",
454                100,
455                Some("rust-proj".into()),
456            ),
457            create_test_project(
458                ProjectType::Go,
459                "/g",
460                "/g/vendor",
461                100,
462                Some("go-proj".into()),
463            ),
464            create_test_project(
465                ProjectType::Python,
466                "/p",
467                "/p/__pycache__",
468                100,
469                Some("py-proj".into()),
470            ),
471            create_test_project(
472                ProjectType::Node,
473                "/n",
474                "/n/node_modules",
475                100,
476                Some("node-proj".into()),
477            ),
478            create_test_project(
479                ProjectType::Java,
480                "/j",
481                "/j/target",
482                100,
483                Some("java-proj".into()),
484            ),
485            create_test_project(
486                ProjectType::Cpp,
487                "/c",
488                "/c/build",
489                100,
490                Some("cpp-proj".into()),
491            ),
492            create_test_project(
493                ProjectType::Swift,
494                "/s",
495                "/s/.build",
496                100,
497                Some("swift-proj".into()),
498            ),
499            create_test_project(
500                ProjectType::DotNet,
501                "/d",
502                "/d/obj",
503                100,
504                Some("dotnet-proj".into()),
505            ),
506            create_test_project(
507                ProjectType::Ruby,
508                "/rb",
509                "/rb/vendor/bundle",
510                100,
511                Some("ruby-proj".into()),
512            ),
513            create_test_project(
514                ProjectType::Elixir,
515                "/ex",
516                "/ex/_build",
517                100,
518                Some("elixir-proj".into()),
519            ),
520            create_test_project(
521                ProjectType::Deno,
522                "/dn",
523                "/dn/vendor",
524                100,
525                Some("deno-proj".into()),
526            ),
527        ];
528
529        let sort_opts = SortOptions {
530            criteria: Some(SortCriteria::Type),
531            reverse: false,
532        };
533        sort_projects(&mut projects, &sort_opts);
534
535        assert_eq!(projects[0].kind, ProjectType::Cpp);
536        assert_eq!(projects[1].kind, ProjectType::Deno);
537        assert_eq!(projects[2].kind, ProjectType::DotNet);
538        assert_eq!(projects[3].kind, ProjectType::Elixir);
539        assert_eq!(projects[4].kind, ProjectType::Go);
540        assert_eq!(projects[5].kind, ProjectType::Java);
541        assert_eq!(projects[6].kind, ProjectType::Node);
542        assert_eq!(projects[7].kind, ProjectType::Python);
543        assert_eq!(projects[8].kind, ProjectType::Ruby);
544        assert_eq!(projects[9].kind, ProjectType::Rust);
545        assert_eq!(projects[10].kind, ProjectType::Swift);
546    }
547
548    #[test]
549    fn test_sort_by_type_reversed() {
550        let mut projects = vec![
551            create_test_project(
552                ProjectType::Go,
553                "/g",
554                "/g/vendor",
555                100,
556                Some("go-proj".into()),
557            ),
558            create_test_project(
559                ProjectType::Rust,
560                "/r",
561                "/r/target",
562                100,
563                Some("rust-proj".into()),
564            ),
565            create_test_project(
566                ProjectType::Node,
567                "/n",
568                "/n/node_modules",
569                100,
570                Some("node-proj".into()),
571            ),
572        ];
573
574        let sort_opts = SortOptions {
575            criteria: Some(SortCriteria::Type),
576            reverse: true,
577        };
578        sort_projects(&mut projects, &sort_opts);
579
580        assert_eq!(projects[0].kind, ProjectType::Rust);
581        assert_eq!(projects[1].kind, ProjectType::Node);
582        assert_eq!(projects[2].kind, ProjectType::Go);
583    }
584
585    #[test]
586    fn test_sort_none_criteria_preserves_order() {
587        let mut projects = vec![
588            create_test_project(
589                ProjectType::Rust,
590                "/c",
591                "/c/target",
592                100,
593                Some("charlie".into()),
594            ),
595            create_test_project(
596                ProjectType::Rust,
597                "/a",
598                "/a/target",
599                300,
600                Some("alpha".into()),
601            ),
602            create_test_project(
603                ProjectType::Rust,
604                "/b",
605                "/b/target",
606                200,
607                Some("bravo".into()),
608            ),
609        ];
610
611        let sort_opts = SortOptions {
612            criteria: None,
613            reverse: false,
614        };
615        sort_projects(&mut projects, &sort_opts);
616
617        // Order should be unchanged
618        assert_eq!(projects[0].name.as_deref(), Some("charlie"));
619        assert_eq!(projects[1].name.as_deref(), Some("alpha"));
620        assert_eq!(projects[2].name.as_deref(), Some("bravo"));
621    }
622
623    #[test]
624    fn test_sort_empty_list() {
625        let mut projects: Vec<Project> = vec![];
626
627        let sort_opts = SortOptions {
628            criteria: Some(SortCriteria::Size),
629            reverse: false,
630        };
631        sort_projects(&mut projects, &sort_opts);
632
633        assert!(projects.is_empty());
634    }
635
636    #[test]
637    fn test_sort_single_element() {
638        let mut projects = vec![create_test_project(
639            ProjectType::Rust,
640            "/a",
641            "/a/target",
642            100,
643            Some("only".into()),
644        )];
645
646        let sort_opts = SortOptions {
647            criteria: Some(SortCriteria::Name),
648            reverse: false,
649        };
650        sort_projects(&mut projects, &sort_opts);
651
652        assert_eq!(projects.len(), 1);
653        assert_eq!(projects[0].name.as_deref(), Some("only"));
654    }
655
656    #[test]
657    fn test_type_order_values() {
658        assert!(type_order(&ProjectType::Cpp) < type_order(&ProjectType::Deno));
659        assert!(type_order(&ProjectType::Deno) < type_order(&ProjectType::DotNet));
660        assert!(type_order(&ProjectType::DotNet) < type_order(&ProjectType::Elixir));
661        assert!(type_order(&ProjectType::Elixir) < type_order(&ProjectType::Go));
662        assert!(type_order(&ProjectType::Go) < type_order(&ProjectType::Java));
663        assert!(type_order(&ProjectType::Java) < type_order(&ProjectType::Node));
664        assert!(type_order(&ProjectType::Node) < type_order(&ProjectType::Python));
665        assert!(type_order(&ProjectType::Python) < type_order(&ProjectType::Ruby));
666        assert!(type_order(&ProjectType::Ruby) < type_order(&ProjectType::Rust));
667        assert!(type_order(&ProjectType::Rust) < type_order(&ProjectType::Swift));
668    }
669}