use std::path::PathBuf;
use ratatui::style::Color;
pub const STATUS_LEGEND: &str = "Status: [*]=Active, [M]=Main, [-]=Other";
pub const CHANGES_LEGEND: &str = "Changes: M=Modified, D=Deleted, A=Added, U=Untracked";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorktreeStatus {
Active,
Main,
Other,
}
impl WorktreeStatus {
pub fn icon(&self) -> &'static str {
match self {
Self::Active => "*",
Self::Main => "M",
Self::Other => "-",
}
}
pub fn bracketed_icon(&self) -> &'static str {
match self {
Self::Active => "[*]",
Self::Main => "[M]",
Self::Other => "[-]",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Active => "ACTIVE",
Self::Main => "MAIN",
Self::Other => "OTHER",
}
}
pub fn color(&self) -> Color {
match self {
Self::Active => Color::Yellow,
Self::Main => Color::Cyan,
Self::Other => Color::White,
}
}
pub fn ansi_color(&self) -> &'static str {
match self {
Self::Active => "\x1b[33m", Self::Main => "\x1b[36m", Self::Other => "\x1b[37m", }
}
pub fn ansi_bold_color(&self) -> &'static str {
match self {
Self::Active => "\x1b[1;33m", Self::Main => "\x1b[36m", Self::Other => "\x1b[37m", }
}
}
#[derive(Debug, Clone)]
pub struct Worktree {
pub path: PathBuf,
pub branch: String,
pub head: String,
pub status: WorktreeStatus,
pub is_main: bool,
pub sync_status: Option<SyncStatus>,
pub change_status: Option<ChangeStatus>,
pub last_activity: Option<String>,
pub commit_date: Option<String>,
pub committer_name: Option<String>,
pub commit_message: Option<String>,
}
impl Worktree {
pub fn display_branch(&self) -> &str {
self.branch
.strip_prefix("refs/heads/")
.unwrap_or(&self.branch)
}
pub fn short_head(&self) -> &str {
if self.head.len() > 7 {
&self.head[..7]
} else {
&self.head
}
}
}
#[derive(Debug, Clone)]
pub struct PullResult {
pub branch: String,
pub path: PathBuf,
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CleanReason {
RemoteDeleted,
Merged,
}
impl CleanReason {
pub fn label(&self) -> &'static str {
match self {
Self::RemoteDeleted => "remote deleted",
Self::Merged => "merged",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::RemoteDeleted => "Remote branch has been deleted",
Self::Merged => "Branch has been merged into main",
}
}
pub fn ansi_color(&self) -> &'static str {
match self {
Self::RemoteDeleted => "\x1b[31m", Self::Merged => "\x1b[32m", }
}
pub fn color(&self) -> Color {
match self {
Self::RemoteDeleted => Color::Red,
Self::Merged => Color::Green,
}
}
}
#[derive(Debug, Clone)]
pub struct CleanableWorktree {
pub worktree: Worktree,
pub reason: CleanReason,
pub merged_into: Option<String>,
}
impl CleanableWorktree {
pub fn reason_text(&self) -> String {
match self.reason {
CleanReason::RemoteDeleted => "remote deleted".to_string(),
CleanReason::Merged => {
if let Some(ref target) = self.merged_into {
format!("merged into {}", target)
} else {
"merged".to_string()
}
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SyncStatus {
pub ahead: usize,
pub behind: usize,
}
impl SyncStatus {
pub fn is_synced(&self) -> bool {
self.ahead == 0 && self.behind == 0
}
pub fn display(&self) -> String {
if self.is_synced() {
"✓".to_string()
} else {
format!("↑{} ↓{}", self.ahead, self.behind)
}
}
}
#[derive(Debug, Clone)]
pub struct ChangedFile {
pub status: char,
pub path: String,
}
impl ChangedFile {
pub fn status_color(&self) -> Color {
match self.status {
'M' => Color::Yellow,
'A' => Color::Green,
'D' => Color::Red,
'?' => Color::Magenta,
_ => Color::White,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ChangeStatus {
pub modified: usize,
pub added: usize,
pub deleted: usize,
pub untracked: usize,
pub changed_files: Vec<ChangedFile>,
}
impl ChangeStatus {
pub fn is_clean(&self) -> bool {
self.modified == 0 && self.added == 0 && self.deleted == 0 && self.untracked == 0
}
pub fn display(&self) -> String {
if self.is_clean() {
"clean".to_string()
} else {
let mut parts = Vec::new();
if self.modified > 0 {
parts.push(format!("{}M", self.modified));
}
if self.added > 0 {
parts.push(format!("{}A", self.added));
}
if self.deleted > 0 {
parts.push(format!("{}D", self.deleted));
}
if self.untracked > 0 {
parts.push(format!("{}U", self.untracked));
}
parts.join(" ")
}
}
pub fn status_label(&self) -> &'static str {
if self.is_clean() {
"Clean"
} else if self.untracked > 0 && self.modified == 0 && self.added == 0 && self.deleted == 0 {
"Untracked"
} else {
"Modified"
}
}
pub fn status_color(&self) -> Color {
if self.is_clean() {
Color::Green
} else if self.untracked > 0 && self.modified == 0 && self.added == 0 && self.deleted == 0 {
Color::Red
} else {
Color::Yellow
}
}
}
#[derive(Debug, Default)]
pub struct LocalChanges {
pub has_unstaged_changes: bool,
pub has_untracked_files: bool,
pub has_staged_changes: bool,
pub has_local_commits: bool,
}
impl LocalChanges {
pub fn has_any(&self) -> bool {
self.has_unstaged_changes
|| self.has_untracked_files
|| self.has_staged_changes
|| self.has_local_commits
}
pub fn summary(&self) -> Vec<String> {
let mut items = Vec::new();
if self.has_staged_changes {
items.push("staged changes".to_string());
}
if self.has_unstaged_changes {
items.push("unstaged changes".to_string());
}
if self.has_untracked_files {
items.push("untracked files".to_string());
}
if self.has_local_commits {
items.push("unpushed commits".to_string());
}
items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_worktree_status_icon() {
assert_eq!(WorktreeStatus::Active.icon(), "*");
assert_eq!(WorktreeStatus::Main.icon(), "M");
assert_eq!(WorktreeStatus::Other.icon(), "-");
}
#[test]
fn test_worktree_status_bracketed_icon() {
assert_eq!(WorktreeStatus::Active.bracketed_icon(), "[*]");
assert_eq!(WorktreeStatus::Main.bracketed_icon(), "[M]");
assert_eq!(WorktreeStatus::Other.bracketed_icon(), "[-]");
}
#[test]
fn test_worktree_status_label() {
assert_eq!(WorktreeStatus::Active.label(), "ACTIVE");
assert_eq!(WorktreeStatus::Main.label(), "MAIN");
assert_eq!(WorktreeStatus::Other.label(), "OTHER");
}
#[test]
fn test_worktree_status_color() {
assert_eq!(WorktreeStatus::Active.color(), Color::Yellow);
assert_eq!(WorktreeStatus::Main.color(), Color::Cyan);
assert_eq!(WorktreeStatus::Other.color(), Color::White);
}
#[test]
fn test_worktree_status_ansi_color() {
assert_eq!(WorktreeStatus::Active.ansi_color(), "\x1b[33m");
assert_eq!(WorktreeStatus::Main.ansi_color(), "\x1b[36m");
assert_eq!(WorktreeStatus::Other.ansi_color(), "\x1b[37m");
}
#[test]
fn test_worktree_status_ansi_bold_color() {
assert_eq!(WorktreeStatus::Active.ansi_bold_color(), "\x1b[1;33m");
assert_eq!(WorktreeStatus::Main.ansi_bold_color(), "\x1b[36m");
assert_eq!(WorktreeStatus::Other.ansi_bold_color(), "\x1b[37m");
}
#[test]
fn test_display_branch_with_refs_prefix() {
let worktree = Worktree {
path: PathBuf::from("/test"),
branch: "refs/heads/feature/test".to_string(),
head: "abc1234".to_string(),
status: WorktreeStatus::Other,
is_main: false,
sync_status: None,
change_status: None,
last_activity: None,
commit_date: None,
committer_name: None,
commit_message: None,
};
assert_eq!(worktree.display_branch(), "feature/test");
}
#[test]
fn test_display_branch_without_prefix() {
let worktree = Worktree {
path: PathBuf::from("/test"),
branch: "(detached)".to_string(),
head: "abc1234".to_string(),
status: WorktreeStatus::Other,
is_main: false,
sync_status: None,
change_status: None,
last_activity: None,
commit_date: None,
committer_name: None,
commit_message: None,
};
assert_eq!(worktree.display_branch(), "(detached)");
}
#[test]
fn test_short_head_long() {
let worktree = Worktree {
path: PathBuf::from("/test"),
branch: "main".to_string(),
head: "abc1234567890".to_string(),
status: WorktreeStatus::Main,
is_main: true,
sync_status: None,
change_status: None,
last_activity: None,
commit_date: None,
committer_name: None,
commit_message: None,
};
assert_eq!(worktree.short_head(), "abc1234");
}
#[test]
fn test_short_head_short() {
let worktree = Worktree {
path: PathBuf::from("/test"),
branch: "main".to_string(),
head: "abc".to_string(),
status: WorktreeStatus::Main,
is_main: true,
sync_status: None,
change_status: None,
last_activity: None,
commit_date: None,
committer_name: None,
commit_message: None,
};
assert_eq!(worktree.short_head(), "abc");
}
#[test]
fn test_short_head_exact_seven() {
let worktree = Worktree {
path: PathBuf::from("/test"),
branch: "main".to_string(),
head: "abc1234".to_string(),
status: WorktreeStatus::Main,
is_main: true,
sync_status: None,
change_status: None,
last_activity: None,
commit_date: None,
committer_name: None,
commit_message: None,
};
assert_eq!(worktree.short_head(), "abc1234");
}
#[test]
fn test_sync_status_display() {
let synced = SyncStatus {
ahead: 0,
behind: 0,
};
assert!(synced.is_synced());
assert_eq!(synced.display(), "✓");
let not_synced = SyncStatus {
ahead: 2,
behind: 3,
};
assert!(!not_synced.is_synced());
assert_eq!(not_synced.display(), "↑2 ↓3");
}
#[test]
fn test_change_status_display() {
let clean = ChangeStatus::default();
assert!(clean.is_clean());
assert_eq!(clean.display(), "clean");
let with_changes = ChangeStatus {
modified: 3,
added: 1,
deleted: 2,
untracked: 0,
changed_files: vec![],
};
assert!(!with_changes.is_clean());
assert_eq!(with_changes.display(), "3M 1A 2D");
}
#[test]
fn test_change_status_label_and_color() {
let clean = ChangeStatus::default();
assert_eq!(clean.status_label(), "Clean");
assert_eq!(clean.status_color(), Color::Green);
let modified = ChangeStatus {
modified: 1,
added: 0,
deleted: 0,
untracked: 0,
changed_files: vec![],
};
assert_eq!(modified.status_label(), "Modified");
assert_eq!(modified.status_color(), Color::Yellow);
let untracked = ChangeStatus {
modified: 0,
added: 0,
deleted: 0,
untracked: 2,
changed_files: vec![],
};
assert_eq!(untracked.status_label(), "Untracked");
assert_eq!(untracked.status_color(), Color::Red);
let mixed = ChangeStatus {
modified: 1,
added: 0,
deleted: 0,
untracked: 1,
changed_files: vec![],
};
assert_eq!(mixed.status_label(), "Modified");
assert_eq!(mixed.status_color(), Color::Yellow);
}
#[test]
fn test_changed_file_status_color() {
assert_eq!(
ChangedFile {
status: 'M',
path: "test.rs".to_string()
}
.status_color(),
Color::Yellow
);
assert_eq!(
ChangedFile {
status: 'A',
path: "new.rs".to_string()
}
.status_color(),
Color::Green
);
assert_eq!(
ChangedFile {
status: 'D',
path: "deleted.rs".to_string()
}
.status_color(),
Color::Red
);
assert_eq!(
ChangedFile {
status: '?',
path: "untracked.rs".to_string()
}
.status_color(),
Color::Magenta
);
}
}