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