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++, Deno, .NET, Elixir, Go, Java, Node, Python, Ruby, Rust, Swift
190const fn type_order(kind: &ProjectType) -> u8 {
191    match kind {
192        ProjectType::Cpp => 0,
193        ProjectType::Deno => 1,
194        ProjectType::DotNet => 2,
195        ProjectType::Elixir => 3,
196        ProjectType::Go => 4,
197        ProjectType::Java => 5,
198        ProjectType::Node => 6,
199        ProjectType::Python => 7,
200        ProjectType::Ruby => 8,
201        ProjectType::Rust => 9,
202        ProjectType::Swift => 10,
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::project::{BuildArtifacts, Project, ProjectType};
210    use std::path::PathBuf;
211
212    /// Helper function to create a test project
213    fn create_test_project(
214        kind: ProjectType,
215        root_path: &str,
216        build_path: &str,
217        size: u64,
218        name: Option<String>,
219    ) -> Project {
220        Project::new(
221            kind,
222            PathBuf::from(root_path),
223            vec![BuildArtifacts {
224                path: PathBuf::from(build_path),
225                size,
226            }],
227            name,
228        )
229    }
230
231    #[test]
232    fn test_meets_size_criteria() {
233        let project = create_test_project(
234            ProjectType::Rust,
235            "/test",
236            "/test/target",
237            1_000_000, // 1MB
238            Some("test".to_string()),
239        );
240
241        assert!(meets_size_criteria(&project, 500_000)); // 0.5MB - should pass
242        assert!(meets_size_criteria(&project, 1_000_000)); // Exactly 1MB - should pass
243        assert!(!meets_size_criteria(&project, 2_000_000)); // 2MB - should fail
244    }
245
246    #[test]
247    fn test_meets_time_criteria_disabled() {
248        let project = create_test_project(
249            ProjectType::Rust,
250            "/test",
251            "/test/target",
252            1_000_000,
253            Some("test".to_string()),
254        );
255
256        // When keep_days is 0, should always return true
257        assert!(meets_time_criteria(&project, 0));
258    }
259
260    // ── Sorting tests ───────────────────────────────────────────────────
261
262    #[test]
263    fn test_sort_by_size_descending() {
264        let mut projects = vec![
265            create_test_project(
266                ProjectType::Rust,
267                "/a",
268                "/a/target",
269                100,
270                Some("small".into()),
271            ),
272            create_test_project(
273                ProjectType::Rust,
274                "/b",
275                "/b/target",
276                300,
277                Some("large".into()),
278            ),
279            create_test_project(
280                ProjectType::Rust,
281                "/c",
282                "/c/target",
283                200,
284                Some("medium".into()),
285            ),
286        ];
287
288        let sort_opts = SortOptions {
289            criteria: Some(SortCriteria::Size),
290            reverse: false,
291        };
292        sort_projects(&mut projects, &sort_opts);
293
294        assert_eq!(projects[0].total_size(), 300);
295        assert_eq!(projects[1].total_size(), 200);
296        assert_eq!(projects[2].total_size(), 100);
297    }
298
299    #[test]
300    fn test_sort_by_size_reversed() {
301        let mut projects = vec![
302            create_test_project(
303                ProjectType::Rust,
304                "/a",
305                "/a/target",
306                100,
307                Some("small".into()),
308            ),
309            create_test_project(
310                ProjectType::Rust,
311                "/b",
312                "/b/target",
313                300,
314                Some("large".into()),
315            ),
316            create_test_project(
317                ProjectType::Rust,
318                "/c",
319                "/c/target",
320                200,
321                Some("medium".into()),
322            ),
323        ];
324
325        let sort_opts = SortOptions {
326            criteria: Some(SortCriteria::Size),
327            reverse: true,
328        };
329        sort_projects(&mut projects, &sort_opts);
330
331        assert_eq!(projects[0].total_size(), 100);
332        assert_eq!(projects[1].total_size(), 200);
333        assert_eq!(projects[2].total_size(), 300);
334    }
335
336    #[test]
337    fn test_sort_by_name_alphabetical() {
338        let mut projects = vec![
339            create_test_project(
340                ProjectType::Rust,
341                "/c",
342                "/c/target",
343                100,
344                Some("charlie".into()),
345            ),
346            create_test_project(
347                ProjectType::Rust,
348                "/a",
349                "/a/target",
350                100,
351                Some("alpha".into()),
352            ),
353            create_test_project(
354                ProjectType::Rust,
355                "/b",
356                "/b/target",
357                100,
358                Some("bravo".into()),
359            ),
360        ];
361
362        let sort_opts = SortOptions {
363            criteria: Some(SortCriteria::Name),
364            reverse: false,
365        };
366        sort_projects(&mut projects, &sort_opts);
367
368        assert_eq!(projects[0].name.as_deref(), Some("alpha"));
369        assert_eq!(projects[1].name.as_deref(), Some("bravo"));
370        assert_eq!(projects[2].name.as_deref(), Some("charlie"));
371    }
372
373    #[test]
374    fn test_sort_by_name_case_insensitive() {
375        let mut projects = vec![
376            create_test_project(
377                ProjectType::Rust,
378                "/c",
379                "/c/target",
380                100,
381                Some("Charlie".into()),
382            ),
383            create_test_project(
384                ProjectType::Rust,
385                "/a",
386                "/a/target",
387                100,
388                Some("alpha".into()),
389            ),
390            create_test_project(
391                ProjectType::Rust,
392                "/b",
393                "/b/target",
394                100,
395                Some("Bravo".into()),
396            ),
397        ];
398
399        let sort_opts = SortOptions {
400            criteria: Some(SortCriteria::Name),
401            reverse: false,
402        };
403        sort_projects(&mut projects, &sort_opts);
404
405        assert_eq!(projects[0].name.as_deref(), Some("alpha"));
406        assert_eq!(projects[1].name.as_deref(), Some("Bravo"));
407        assert_eq!(projects[2].name.as_deref(), Some("Charlie"));
408    }
409
410    #[test]
411    fn test_sort_by_name_none_names_first() {
412        let mut projects = vec![
413            create_test_project(
414                ProjectType::Rust,
415                "/c",
416                "/c/target",
417                100,
418                Some("charlie".into()),
419            ),
420            create_test_project(ProjectType::Rust, "/a", "/a/target", 100, None),
421            create_test_project(
422                ProjectType::Rust,
423                "/b",
424                "/b/target",
425                100,
426                Some("alpha".into()),
427            ),
428        ];
429
430        let sort_opts = SortOptions {
431            criteria: Some(SortCriteria::Name),
432            reverse: false,
433        };
434        sort_projects(&mut projects, &sort_opts);
435
436        // None name sorts as "" which comes before any alphabetical name
437        assert_eq!(projects[0].name.as_deref(), None);
438        assert_eq!(projects[1].name.as_deref(), Some("alpha"));
439        assert_eq!(projects[2].name.as_deref(), Some("charlie"));
440    }
441
442    #[test]
443    fn test_sort_by_type() {
444        let mut projects = vec![
445            create_test_project(
446                ProjectType::Rust,
447                "/r",
448                "/r/target",
449                100,
450                Some("rust-proj".into()),
451            ),
452            create_test_project(
453                ProjectType::Go,
454                "/g",
455                "/g/vendor",
456                100,
457                Some("go-proj".into()),
458            ),
459            create_test_project(
460                ProjectType::Python,
461                "/p",
462                "/p/__pycache__",
463                100,
464                Some("py-proj".into()),
465            ),
466            create_test_project(
467                ProjectType::Node,
468                "/n",
469                "/n/node_modules",
470                100,
471                Some("node-proj".into()),
472            ),
473            create_test_project(
474                ProjectType::Java,
475                "/j",
476                "/j/target",
477                100,
478                Some("java-proj".into()),
479            ),
480            create_test_project(
481                ProjectType::Cpp,
482                "/c",
483                "/c/build",
484                100,
485                Some("cpp-proj".into()),
486            ),
487            create_test_project(
488                ProjectType::Swift,
489                "/s",
490                "/s/.build",
491                100,
492                Some("swift-proj".into()),
493            ),
494            create_test_project(
495                ProjectType::DotNet,
496                "/d",
497                "/d/obj",
498                100,
499                Some("dotnet-proj".into()),
500            ),
501            create_test_project(
502                ProjectType::Ruby,
503                "/rb",
504                "/rb/vendor/bundle",
505                100,
506                Some("ruby-proj".into()),
507            ),
508            create_test_project(
509                ProjectType::Elixir,
510                "/ex",
511                "/ex/_build",
512                100,
513                Some("elixir-proj".into()),
514            ),
515            create_test_project(
516                ProjectType::Deno,
517                "/dn",
518                "/dn/vendor",
519                100,
520                Some("deno-proj".into()),
521            ),
522        ];
523
524        let sort_opts = SortOptions {
525            criteria: Some(SortCriteria::Type),
526            reverse: false,
527        };
528        sort_projects(&mut projects, &sort_opts);
529
530        assert_eq!(projects[0].kind, ProjectType::Cpp);
531        assert_eq!(projects[1].kind, ProjectType::Deno);
532        assert_eq!(projects[2].kind, ProjectType::DotNet);
533        assert_eq!(projects[3].kind, ProjectType::Elixir);
534        assert_eq!(projects[4].kind, ProjectType::Go);
535        assert_eq!(projects[5].kind, ProjectType::Java);
536        assert_eq!(projects[6].kind, ProjectType::Node);
537        assert_eq!(projects[7].kind, ProjectType::Python);
538        assert_eq!(projects[8].kind, ProjectType::Ruby);
539        assert_eq!(projects[9].kind, ProjectType::Rust);
540        assert_eq!(projects[10].kind, ProjectType::Swift);
541    }
542
543    #[test]
544    fn test_sort_by_type_reversed() {
545        let mut projects = vec![
546            create_test_project(
547                ProjectType::Go,
548                "/g",
549                "/g/vendor",
550                100,
551                Some("go-proj".into()),
552            ),
553            create_test_project(
554                ProjectType::Rust,
555                "/r",
556                "/r/target",
557                100,
558                Some("rust-proj".into()),
559            ),
560            create_test_project(
561                ProjectType::Node,
562                "/n",
563                "/n/node_modules",
564                100,
565                Some("node-proj".into()),
566            ),
567        ];
568
569        let sort_opts = SortOptions {
570            criteria: Some(SortCriteria::Type),
571            reverse: true,
572        };
573        sort_projects(&mut projects, &sort_opts);
574
575        assert_eq!(projects[0].kind, ProjectType::Rust);
576        assert_eq!(projects[1].kind, ProjectType::Node);
577        assert_eq!(projects[2].kind, ProjectType::Go);
578    }
579
580    #[test]
581    fn test_sort_none_criteria_preserves_order() {
582        let mut projects = vec![
583            create_test_project(
584                ProjectType::Rust,
585                "/c",
586                "/c/target",
587                100,
588                Some("charlie".into()),
589            ),
590            create_test_project(
591                ProjectType::Rust,
592                "/a",
593                "/a/target",
594                300,
595                Some("alpha".into()),
596            ),
597            create_test_project(
598                ProjectType::Rust,
599                "/b",
600                "/b/target",
601                200,
602                Some("bravo".into()),
603            ),
604        ];
605
606        let sort_opts = SortOptions {
607            criteria: None,
608            reverse: false,
609        };
610        sort_projects(&mut projects, &sort_opts);
611
612        // Order should be unchanged
613        assert_eq!(projects[0].name.as_deref(), Some("charlie"));
614        assert_eq!(projects[1].name.as_deref(), Some("alpha"));
615        assert_eq!(projects[2].name.as_deref(), Some("bravo"));
616    }
617
618    #[test]
619    fn test_sort_empty_list() {
620        let mut projects: Vec<Project> = vec![];
621
622        let sort_opts = SortOptions {
623            criteria: Some(SortCriteria::Size),
624            reverse: false,
625        };
626        sort_projects(&mut projects, &sort_opts);
627
628        assert!(projects.is_empty());
629    }
630
631    #[test]
632    fn test_sort_single_element() {
633        let mut projects = vec![create_test_project(
634            ProjectType::Rust,
635            "/a",
636            "/a/target",
637            100,
638            Some("only".into()),
639        )];
640
641        let sort_opts = SortOptions {
642            criteria: Some(SortCriteria::Name),
643            reverse: false,
644        };
645        sort_projects(&mut projects, &sort_opts);
646
647        assert_eq!(projects.len(), 1);
648        assert_eq!(projects[0].name.as_deref(), Some("only"));
649    }
650
651    #[test]
652    fn test_type_order_values() {
653        assert!(type_order(&ProjectType::Cpp) < type_order(&ProjectType::Deno));
654        assert!(type_order(&ProjectType::Deno) < type_order(&ProjectType::DotNet));
655        assert!(type_order(&ProjectType::DotNet) < type_order(&ProjectType::Elixir));
656        assert!(type_order(&ProjectType::Elixir) < type_order(&ProjectType::Go));
657        assert!(type_order(&ProjectType::Go) < type_order(&ProjectType::Java));
658        assert!(type_order(&ProjectType::Java) < type_order(&ProjectType::Node));
659        assert!(type_order(&ProjectType::Node) < type_order(&ProjectType::Python));
660        assert!(type_order(&ProjectType::Python) < type_order(&ProjectType::Ruby));
661        assert!(type_order(&ProjectType::Ruby) < type_order(&ProjectType::Rust));
662        assert!(type_order(&ProjectType::Rust) < type_order(&ProjectType::Swift));
663    }
664}