use chrono::Utc;
use crossterm::style::{Color, Stylize};
use serde::Serialize;
use crate::types::ProjectStatus;
const STALE_THRESHOLD_DAYS: i64 = 30;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Summary {
pub total: usize,
pub dirty: usize,
pub clean: usize,
pub stale: usize,
pub unpushed: usize,
}
impl Summary {
pub fn from_statuses(statuses: &[ProjectStatus]) -> Self {
let now = Utc::now();
let total = statuses.len();
let dirty = statuses.iter().filter(|s| !s.is_clean).count();
let clean = total - dirty;
let stale = statuses
.iter()
.filter(|s| {
s.last_commit
.map(|dt| (now - dt).num_days() > STALE_THRESHOLD_DAYS)
.unwrap_or(true) })
.count();
let unpushed = statuses.iter().filter(|s| s.ahead > 0).count();
Self {
total,
dirty,
clean,
stale,
unpushed,
}
}
#[allow(dead_code)]
pub fn to_plain_string(&self) -> String {
format!(
"{} projects │ {} dirty │ {} stale │ {} unpushed",
self.total, self.dirty, self.stale, self.unpushed,
)
}
pub fn print_colored(&self) {
let total_part = format!("{} projects", self.total);
let dirty_part = format!("{} dirty", self.dirty);
let dirty_colored = if self.dirty > 0 {
dirty_part.with(Color::Yellow)
} else {
dirty_part.with(Color::Green)
};
let stale_part = format!("{} stale", self.stale);
let stale_colored = if self.stale > 0 {
stale_part.with(Color::Red)
} else {
stale_part.with(Color::Green)
};
let unpushed_part = format!("{} unpushed", self.unpushed);
let unpushed_colored = if self.unpushed > 0 {
unpushed_part.with(Color::Yellow)
} else {
unpushed_part.with(Color::Green)
};
println!(
" {} │ {} │ {} │ {}",
total_part, dirty_colored, stale_colored, unpushed_colored,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ProjectStatus;
use chrono::{Duration, Utc};
use std::path::PathBuf;
fn make_project(
name: &str,
is_clean: bool,
days_ago: Option<i64>,
ahead: usize,
) -> ProjectStatus {
ProjectStatus {
name: name.to_string(),
path: PathBuf::from(format!("/tmp/{}", name)),
branch: "main".to_string(),
is_clean,
changed_files: if is_clean { 0 } else { 2 },
last_commit: days_ago.map(|d| Utc::now() - Duration::days(d)),
ahead,
behind: 0,
remote_url: None,
stash_count: 0,
last_commit_message: None,
ci_status: crate::ci::CiStatus::Unknown,
}
}
#[test]
fn test_empty_statuses() {
let summary = Summary::from_statuses(&[]);
assert_eq!(
summary,
Summary {
total: 0,
dirty: 0,
clean: 0,
stale: 0,
unpushed: 0,
}
);
}
#[test]
fn test_all_clean_active() {
let statuses = vec![
make_project("a", true, Some(1), 0),
make_project("b", true, Some(5), 0),
];
let summary = Summary::from_statuses(&statuses);
assert_eq!(summary.total, 2);
assert_eq!(summary.dirty, 0);
assert_eq!(summary.clean, 2);
assert_eq!(summary.stale, 0);
assert_eq!(summary.unpushed, 0);
}
#[test]
fn test_mixed_statuses() {
let statuses = vec![
make_project("active-clean", true, Some(3), 0),
make_project("active-dirty", false, Some(7), 0),
make_project("stale-clean", true, Some(60), 0),
make_project("unpushed", true, Some(1), 5),
];
let summary = Summary::from_statuses(&statuses);
assert_eq!(summary.total, 4);
assert_eq!(summary.dirty, 1);
assert_eq!(summary.clean, 3);
assert_eq!(summary.stale, 1);
assert_eq!(summary.unpushed, 1);
}
#[test]
fn test_no_commits_counts_as_stale() {
let statuses = vec![make_project("empty", true, None, 0)];
let summary = Summary::from_statuses(&statuses);
assert_eq!(summary.stale, 1);
}
#[test]
fn test_exactly_30_days_is_not_stale() {
let statuses = vec![make_project("borderline", true, Some(30), 0)];
let summary = Summary::from_statuses(&statuses);
assert_eq!(summary.stale, 0);
}
#[test]
fn test_31_days_is_stale() {
let statuses = vec![make_project("old", true, Some(31), 0)];
let summary = Summary::from_statuses(&statuses);
assert_eq!(summary.stale, 1);
}
#[test]
fn test_multiple_unpushed() {
let statuses = vec![
make_project("a", true, Some(1), 3),
make_project("b", true, Some(1), 1),
make_project("c", true, Some(1), 0),
];
let summary = Summary::from_statuses(&statuses);
assert_eq!(summary.unpushed, 2);
}
#[test]
fn test_to_plain_string() {
let summary = Summary {
total: 12,
dirty: 3,
clean: 9,
stale: 2,
unpushed: 1,
};
assert_eq!(
summary.to_plain_string(),
"12 projects │ 3 dirty │ 2 stale │ 1 unpushed"
);
}
#[test]
fn test_to_plain_string_zeros() {
let summary = Summary {
total: 0,
dirty: 0,
clean: 0,
stale: 0,
unpushed: 0,
};
assert_eq!(
summary.to_plain_string(),
"0 projects │ 0 dirty │ 0 stale │ 0 unpushed"
);
}
#[test]
fn test_all_dirty_all_stale_all_unpushed() {
let statuses = vec![
make_project("a", false, Some(100), 2),
make_project("b", false, Some(200), 1),
];
let summary = Summary::from_statuses(&statuses);
assert_eq!(summary.total, 2);
assert_eq!(summary.dirty, 2);
assert_eq!(summary.clean, 0);
assert_eq!(summary.stale, 2);
assert_eq!(summary.unpushed, 2);
}
#[test]
fn test_serialization() {
let summary = Summary {
total: 5,
dirty: 2,
clean: 3,
stale: 1,
unpushed: 0,
};
let json = serde_json::to_string(&summary).expect("should serialize");
assert!(json.contains("\"total\":5"));
assert!(json.contains("\"dirty\":2"));
assert!(json.contains("\"stale\":1"));
}
#[test]
fn test_print_colored_does_not_panic() {
let summary = Summary {
total: 3,
dirty: 1,
clean: 2,
stale: 0,
unpushed: 1,
};
summary.print_colored();
}
}