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 glob::Pattern as GlobPattern;
9use rayon::prelude::*;
10use regex::Regex;
11use std::cmp::Reverse;
12use std::fs;
13use std::time::SystemTime;
14
15use crate::config::filter::SortCriteria;
16use crate::config::{FilterOptions, SortOptions};
17use crate::project::{Project, ProjectType};
18use crate::utils::parse_size;
19
20/// Compiled name pattern used to filter projects by name.
21///
22/// Precompiled once before the parallel filter pass to avoid repeated
23/// compilation inside the hot path.
24enum NameMatcher {
25    None,
26    Glob(GlobPattern),
27    Regex(Regex),
28}
29
30impl NameMatcher {
31    fn is_match(&self, name: &str) -> bool {
32        match self {
33            Self::None => true,
34            Self::Glob(pat) => pat.matches(name),
35            Self::Regex(re) => re.is_match(name),
36        }
37    }
38}
39
40/// Compile a raw pattern string into a [`NameMatcher`].
41///
42/// - `None` or empty string → `NameMatcher::None` (no filtering)
43/// - `"regex:<expr>"` → compiled regular expression
44/// - anything else → glob pattern
45fn compile_name_matcher(pattern: Option<&str>) -> Result<NameMatcher> {
46    let Some(pat) = pattern else {
47        return Ok(NameMatcher::None);
48    };
49    if pat.is_empty() {
50        return Ok(NameMatcher::None);
51    }
52    if let Some(regex_pat) = pat.strip_prefix("regex:") {
53        Ok(NameMatcher::Regex(Regex::new(regex_pat)?))
54    } else {
55        Ok(NameMatcher::Glob(GlobPattern::new(pat)?))
56    }
57}
58
59/// Filter projects based on size and modification time criteria.
60///
61/// This function applies parallel filtering to remove projects that don't meet
62/// the specified criteria:
63/// - Projects smaller than the minimum size threshold
64/// - Projects modified more recently than the specified number of days
65///
66/// # Arguments
67///
68/// * `projects` - Vector of projects to filter
69/// * `filter_opts` - Filtering options containing size and time criteria
70///
71/// # Returns
72///
73/// - `Ok(Vec<Project>)` - Filtered list of projects that meet all criteria
74/// - `Err(anyhow::Error)` - If size parsing fails, or file system errors occur
75///
76/// # Errors
77///
78/// This function can return errors if:
79/// - The size string in `filter_opts.keep_size` cannot be parsed (invalid format)
80/// - Size value overflow occurs during parsing
81///
82/// # Examples
83///
84/// ```no_run
85/// # use clean_dev_dirs::{filtering::filter_projects, config::FilterOptions, project::Project};
86/// # use anyhow::Result;
87/// # fn example(projects: Vec<Project>) -> Result<()> {
88/// let filter_opts = FilterOptions {
89///     keep_size: "100MB".to_string(),
90///     keep_days: 30,
91///     name_pattern: None,
92/// };
93/// let filtered = filter_projects(projects, &filter_opts)?;
94/// # Ok(())
95/// # }
96/// ```
97pub fn filter_projects(
98    projects: Vec<Project>,
99    filter_opts: &FilterOptions,
100) -> Result<Vec<Project>> {
101    let keep_size_bytes = parse_size(&filter_opts.keep_size)?;
102    let keep_days = filter_opts.keep_days;
103    let name_matcher = compile_name_matcher(filter_opts.name_pattern.as_deref())?;
104
105    Ok(projects
106        .into_par_iter()
107        .filter(|project| meets_size_criteria(project, keep_size_bytes))
108        .filter(|project| meets_time_criteria(project, keep_days))
109        .filter(|project| {
110            let name = project.name.as_deref().unwrap_or("");
111            name_matcher.is_match(name)
112        })
113        .collect())
114}
115
116/// Check if a project meets the size criteria.
117fn meets_size_criteria(project: &Project, min_size: u64) -> bool {
118    project.total_size() >= min_size
119}
120
121/// Check if a project meets the time criteria.
122fn meets_time_criteria(project: &Project, keep_days: u32) -> bool {
123    if keep_days == 0 {
124        return true;
125    }
126
127    is_project_old_enough(project, keep_days)
128}
129
130/// Check if a project is old enough based on its modification time.
131fn is_project_old_enough(project: &Project, keep_days: u32) -> bool {
132    let Some(primary) = project.build_arts.first() else {
133        return true;
134    };
135    let Result::Ok(metadata) = fs::metadata(&primary.path) else {
136        return true; // If we can't read metadata, don't filter it out
137    };
138
139    let Result::Ok(modified) = metadata.modified() else {
140        return true; // If we can't read modification time, don't filter it out
141    };
142
143    let modified_time: DateTime<Local> = modified.into();
144    let cutoff_time = Local::now() - chrono::Duration::days(i64::from(keep_days));
145
146    modified_time <= cutoff_time
147}
148
149/// Sort projects in place according to the given sorting options.
150///
151/// When `sort_opts.criteria` is `None`, the list is left in its current order.
152/// Each criterion has a natural default direction:
153/// - `Size`: largest first (descending)
154/// - `Age`: oldest first (ascending)
155/// - `Name`: alphabetical, case-insensitive (ascending)
156/// - `Type`: grouped by type name alphabetically
157///
158/// Setting `sort_opts.reverse` to `true` flips the resulting order.
159///
160/// For the `Age` criterion a Schwartzian transform is used to avoid
161/// repeated filesystem calls inside the comparator.
162///
163/// # Arguments
164///
165/// * `projects` - Mutable reference to the vector of projects to sort
166/// * `sort_opts` - Sorting options specifying criterion and direction
167///
168/// # Examples
169///
170/// ```no_run
171/// # use clean_dev_dirs::{filtering::sort_projects, config::{SortOptions, SortCriteria}};
172/// # use clean_dev_dirs::project::Project;
173/// # fn example(mut projects: Vec<Project>) {
174/// let sort_opts = SortOptions {
175///     criteria: Some(SortCriteria::Size),
176///     reverse: false,
177/// };
178/// sort_projects(&mut projects, &sort_opts);
179/// # }
180/// ```
181pub fn sort_projects(projects: &mut Vec<Project>, sort_opts: &SortOptions) {
182    let Some(criteria) = sort_opts.criteria else {
183        return;
184    };
185
186    match criteria {
187        SortCriteria::Size => {
188            projects.sort_by_key(|p| Reverse(p.total_size()));
189        }
190        SortCriteria::Age => {
191            sort_by_age(projects);
192        }
193        SortCriteria::Name => {
194            projects.sort_by(|a, b| {
195                let name_a = a.name.as_deref().unwrap_or("");
196                let name_b = b.name.as_deref().unwrap_or("");
197                name_a.to_lowercase().cmp(&name_b.to_lowercase())
198            });
199        }
200        SortCriteria::Type => {
201            projects.sort_by(|a, b| type_order(&a.kind).cmp(&type_order(&b.kind)));
202        }
203    }
204
205    if sort_opts.reverse {
206        projects.reverse();
207    }
208}
209
210/// Sort projects by build artifacts modification time (oldest first).
211///
212/// Uses a Schwartzian transform: each project is paired with its modification
213/// time (fetched once), sorted, then the timestamps are discarded.
214fn sort_by_age(projects: &mut Vec<Project>) {
215    let mut decorated: Vec<(Project, SystemTime)> = projects
216        .drain(..)
217        .map(|p| {
218            let mtime = p
219                .build_arts
220                .first()
221                .and_then(|a| fs::metadata(&a.path).ok())
222                .and_then(|m| m.modified().ok())
223                .unwrap_or(SystemTime::UNIX_EPOCH);
224            (p, mtime)
225        })
226        .collect();
227
228    decorated.sort_by(|a, b| a.1.cmp(&b.1));
229
230    projects.extend(decorated.into_iter().map(|(p, _)| p));
231}
232
233/// Map a `ProjectType` to an ordering index for type-based sorting.
234///
235/// Types are ordered alphabetically by their display name:
236/// C/C++, Dart, Deno, .NET, Elixir, Go, Haskell, Java, Node, PHP, Python, Ruby, Rust, Scala, Swift, Zig
237const fn type_order(kind: &ProjectType) -> u8 {
238    match kind {
239        ProjectType::Cpp => 0,
240        ProjectType::Dart => 1,
241        ProjectType::Deno => 2,
242        ProjectType::DotNet => 3,
243        ProjectType::Elixir => 4,
244        ProjectType::Go => 5,
245        ProjectType::Haskell => 6,
246        ProjectType::Java => 7,
247        ProjectType::Node => 8,
248        ProjectType::Php => 9,
249        ProjectType::Python => 10,
250        ProjectType::Ruby => 11,
251        ProjectType::Rust => 12,
252        ProjectType::Scala => 13,
253        ProjectType::Swift => 14,
254        ProjectType::Zig => 15,
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::project::{BuildArtifacts, Project, ProjectType};
262    use std::path::PathBuf;
263
264    /// Helper function to create a test project
265    fn create_test_project(
266        kind: ProjectType,
267        root_path: &str,
268        build_path: &str,
269        size: u64,
270        name: Option<String>,
271    ) -> Project {
272        Project::new(
273            kind,
274            PathBuf::from(root_path),
275            vec![BuildArtifacts {
276                path: PathBuf::from(build_path),
277                size,
278            }],
279            name,
280        )
281    }
282
283    #[test]
284    fn test_meets_size_criteria() {
285        let project = create_test_project(
286            ProjectType::Rust,
287            "/test",
288            "/test/target",
289            1_000_000, // 1MB
290            Some("test".to_string()),
291        );
292
293        assert!(meets_size_criteria(&project, 500_000)); // 0.5MB - should pass
294        assert!(meets_size_criteria(&project, 1_000_000)); // Exactly 1MB - should pass
295        assert!(!meets_size_criteria(&project, 2_000_000)); // 2MB - should fail
296    }
297
298    #[test]
299    fn test_meets_time_criteria_disabled() {
300        let project = create_test_project(
301            ProjectType::Rust,
302            "/test",
303            "/test/target",
304            1_000_000,
305            Some("test".to_string()),
306        );
307
308        // When keep_days is 0, should always return true
309        assert!(meets_time_criteria(&project, 0));
310    }
311
312    // ── Sorting tests ───────────────────────────────────────────────────
313
314    #[test]
315    fn test_sort_by_size_descending() {
316        let mut projects = vec![
317            create_test_project(
318                ProjectType::Rust,
319                "/a",
320                "/a/target",
321                100,
322                Some("small".into()),
323            ),
324            create_test_project(
325                ProjectType::Rust,
326                "/b",
327                "/b/target",
328                300,
329                Some("large".into()),
330            ),
331            create_test_project(
332                ProjectType::Rust,
333                "/c",
334                "/c/target",
335                200,
336                Some("medium".into()),
337            ),
338        ];
339
340        let sort_opts = SortOptions {
341            criteria: Some(SortCriteria::Size),
342            reverse: false,
343        };
344        sort_projects(&mut projects, &sort_opts);
345
346        assert_eq!(projects[0].total_size(), 300);
347        assert_eq!(projects[1].total_size(), 200);
348        assert_eq!(projects[2].total_size(), 100);
349    }
350
351    #[test]
352    fn test_sort_by_size_reversed() {
353        let mut projects = vec![
354            create_test_project(
355                ProjectType::Rust,
356                "/a",
357                "/a/target",
358                100,
359                Some("small".into()),
360            ),
361            create_test_project(
362                ProjectType::Rust,
363                "/b",
364                "/b/target",
365                300,
366                Some("large".into()),
367            ),
368            create_test_project(
369                ProjectType::Rust,
370                "/c",
371                "/c/target",
372                200,
373                Some("medium".into()),
374            ),
375        ];
376
377        let sort_opts = SortOptions {
378            criteria: Some(SortCriteria::Size),
379            reverse: true,
380        };
381        sort_projects(&mut projects, &sort_opts);
382
383        assert_eq!(projects[0].total_size(), 100);
384        assert_eq!(projects[1].total_size(), 200);
385        assert_eq!(projects[2].total_size(), 300);
386    }
387
388    #[test]
389    fn test_sort_by_name_alphabetical() {
390        let mut projects = vec![
391            create_test_project(
392                ProjectType::Rust,
393                "/c",
394                "/c/target",
395                100,
396                Some("charlie".into()),
397            ),
398            create_test_project(
399                ProjectType::Rust,
400                "/a",
401                "/a/target",
402                100,
403                Some("alpha".into()),
404            ),
405            create_test_project(
406                ProjectType::Rust,
407                "/b",
408                "/b/target",
409                100,
410                Some("bravo".into()),
411            ),
412        ];
413
414        let sort_opts = SortOptions {
415            criteria: Some(SortCriteria::Name),
416            reverse: false,
417        };
418        sort_projects(&mut projects, &sort_opts);
419
420        assert_eq!(projects[0].name.as_deref(), Some("alpha"));
421        assert_eq!(projects[1].name.as_deref(), Some("bravo"));
422        assert_eq!(projects[2].name.as_deref(), Some("charlie"));
423    }
424
425    #[test]
426    fn test_sort_by_name_case_insensitive() {
427        let mut projects = vec![
428            create_test_project(
429                ProjectType::Rust,
430                "/c",
431                "/c/target",
432                100,
433                Some("Charlie".into()),
434            ),
435            create_test_project(
436                ProjectType::Rust,
437                "/a",
438                "/a/target",
439                100,
440                Some("alpha".into()),
441            ),
442            create_test_project(
443                ProjectType::Rust,
444                "/b",
445                "/b/target",
446                100,
447                Some("Bravo".into()),
448            ),
449        ];
450
451        let sort_opts = SortOptions {
452            criteria: Some(SortCriteria::Name),
453            reverse: false,
454        };
455        sort_projects(&mut projects, &sort_opts);
456
457        assert_eq!(projects[0].name.as_deref(), Some("alpha"));
458        assert_eq!(projects[1].name.as_deref(), Some("Bravo"));
459        assert_eq!(projects[2].name.as_deref(), Some("Charlie"));
460    }
461
462    #[test]
463    fn test_sort_by_name_none_names_first() {
464        let mut projects = vec![
465            create_test_project(
466                ProjectType::Rust,
467                "/c",
468                "/c/target",
469                100,
470                Some("charlie".into()),
471            ),
472            create_test_project(ProjectType::Rust, "/a", "/a/target", 100, None),
473            create_test_project(
474                ProjectType::Rust,
475                "/b",
476                "/b/target",
477                100,
478                Some("alpha".into()),
479            ),
480        ];
481
482        let sort_opts = SortOptions {
483            criteria: Some(SortCriteria::Name),
484            reverse: false,
485        };
486        sort_projects(&mut projects, &sort_opts);
487
488        // None name sorts as "" which comes before any alphabetical name
489        assert_eq!(projects[0].name.as_deref(), None);
490        assert_eq!(projects[1].name.as_deref(), Some("alpha"));
491        assert_eq!(projects[2].name.as_deref(), Some("charlie"));
492    }
493
494    #[test]
495    fn test_sort_by_type() {
496        let mut projects = vec![
497            create_test_project(
498                ProjectType::Rust,
499                "/r",
500                "/r/target",
501                100,
502                Some("rust-proj".into()),
503            ),
504            create_test_project(
505                ProjectType::Go,
506                "/g",
507                "/g/vendor",
508                100,
509                Some("go-proj".into()),
510            ),
511            create_test_project(
512                ProjectType::Python,
513                "/p",
514                "/p/__pycache__",
515                100,
516                Some("py-proj".into()),
517            ),
518            create_test_project(
519                ProjectType::Node,
520                "/n",
521                "/n/node_modules",
522                100,
523                Some("node-proj".into()),
524            ),
525            create_test_project(
526                ProjectType::Java,
527                "/j",
528                "/j/target",
529                100,
530                Some("java-proj".into()),
531            ),
532            create_test_project(
533                ProjectType::Cpp,
534                "/c",
535                "/c/build",
536                100,
537                Some("cpp-proj".into()),
538            ),
539            create_test_project(
540                ProjectType::Swift,
541                "/s",
542                "/s/.build",
543                100,
544                Some("swift-proj".into()),
545            ),
546            create_test_project(
547                ProjectType::DotNet,
548                "/d",
549                "/d/obj",
550                100,
551                Some("dotnet-proj".into()),
552            ),
553            create_test_project(
554                ProjectType::Ruby,
555                "/rb",
556                "/rb/vendor/bundle",
557                100,
558                Some("ruby-proj".into()),
559            ),
560            create_test_project(
561                ProjectType::Elixir,
562                "/ex",
563                "/ex/_build",
564                100,
565                Some("elixir-proj".into()),
566            ),
567            create_test_project(
568                ProjectType::Deno,
569                "/dn",
570                "/dn/vendor",
571                100,
572                Some("deno-proj".into()),
573            ),
574        ];
575
576        let sort_opts = SortOptions {
577            criteria: Some(SortCriteria::Type),
578            reverse: false,
579        };
580        sort_projects(&mut projects, &sort_opts);
581
582        assert_eq!(projects[0].kind, ProjectType::Cpp);
583        assert_eq!(projects[1].kind, ProjectType::Deno);
584        assert_eq!(projects[2].kind, ProjectType::DotNet);
585        assert_eq!(projects[3].kind, ProjectType::Elixir);
586        assert_eq!(projects[4].kind, ProjectType::Go);
587        assert_eq!(projects[5].kind, ProjectType::Java);
588        assert_eq!(projects[6].kind, ProjectType::Node);
589        assert_eq!(projects[7].kind, ProjectType::Python);
590        assert_eq!(projects[8].kind, ProjectType::Ruby);
591        assert_eq!(projects[9].kind, ProjectType::Rust);
592        assert_eq!(projects[10].kind, ProjectType::Swift);
593    }
594
595    #[test]
596    fn test_sort_by_type_reversed() {
597        let mut projects = vec![
598            create_test_project(
599                ProjectType::Go,
600                "/g",
601                "/g/vendor",
602                100,
603                Some("go-proj".into()),
604            ),
605            create_test_project(
606                ProjectType::Rust,
607                "/r",
608                "/r/target",
609                100,
610                Some("rust-proj".into()),
611            ),
612            create_test_project(
613                ProjectType::Node,
614                "/n",
615                "/n/node_modules",
616                100,
617                Some("node-proj".into()),
618            ),
619        ];
620
621        let sort_opts = SortOptions {
622            criteria: Some(SortCriteria::Type),
623            reverse: true,
624        };
625        sort_projects(&mut projects, &sort_opts);
626
627        assert_eq!(projects[0].kind, ProjectType::Rust);
628        assert_eq!(projects[1].kind, ProjectType::Node);
629        assert_eq!(projects[2].kind, ProjectType::Go);
630    }
631
632    #[test]
633    fn test_sort_none_criteria_preserves_order() {
634        let mut projects = vec![
635            create_test_project(
636                ProjectType::Rust,
637                "/c",
638                "/c/target",
639                100,
640                Some("charlie".into()),
641            ),
642            create_test_project(
643                ProjectType::Rust,
644                "/a",
645                "/a/target",
646                300,
647                Some("alpha".into()),
648            ),
649            create_test_project(
650                ProjectType::Rust,
651                "/b",
652                "/b/target",
653                200,
654                Some("bravo".into()),
655            ),
656        ];
657
658        let sort_opts = SortOptions {
659            criteria: None,
660            reverse: false,
661        };
662        sort_projects(&mut projects, &sort_opts);
663
664        // Order should be unchanged
665        assert_eq!(projects[0].name.as_deref(), Some("charlie"));
666        assert_eq!(projects[1].name.as_deref(), Some("alpha"));
667        assert_eq!(projects[2].name.as_deref(), Some("bravo"));
668    }
669
670    #[test]
671    fn test_sort_empty_list() {
672        let mut projects: Vec<Project> = vec![];
673
674        let sort_opts = SortOptions {
675            criteria: Some(SortCriteria::Size),
676            reverse: false,
677        };
678        sort_projects(&mut projects, &sort_opts);
679
680        assert!(projects.is_empty());
681    }
682
683    #[test]
684    fn test_sort_single_element() {
685        let mut projects = vec![create_test_project(
686            ProjectType::Rust,
687            "/a",
688            "/a/target",
689            100,
690            Some("only".into()),
691        )];
692
693        let sort_opts = SortOptions {
694            criteria: Some(SortCriteria::Name),
695            reverse: false,
696        };
697        sort_projects(&mut projects, &sort_opts);
698
699        assert_eq!(projects.len(), 1);
700        assert_eq!(projects[0].name.as_deref(), Some("only"));
701    }
702
703    #[test]
704    fn test_type_order_values() {
705        assert!(type_order(&ProjectType::Cpp) < type_order(&ProjectType::Deno));
706        assert!(type_order(&ProjectType::Deno) < type_order(&ProjectType::DotNet));
707        assert!(type_order(&ProjectType::DotNet) < type_order(&ProjectType::Elixir));
708        assert!(type_order(&ProjectType::Elixir) < type_order(&ProjectType::Go));
709        assert!(type_order(&ProjectType::Go) < type_order(&ProjectType::Java));
710        assert!(type_order(&ProjectType::Java) < type_order(&ProjectType::Node));
711        assert!(type_order(&ProjectType::Node) < type_order(&ProjectType::Python));
712        assert!(type_order(&ProjectType::Python) < type_order(&ProjectType::Ruby));
713        assert!(type_order(&ProjectType::Ruby) < type_order(&ProjectType::Rust));
714        assert!(type_order(&ProjectType::Rust) < type_order(&ProjectType::Swift));
715    }
716
717    // ── NameMatcher tests ───────────────────────────────────────────────
718
719    #[test]
720    fn test_name_matcher_none_passes_all() -> anyhow::Result<()> {
721        let matcher = compile_name_matcher(None)?;
722        assert!(matcher.is_match("any-name"));
723        assert!(matcher.is_match(""));
724        assert!(matcher.is_match("something-else"));
725        Ok(())
726    }
727
728    #[test]
729    fn test_name_matcher_glob_star() -> anyhow::Result<()> {
730        let matcher = compile_name_matcher(Some("my-app*"))?;
731        assert!(matcher.is_match("my-app"));
732        assert!(matcher.is_match("my-app-v2"));
733        assert!(matcher.is_match("my-appXYZ"));
734        assert!(!matcher.is_match("other-app"));
735        assert!(!matcher.is_match(""));
736        Ok(())
737    }
738
739    #[test]
740    fn test_name_matcher_glob_question_mark() -> anyhow::Result<()> {
741        let matcher = compile_name_matcher(Some("app-?"))?;
742        assert!(matcher.is_match("app-1"));
743        assert!(matcher.is_match("app-a"));
744        assert!(!matcher.is_match("app-12"));
745        assert!(!matcher.is_match("app-"));
746        Ok(())
747    }
748
749    #[test]
750    fn test_name_matcher_regex_prefix() -> anyhow::Result<()> {
751        let matcher = compile_name_matcher(Some("regex:^client-.*"))?;
752        assert!(matcher.is_match("client-api"));
753        assert!(matcher.is_match("client-web"));
754        assert!(!matcher.is_match("server-api"));
755        assert!(!matcher.is_match(""));
756        Ok(())
757    }
758
759    #[test]
760    fn test_name_matcher_regex_invalid_returns_error() {
761        let result = compile_name_matcher(Some("regex:[invalid"));
762        assert!(result.is_err());
763    }
764
765    #[test]
766    fn test_name_matcher_glob_invalid_returns_error() {
767        // glob crate treats most patterns as valid, but brackets without closing
768        // bracket cause a PatternError
769        let result = compile_name_matcher(Some("["));
770        assert!(result.is_err());
771    }
772
773    #[test]
774    fn test_filter_projects_by_name_glob() -> anyhow::Result<()> {
775        let projects = vec![
776            create_test_project(
777                ProjectType::Rust,
778                "/a",
779                "/a/target",
780                1000,
781                Some("my-app".into()),
782            ),
783            create_test_project(
784                ProjectType::Rust,
785                "/b",
786                "/b/target",
787                1000,
788                Some("my-app-v2".into()),
789            ),
790            create_test_project(
791                ProjectType::Rust,
792                "/c",
793                "/c/target",
794                1000,
795                Some("other-project".into()),
796            ),
797        ];
798
799        let filter_opts = FilterOptions {
800            keep_size: "0".to_string(),
801            keep_days: 0,
802            name_pattern: Some("my-app*".to_string()),
803        };
804
805        let filtered = filter_projects(projects, &filter_opts)?;
806        assert_eq!(filtered.len(), 2);
807        assert!(
808            filtered
809                .iter()
810                .all(|p| p.name.as_deref().unwrap_or("").starts_with("my-app"))
811        );
812        Ok(())
813    }
814
815    #[test]
816    fn test_filter_projects_by_name_regex() -> anyhow::Result<()> {
817        let projects = vec![
818            create_test_project(
819                ProjectType::Rust,
820                "/a",
821                "/a/target",
822                1000,
823                Some("client-api".into()),
824            ),
825            create_test_project(
826                ProjectType::Rust,
827                "/b",
828                "/b/target",
829                1000,
830                Some("client-web".into()),
831            ),
832            create_test_project(
833                ProjectType::Rust,
834                "/c",
835                "/c/target",
836                1000,
837                Some("server-api".into()),
838            ),
839        ];
840
841        let filter_opts = FilterOptions {
842            keep_size: "0".to_string(),
843            keep_days: 0,
844            name_pattern: Some("regex:^client-.*".to_string()),
845        };
846
847        let filtered = filter_projects(projects, &filter_opts)?;
848        assert_eq!(filtered.len(), 2);
849        assert!(
850            filtered
851                .iter()
852                .all(|p| p.name.as_deref().unwrap_or("").starts_with("client-"))
853        );
854        Ok(())
855    }
856
857    #[test]
858    fn test_filter_projects_name_none_no_match() -> anyhow::Result<()> {
859        let projects = vec![
860            create_test_project(ProjectType::Rust, "/a", "/a/target", 1000, None),
861            create_test_project(
862                ProjectType::Rust,
863                "/b",
864                "/b/target",
865                1000,
866                Some("named".into()),
867            ),
868        ];
869
870        let filter_opts = FilterOptions {
871            keep_size: "0".to_string(),
872            keep_days: 0,
873            name_pattern: Some("named*".to_string()),
874        };
875
876        let filtered = filter_projects(projects, &filter_opts)?;
877        // The project with name=None is treated as "" and should not match "named*"
878        assert_eq!(filtered.len(), 1);
879        assert_eq!(filtered[0].name.as_deref(), Some("named"));
880        Ok(())
881    }
882}