use anyhow::{Result, bail};
use chrono::{DateTime, Duration, Utc};
use crate::types::ProjectStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SinceDuration {
pub count: u64,
pub unit: DurationUnit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DurationUnit {
Days,
Weeks,
Months,
}
impl SinceDuration {
pub fn to_chrono_duration(self) -> Duration {
let days = match self.unit {
DurationUnit::Days => self.count as i64,
DurationUnit::Weeks => self.count as i64 * 7,
DurationUnit::Months => self.count as i64 * 30,
};
Duration::days(days)
}
}
pub fn parse_duration(s: &str) -> Result<SinceDuration> {
let s = s.trim();
if s.is_empty() {
bail!("Duration cannot be empty. Use format: <number><unit> (e.g. 7d, 2w, 1m)");
}
let num_end = s
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map(|(i, _)| i);
let (num_str, unit_str) = match num_end {
Some(i) if i > 0 => (&s[..i], &s[i..]),
Some(0) => {
bail!(
"Invalid duration '{}': must start with a number. Use format: <number><unit> (e.g. 7d, 2w, 1m)",
s
);
}
None => {
bail!(
"Invalid duration '{}': missing unit. Use format: <number><unit> where unit is d (days), w (weeks), or m (months)",
s
);
}
_ => bail!("Invalid duration '{}'", s),
};
let count: u64 = num_str.parse().map_err(|_| {
anyhow::anyhow!(
"Invalid number in duration '{}'. Use format: <number><unit> (e.g. 7d, 2w, 1m)",
s
)
})?;
if count == 0 {
bail!("Duration must be greater than zero. Got: '{}'", s);
}
let unit = match unit_str.trim().to_lowercase().as_str() {
"d" | "day" | "days" => DurationUnit::Days,
"w" | "week" | "weeks" => DurationUnit::Weeks,
"m" | "month" | "months" => DurationUnit::Months,
other => {
bail!(
"Unknown duration unit '{}'. Valid units: d (days), w (weeks), m (months)",
other
);
}
};
Ok(SinceDuration { count, unit })
}
pub fn filter_since(
statuses: Vec<ProjectStatus>,
since: &SinceDuration,
now: DateTime<Utc>,
include_empty: bool,
) -> Vec<ProjectStatus> {
let cutoff = now - since.to_chrono_duration();
statuses
.into_iter()
.filter(|s| match s.last_commit {
Some(dt) => dt >= cutoff,
None => include_empty,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ProjectStatus;
use chrono::{Duration, Utc};
use std::path::PathBuf;
fn make_project(name: &str, days_ago: Option<i64>) -> ProjectStatus {
ProjectStatus {
name: name.to_string(),
path: PathBuf::from(format!("/tmp/{}", name)),
branch: "main".to_string(),
is_clean: true,
changed_files: 0,
last_commit: days_ago.map(|d| Utc::now() - Duration::days(d)),
ahead: 0,
behind: 0,
remote_url: None,
stash_count: 0,
last_commit_message: None,
ci_status: crate::ci::CiStatus::Unknown,
}
}
#[test]
fn test_parse_days() {
let d = parse_duration("7d").unwrap();
assert_eq!(d.count, 7);
assert_eq!(d.unit, DurationUnit::Days);
}
#[test]
fn test_parse_weeks() {
let d = parse_duration("2w").unwrap();
assert_eq!(d.count, 2);
assert_eq!(d.unit, DurationUnit::Weeks);
}
#[test]
fn test_parse_months() {
let d = parse_duration("1m").unwrap();
assert_eq!(d.count, 1);
assert_eq!(d.unit, DurationUnit::Months);
}
#[test]
fn test_parse_long_unit_names() {
assert_eq!(parse_duration("3days").unwrap().unit, DurationUnit::Days);
assert_eq!(parse_duration("1day").unwrap().unit, DurationUnit::Days);
assert_eq!(parse_duration("2weeks").unwrap().unit, DurationUnit::Weeks);
assert_eq!(parse_duration("1week").unwrap().unit, DurationUnit::Weeks);
assert_eq!(
parse_duration("6months").unwrap().unit,
DurationUnit::Months
);
assert_eq!(parse_duration("1month").unwrap().unit, DurationUnit::Months);
}
#[test]
fn test_parse_case_insensitive() {
assert_eq!(parse_duration("7D").unwrap().unit, DurationUnit::Days);
assert_eq!(parse_duration("2W").unwrap().unit, DurationUnit::Weeks);
assert_eq!(parse_duration("1M").unwrap().unit, DurationUnit::Months);
}
#[test]
fn test_parse_with_whitespace() {
let d = parse_duration(" 7d ").unwrap();
assert_eq!(d.count, 7);
assert_eq!(d.unit, DurationUnit::Days);
}
#[test]
fn test_parse_empty_string() {
assert!(parse_duration("").is_err());
}
#[test]
fn test_parse_no_number() {
assert!(parse_duration("d").is_err());
}
#[test]
fn test_parse_no_unit() {
assert!(parse_duration("7").is_err());
}
#[test]
fn test_parse_zero_duration() {
assert!(parse_duration("0d").is_err());
}
#[test]
fn test_parse_invalid_unit() {
assert!(parse_duration("7x").is_err());
assert!(parse_duration("3y").is_err());
assert!(parse_duration("5h").is_err());
}
#[test]
fn test_parse_large_number() {
let d = parse_duration("365d").unwrap();
assert_eq!(d.count, 365);
}
#[test]
fn test_chrono_duration_days() {
let d = SinceDuration {
count: 7,
unit: DurationUnit::Days,
};
assert_eq!(d.to_chrono_duration().num_days(), 7);
}
#[test]
fn test_chrono_duration_weeks() {
let d = SinceDuration {
count: 2,
unit: DurationUnit::Weeks,
};
assert_eq!(d.to_chrono_duration().num_days(), 14);
}
#[test]
fn test_chrono_duration_months() {
let d = SinceDuration {
count: 1,
unit: DurationUnit::Months,
};
assert_eq!(d.to_chrono_duration().num_days(), 30);
}
#[test]
fn test_filter_includes_recent_projects() {
let statuses = vec![
make_project("recent", Some(3)),
make_project("old", Some(60)),
];
let since = parse_duration("7d").unwrap();
let result = filter_since(statuses, &since, Utc::now(), false);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "recent");
}
#[test]
fn test_filter_excludes_no_commit_by_default() {
let statuses = vec![make_project("empty", None), make_project("recent", Some(1))];
let since = parse_duration("7d").unwrap();
let result = filter_since(statuses, &since, Utc::now(), false);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "recent");
}
#[test]
fn test_filter_includes_no_commit_when_flag_set() {
let statuses = vec![make_project("empty", None), make_project("recent", Some(1))];
let since = parse_duration("7d").unwrap();
let result = filter_since(statuses, &since, Utc::now(), true);
assert_eq!(result.len(), 2);
}
#[test]
fn test_filter_boundary_exact_cutoff() {
let now = Utc::now();
let statuses = vec![ProjectStatus {
name: "boundary".to_string(),
path: PathBuf::from("/tmp/boundary"),
branch: "main".to_string(),
is_clean: true,
changed_files: 0,
last_commit: Some(now - Duration::days(7)),
ahead: 0,
behind: 0,
remote_url: None,
stash_count: 0,
last_commit_message: None,
ci_status: crate::ci::CiStatus::Unknown,
}];
let since = parse_duration("7d").unwrap();
let result = filter_since(statuses, &since, now, false);
assert_eq!(result.len(), 1);
}
#[test]
fn test_filter_empty_input() {
let statuses: Vec<ProjectStatus> = vec![];
let since = parse_duration("7d").unwrap();
let result = filter_since(statuses, &since, Utc::now(), false);
assert!(result.is_empty());
}
#[test]
fn test_filter_all_excluded() {
let statuses = vec![
make_project("old1", Some(60)),
make_project("old2", Some(90)),
];
let since = parse_duration("7d").unwrap();
let result = filter_since(statuses, &since, Utc::now(), false);
assert!(result.is_empty());
}
#[test]
fn test_filter_all_included() {
let statuses = vec![
make_project("a", Some(1)),
make_project("b", Some(2)),
make_project("c", Some(3)),
];
let since = parse_duration("30d").unwrap();
let result = filter_since(statuses, &since, Utc::now(), false);
assert_eq!(result.len(), 3);
}
#[test]
fn test_filter_weeks_duration() {
let statuses = vec![
make_project("recent", Some(10)),
make_project("old", Some(30)),
];
let since = parse_duration("2w").unwrap(); let result = filter_since(statuses, &since, Utc::now(), false);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "recent");
}
#[test]
fn test_filter_months_duration() {
let statuses = vec![
make_project("recent", Some(20)),
make_project("old", Some(45)),
];
let since = parse_duration("1m").unwrap(); let result = filter_since(statuses, &since, Utc::now(), false);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "recent");
}
}