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