use anyhow::Result;
use chrono::{DateTime, Local};
use glob::Pattern as GlobPattern;
use rayon::prelude::*;
use regex::Regex;
use std::cmp::Reverse;
use std::fs;
use std::time::SystemTime;
use crate::config::filter::SortCriteria;
use crate::config::{FilterOptions, SortOptions};
use crate::project::{Project, ProjectType};
use crate::utils::parse_size;
enum NameMatcher {
None,
Glob(GlobPattern),
Regex(Regex),
}
impl NameMatcher {
fn is_match(&self, name: &str) -> bool {
match self {
Self::None => true,
Self::Glob(pat) => pat.matches(name),
Self::Regex(re) => re.is_match(name),
}
}
}
fn compile_name_matcher(pattern: Option<&str>) -> Result<NameMatcher> {
let Some(pat) = pattern else {
return Ok(NameMatcher::None);
};
if pat.is_empty() {
return Ok(NameMatcher::None);
}
if let Some(regex_pat) = pat.strip_prefix("regex:") {
Ok(NameMatcher::Regex(Regex::new(regex_pat)?))
} else {
Ok(NameMatcher::Glob(GlobPattern::new(pat)?))
}
}
pub fn filter_projects(
projects: Vec<Project>,
filter_opts: &FilterOptions,
) -> Result<Vec<Project>> {
let keep_size_bytes = parse_size(&filter_opts.keep_size)?;
let keep_days = filter_opts.keep_days;
let name_matcher = compile_name_matcher(filter_opts.name_pattern.as_deref())?;
Ok(projects
.into_par_iter()
.filter(|project| meets_size_criteria(project, keep_size_bytes))
.filter(|project| meets_time_criteria(project, keep_days))
.filter(|project| {
let name = project.name.as_deref().unwrap_or("");
name_matcher.is_match(name)
})
.collect())
}
fn meets_size_criteria(project: &Project, min_size: u64) -> bool {
project.total_size() >= min_size
}
fn meets_time_criteria(project: &Project, keep_days: u32) -> bool {
if keep_days == 0 {
return true;
}
is_project_old_enough(project, keep_days)
}
fn is_project_old_enough(project: &Project, keep_days: u32) -> bool {
let Some(primary) = project.build_arts.first() else {
return true;
};
let Result::Ok(metadata) = fs::metadata(&primary.path) else {
return true; };
let Result::Ok(modified) = metadata.modified() else {
return true; };
let modified_time: DateTime<Local> = modified.into();
let cutoff_time = Local::now() - chrono::Duration::days(i64::from(keep_days));
modified_time <= cutoff_time
}
pub fn sort_projects(projects: &mut Vec<Project>, sort_opts: &SortOptions) {
let Some(criteria) = sort_opts.criteria else {
return;
};
match criteria {
SortCriteria::Size => {
projects.sort_by_key(|p| Reverse(p.total_size()));
}
SortCriteria::Age => {
sort_by_age(projects);
}
SortCriteria::Name => {
projects.sort_by(|a, b| {
let name_a = a.name.as_deref().unwrap_or("");
let name_b = b.name.as_deref().unwrap_or("");
name_a.to_lowercase().cmp(&name_b.to_lowercase())
});
}
SortCriteria::Type => {
projects.sort_by_key(|a| type_order(&a.kind));
}
}
if sort_opts.reverse {
projects.reverse();
}
}
fn sort_by_age(projects: &mut Vec<Project>) {
let mut decorated: Vec<(Project, SystemTime)> = projects
.drain(..)
.map(|p| {
let mtime = p
.build_arts
.first()
.and_then(|a| fs::metadata(&a.path).ok())
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
(p, mtime)
})
.collect();
decorated.sort_by_key(|a| a.1);
projects.extend(decorated.into_iter().map(|(p, _)| p));
}
const fn type_order(kind: &ProjectType) -> u8 {
match kind {
ProjectType::Cpp => 0,
ProjectType::Dart => 1,
ProjectType::Deno => 2,
ProjectType::DotNet => 3,
ProjectType::Elixir => 4,
ProjectType::Go => 5,
ProjectType::Haskell => 6,
ProjectType::Java => 7,
ProjectType::Node => 8,
ProjectType::Php => 9,
ProjectType::Python => 10,
ProjectType::Ruby => 11,
ProjectType::Rust => 12,
ProjectType::Scala => 13,
ProjectType::Swift => 14,
ProjectType::Zig => 15,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::{BuildArtifacts, Project, ProjectType};
use std::path::PathBuf;
fn create_test_project(
kind: ProjectType,
root_path: &str,
build_path: &str,
size: u64,
name: Option<String>,
) -> Project {
Project::new(
kind,
PathBuf::from(root_path),
vec![BuildArtifacts {
path: PathBuf::from(build_path),
size,
}],
name,
)
}
#[test]
fn test_meets_size_criteria() {
let project = create_test_project(
ProjectType::Rust,
"/test",
"/test/target",
1_000_000, Some("test".to_string()),
);
assert!(meets_size_criteria(&project, 500_000)); assert!(meets_size_criteria(&project, 1_000_000)); assert!(!meets_size_criteria(&project, 2_000_000)); }
#[test]
fn test_meets_time_criteria_disabled() {
let project = create_test_project(
ProjectType::Rust,
"/test",
"/test/target",
1_000_000,
Some("test".to_string()),
);
assert!(meets_time_criteria(&project, 0));
}
#[test]
fn test_sort_by_size_descending() {
let mut projects = vec![
create_test_project(
ProjectType::Rust,
"/a",
"/a/target",
100,
Some("small".into()),
),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
300,
Some("large".into()),
),
create_test_project(
ProjectType::Rust,
"/c",
"/c/target",
200,
Some("medium".into()),
),
];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Size),
reverse: false,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects[0].total_size(), 300);
assert_eq!(projects[1].total_size(), 200);
assert_eq!(projects[2].total_size(), 100);
}
#[test]
fn test_sort_by_size_reversed() {
let mut projects = vec![
create_test_project(
ProjectType::Rust,
"/a",
"/a/target",
100,
Some("small".into()),
),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
300,
Some("large".into()),
),
create_test_project(
ProjectType::Rust,
"/c",
"/c/target",
200,
Some("medium".into()),
),
];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Size),
reverse: true,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects[0].total_size(), 100);
assert_eq!(projects[1].total_size(), 200);
assert_eq!(projects[2].total_size(), 300);
}
#[test]
fn test_sort_by_name_alphabetical() {
let mut projects = vec![
create_test_project(
ProjectType::Rust,
"/c",
"/c/target",
100,
Some("charlie".into()),
),
create_test_project(
ProjectType::Rust,
"/a",
"/a/target",
100,
Some("alpha".into()),
),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
100,
Some("bravo".into()),
),
];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Name),
reverse: false,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects[0].name.as_deref(), Some("alpha"));
assert_eq!(projects[1].name.as_deref(), Some("bravo"));
assert_eq!(projects[2].name.as_deref(), Some("charlie"));
}
#[test]
fn test_sort_by_name_case_insensitive() {
let mut projects = vec![
create_test_project(
ProjectType::Rust,
"/c",
"/c/target",
100,
Some("Charlie".into()),
),
create_test_project(
ProjectType::Rust,
"/a",
"/a/target",
100,
Some("alpha".into()),
),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
100,
Some("Bravo".into()),
),
];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Name),
reverse: false,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects[0].name.as_deref(), Some("alpha"));
assert_eq!(projects[1].name.as_deref(), Some("Bravo"));
assert_eq!(projects[2].name.as_deref(), Some("Charlie"));
}
#[test]
fn test_sort_by_name_none_names_first() {
let mut projects = vec![
create_test_project(
ProjectType::Rust,
"/c",
"/c/target",
100,
Some("charlie".into()),
),
create_test_project(ProjectType::Rust, "/a", "/a/target", 100, None),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
100,
Some("alpha".into()),
),
];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Name),
reverse: false,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects[0].name.as_deref(), None);
assert_eq!(projects[1].name.as_deref(), Some("alpha"));
assert_eq!(projects[2].name.as_deref(), Some("charlie"));
}
#[test]
fn test_sort_by_type() {
let mut projects = vec![
create_test_project(
ProjectType::Rust,
"/r",
"/r/target",
100,
Some("rust-proj".into()),
),
create_test_project(
ProjectType::Go,
"/g",
"/g/vendor",
100,
Some("go-proj".into()),
),
create_test_project(
ProjectType::Python,
"/p",
"/p/__pycache__",
100,
Some("py-proj".into()),
),
create_test_project(
ProjectType::Node,
"/n",
"/n/node_modules",
100,
Some("node-proj".into()),
),
create_test_project(
ProjectType::Java,
"/j",
"/j/target",
100,
Some("java-proj".into()),
),
create_test_project(
ProjectType::Cpp,
"/c",
"/c/build",
100,
Some("cpp-proj".into()),
),
create_test_project(
ProjectType::Swift,
"/s",
"/s/.build",
100,
Some("swift-proj".into()),
),
create_test_project(
ProjectType::DotNet,
"/d",
"/d/obj",
100,
Some("dotnet-proj".into()),
),
create_test_project(
ProjectType::Ruby,
"/rb",
"/rb/vendor/bundle",
100,
Some("ruby-proj".into()),
),
create_test_project(
ProjectType::Elixir,
"/ex",
"/ex/_build",
100,
Some("elixir-proj".into()),
),
create_test_project(
ProjectType::Deno,
"/dn",
"/dn/vendor",
100,
Some("deno-proj".into()),
),
];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Type),
reverse: false,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects[0].kind, ProjectType::Cpp);
assert_eq!(projects[1].kind, ProjectType::Deno);
assert_eq!(projects[2].kind, ProjectType::DotNet);
assert_eq!(projects[3].kind, ProjectType::Elixir);
assert_eq!(projects[4].kind, ProjectType::Go);
assert_eq!(projects[5].kind, ProjectType::Java);
assert_eq!(projects[6].kind, ProjectType::Node);
assert_eq!(projects[7].kind, ProjectType::Python);
assert_eq!(projects[8].kind, ProjectType::Ruby);
assert_eq!(projects[9].kind, ProjectType::Rust);
assert_eq!(projects[10].kind, ProjectType::Swift);
}
#[test]
fn test_sort_by_type_reversed() {
let mut projects = vec![
create_test_project(
ProjectType::Go,
"/g",
"/g/vendor",
100,
Some("go-proj".into()),
),
create_test_project(
ProjectType::Rust,
"/r",
"/r/target",
100,
Some("rust-proj".into()),
),
create_test_project(
ProjectType::Node,
"/n",
"/n/node_modules",
100,
Some("node-proj".into()),
),
];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Type),
reverse: true,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects[0].kind, ProjectType::Rust);
assert_eq!(projects[1].kind, ProjectType::Node);
assert_eq!(projects[2].kind, ProjectType::Go);
}
#[test]
fn test_sort_none_criteria_preserves_order() {
let mut projects = vec![
create_test_project(
ProjectType::Rust,
"/c",
"/c/target",
100,
Some("charlie".into()),
),
create_test_project(
ProjectType::Rust,
"/a",
"/a/target",
300,
Some("alpha".into()),
),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
200,
Some("bravo".into()),
),
];
let sort_opts = SortOptions {
criteria: None,
reverse: false,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects[0].name.as_deref(), Some("charlie"));
assert_eq!(projects[1].name.as_deref(), Some("alpha"));
assert_eq!(projects[2].name.as_deref(), Some("bravo"));
}
#[test]
fn test_sort_empty_list() {
let mut projects: Vec<Project> = vec![];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Size),
reverse: false,
};
sort_projects(&mut projects, &sort_opts);
assert!(projects.is_empty());
}
#[test]
fn test_sort_single_element() {
let mut projects = vec![create_test_project(
ProjectType::Rust,
"/a",
"/a/target",
100,
Some("only".into()),
)];
let sort_opts = SortOptions {
criteria: Some(SortCriteria::Name),
reverse: false,
};
sort_projects(&mut projects, &sort_opts);
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].name.as_deref(), Some("only"));
}
#[test]
fn test_type_order_values() {
assert!(type_order(&ProjectType::Cpp) < type_order(&ProjectType::Deno));
assert!(type_order(&ProjectType::Deno) < type_order(&ProjectType::DotNet));
assert!(type_order(&ProjectType::DotNet) < type_order(&ProjectType::Elixir));
assert!(type_order(&ProjectType::Elixir) < type_order(&ProjectType::Go));
assert!(type_order(&ProjectType::Go) < type_order(&ProjectType::Java));
assert!(type_order(&ProjectType::Java) < type_order(&ProjectType::Node));
assert!(type_order(&ProjectType::Node) < type_order(&ProjectType::Python));
assert!(type_order(&ProjectType::Python) < type_order(&ProjectType::Ruby));
assert!(type_order(&ProjectType::Ruby) < type_order(&ProjectType::Rust));
assert!(type_order(&ProjectType::Rust) < type_order(&ProjectType::Swift));
}
#[test]
fn test_name_matcher_none_passes_all() -> anyhow::Result<()> {
let matcher = compile_name_matcher(None)?;
assert!(matcher.is_match("any-name"));
assert!(matcher.is_match(""));
assert!(matcher.is_match("something-else"));
Ok(())
}
#[test]
fn test_name_matcher_glob_star() -> anyhow::Result<()> {
let matcher = compile_name_matcher(Some("my-app*"))?;
assert!(matcher.is_match("my-app"));
assert!(matcher.is_match("my-app-v2"));
assert!(matcher.is_match("my-appXYZ"));
assert!(!matcher.is_match("other-app"));
assert!(!matcher.is_match(""));
Ok(())
}
#[test]
fn test_name_matcher_glob_question_mark() -> anyhow::Result<()> {
let matcher = compile_name_matcher(Some("app-?"))?;
assert!(matcher.is_match("app-1"));
assert!(matcher.is_match("app-a"));
assert!(!matcher.is_match("app-12"));
assert!(!matcher.is_match("app-"));
Ok(())
}
#[test]
fn test_name_matcher_regex_prefix() -> anyhow::Result<()> {
let matcher = compile_name_matcher(Some("regex:^client-.*"))?;
assert!(matcher.is_match("client-api"));
assert!(matcher.is_match("client-web"));
assert!(!matcher.is_match("server-api"));
assert!(!matcher.is_match(""));
Ok(())
}
#[test]
fn test_name_matcher_regex_invalid_returns_error() {
let result = compile_name_matcher(Some("regex:[invalid"));
assert!(result.is_err());
}
#[test]
fn test_name_matcher_glob_invalid_returns_error() {
let result = compile_name_matcher(Some("["));
assert!(result.is_err());
}
#[test]
fn test_filter_projects_by_name_glob() -> anyhow::Result<()> {
let projects = vec![
create_test_project(
ProjectType::Rust,
"/a",
"/a/target",
1000,
Some("my-app".into()),
),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
1000,
Some("my-app-v2".into()),
),
create_test_project(
ProjectType::Rust,
"/c",
"/c/target",
1000,
Some("other-project".into()),
),
];
let filter_opts = FilterOptions {
keep_size: "0".to_string(),
keep_days: 0,
name_pattern: Some("my-app*".to_string()),
};
let filtered = filter_projects(projects, &filter_opts)?;
assert_eq!(filtered.len(), 2);
assert!(
filtered
.iter()
.all(|p| p.name.as_deref().unwrap_or("").starts_with("my-app"))
);
Ok(())
}
#[test]
fn test_filter_projects_by_name_regex() -> anyhow::Result<()> {
let projects = vec![
create_test_project(
ProjectType::Rust,
"/a",
"/a/target",
1000,
Some("client-api".into()),
),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
1000,
Some("client-web".into()),
),
create_test_project(
ProjectType::Rust,
"/c",
"/c/target",
1000,
Some("server-api".into()),
),
];
let filter_opts = FilterOptions {
keep_size: "0".to_string(),
keep_days: 0,
name_pattern: Some("regex:^client-.*".to_string()),
};
let filtered = filter_projects(projects, &filter_opts)?;
assert_eq!(filtered.len(), 2);
assert!(
filtered
.iter()
.all(|p| p.name.as_deref().unwrap_or("").starts_with("client-"))
);
Ok(())
}
#[test]
fn test_filter_projects_name_none_no_match() -> anyhow::Result<()> {
let projects = vec![
create_test_project(ProjectType::Rust, "/a", "/a/target", 1000, None),
create_test_project(
ProjectType::Rust,
"/b",
"/b/target",
1000,
Some("named".into()),
),
];
let filter_opts = FilterOptions {
keep_size: "0".to_string(),
keep_days: 0,
name_pattern: Some("named*".to_string()),
};
let filtered = filter_projects(projects, &filter_opts)?;
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name.as_deref(), Some("named"));
Ok(())
}
}