use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct Branch {
pub name: String,
pub age_days: i64,
pub is_merged: bool,
pub is_remote: bool,
pub last_commit_sha: String,
pub last_commit_date: DateTime<Utc>,
}
impl Branch {
pub fn is_protected(&self, protected_branches: &[String]) -> bool {
let name = self.short_name();
protected_branches.iter().any(|p| p == name)
}
pub fn matches_exclude_pattern(&self, patterns: &[String]) -> bool {
let name = self.short_name();
patterns
.iter()
.any(|pattern| Self::glob_match(pattern, name))
}
fn glob_match(pattern: &str, text: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == text;
}
let mut remaining = text;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
if !remaining.ends_with(part) {
return false;
}
remaining = "";
} else {
if let Some(pos) = remaining.find(part) {
remaining = &remaining[pos + part.len()..];
} else {
return false;
}
}
}
true
}
pub fn short_name(&self) -> &str {
if self.is_remote {
self.name.strip_prefix("origin/").unwrap_or(&self.name)
} else {
&self.name
}
}
pub fn format_age(&self) -> String {
if self.age_days == 1 {
"1 day".to_string()
} else {
format!("{} days", self.age_days)
}
}
}
#[derive(Debug, Clone, Default)]
pub struct BranchFilter {
pub min_age_days: u32,
pub local_only: bool,
pub remote_only: bool,
pub merged_only: bool,
pub protected_branches: Vec<String>,
pub exclude_patterns: Vec<String>,
}
impl BranchFilter {
pub fn matches(&self, branch: &Branch) -> bool {
if branch.age_days < self.min_age_days as i64 {
return false;
}
if self.local_only && branch.is_remote {
return false;
}
if self.remote_only && !branch.is_remote {
return false;
}
if self.merged_only && !branch.is_merged {
return false;
}
if branch.is_protected(&self.protected_branches) {
return false;
}
if branch.matches_exclude_pattern(&self.exclude_patterns) {
return false;
}
true
}
}
pub fn sort_branches(branches: &mut [Branch]) {
branches.sort_by(|a, b| {
match (a.is_merged, b.is_merged) {
(false, true) => std::cmp::Ordering::Less,
(true, false) => std::cmp::Ordering::Greater,
_ => a.age_days.cmp(&b.age_days),
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn test_branch(name: &str, age_days: i64, is_merged: bool, is_remote: bool) -> Branch {
Branch {
name: name.to_string(),
age_days,
is_merged,
is_remote,
last_commit_sha: "abc123".to_string(),
last_commit_date: Utc::now(),
}
}
#[test]
fn test_branch_short_name() {
let local = test_branch("feature/test", 10, false, false);
assert_eq!(local.short_name(), "feature/test");
let remote = test_branch("origin/feature/test", 10, false, true);
assert_eq!(remote.short_name(), "feature/test");
}
#[test]
fn test_branch_format_age() {
let one_day = test_branch("test", 1, false, false);
assert_eq!(one_day.format_age(), "1 day");
let multiple_days = test_branch("test", 42, false, false);
assert_eq!(multiple_days.format_age(), "42 days");
}
#[test]
fn test_branch_is_protected() {
let branch = test_branch("feature/test", 10, false, false);
let protected = vec!["main".to_string(), "develop".to_string()];
assert!(!branch.is_protected(&protected));
let main_branch = test_branch("main", 10, false, false);
assert!(main_branch.is_protected(&protected));
let remote_main = test_branch("origin/main", 10, false, true);
assert!(remote_main.is_protected(&protected));
}
#[test]
fn test_glob_match_exact() {
assert!(Branch::glob_match("main", "main"));
assert!(!Branch::glob_match("main", "develop"));
}
#[test]
fn test_glob_match_prefix() {
assert!(Branch::glob_match("wip/*", "wip/test"));
assert!(Branch::glob_match("wip/*", "wip/feature/test"));
assert!(!Branch::glob_match("wip/*", "feature/wip"));
}
#[test]
fn test_glob_match_suffix() {
assert!(Branch::glob_match("*/draft", "feature/draft"));
assert!(Branch::glob_match("*/draft", "test/feature/draft"));
assert!(!Branch::glob_match("*/draft", "draft/feature"));
}
#[test]
fn test_glob_match_middle() {
assert!(Branch::glob_match("feature/*/temp", "feature/test/temp"));
assert!(Branch::glob_match("feature/*/temp", "feature/foo/bar/temp"));
assert!(!Branch::glob_match("feature/*/temp", "feature/temp"));
}
#[test]
fn test_glob_match_multiple_wildcards() {
assert!(Branch::glob_match("*/*/test", "a/b/test"));
assert!(Branch::glob_match("*/test/*", "a/test/b"));
assert!(Branch::glob_match("*test*", "mytest123"));
}
#[test]
fn test_branch_matches_exclude_pattern() {
let branch = test_branch("wip/feature", 10, false, false);
let patterns = vec!["wip/*".to_string(), "*/draft".to_string()];
assert!(branch.matches_exclude_pattern(&patterns));
let draft_branch = test_branch("feature/draft", 10, false, false);
assert!(draft_branch.matches_exclude_pattern(&patterns));
let normal_branch = test_branch("feature/test", 10, false, false);
assert!(!normal_branch.matches_exclude_pattern(&patterns));
}
#[test]
fn test_filter_by_age() {
let filter = BranchFilter {
min_age_days: 30,
..Default::default()
};
let old_branch = test_branch("old", 45, false, false);
assert!(filter.matches(&old_branch));
let new_branch = test_branch("new", 15, false, false);
assert!(!filter.matches(&new_branch));
let exact_age = test_branch("exact", 30, false, false);
assert!(filter.matches(&exact_age));
}
#[test]
fn test_filter_local_only() {
let filter = BranchFilter {
local_only: true,
..Default::default()
};
let local = test_branch("feature", 45, false, false);
assert!(filter.matches(&local));
let remote = test_branch("origin/feature", 45, false, true);
assert!(!filter.matches(&remote));
}
#[test]
fn test_filter_remote_only() {
let filter = BranchFilter {
remote_only: true,
..Default::default()
};
let local = test_branch("feature", 45, false, false);
assert!(!filter.matches(&local));
let remote = test_branch("origin/feature", 45, false, true);
assert!(filter.matches(&remote));
}
#[test]
fn test_filter_merged_only() {
let filter = BranchFilter {
merged_only: true,
..Default::default()
};
let merged = test_branch("feature", 45, true, false);
assert!(filter.matches(&merged));
let unmerged = test_branch("feature", 45, false, false);
assert!(!filter.matches(&unmerged));
}
#[test]
fn test_filter_protected_branches() {
let filter = BranchFilter {
protected_branches: vec!["main".to_string(), "develop".to_string()],
..Default::default()
};
let feature = test_branch("feature", 45, false, false);
assert!(filter.matches(&feature));
let main = test_branch("main", 45, false, false);
assert!(!filter.matches(&main));
let develop = test_branch("develop", 45, false, false);
assert!(!filter.matches(&develop));
}
#[test]
fn test_filter_exclude_patterns() {
let filter = BranchFilter {
exclude_patterns: vec!["wip/*".to_string(), "*/draft".to_string()],
..Default::default()
};
let feature = test_branch("feature/test", 45, false, false);
assert!(filter.matches(&feature));
let wip = test_branch("wip/feature", 45, false, false);
assert!(!filter.matches(&wip));
let draft = test_branch("feature/draft", 45, false, false);
assert!(!filter.matches(&draft));
}
#[test]
fn test_filter_combined() {
let filter = BranchFilter {
min_age_days: 30,
merged_only: true,
local_only: true,
remote_only: false,
protected_branches: vec!["main".to_string()],
exclude_patterns: vec!["wip/*".to_string()],
};
let good = test_branch("feature/old", 45, true, false);
assert!(filter.matches(&good));
let too_young = test_branch("feature/new", 15, true, false);
assert!(!filter.matches(&too_young));
let unmerged = test_branch("feature/unmerged", 45, false, false);
assert!(!filter.matches(&unmerged));
let remote = test_branch("origin/feature", 45, true, true);
assert!(!filter.matches(&remote));
let protected = test_branch("main", 45, true, false);
assert!(!filter.matches(&protected));
let wip = test_branch("wip/feature", 45, true, false);
assert!(!filter.matches(&wip));
}
#[test]
fn test_sort_branches_by_merge_status() {
let mut branches = vec![
test_branch("merged1", 20, true, false),
test_branch("unmerged1", 30, false, false),
test_branch("merged2", 10, true, false),
test_branch("unmerged2", 40, false, false),
];
sort_branches(&mut branches);
assert!(!branches[0].is_merged);
assert!(!branches[1].is_merged);
assert!(branches[2].is_merged);
assert!(branches[3].is_merged);
}
#[test]
fn test_sort_branches_by_age_within_merged_status() {
let mut branches = vec![
test_branch("unmerged_newer", 20, false, false),
test_branch("unmerged_older", 40, false, false),
test_branch("merged_newer", 10, true, false),
test_branch("merged_older", 30, true, false),
];
sort_branches(&mut branches);
assert_eq!(branches[0].name, "unmerged_newer");
assert_eq!(branches[1].name, "unmerged_older");
assert_eq!(branches[2].name, "merged_newer");
assert_eq!(branches[3].name, "merged_older");
}
}