tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Item types for popup lists.

use std::path::PathBuf;

use ratatui::{
    style::{Color, Style},
    text::{Line, Span},
    widgets::ListItem,
};

use crate::tui::components::ListItemRenderable;
use crate::tui::tabs::StatusIndicator;
use crate::worktree::GitWorktreeStatus;

/// Worktree item for popup display
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeItem {
    /// Display name (directory name from path)
    pub display_name: String,
    /// Branch name
    pub branch: String,
    /// Full path to worktree
    pub path: PathBuf,
    /// Git status (dirty, ahead/behind, etc.)
    pub status: GitWorktreeStatus,
}

impl WorktreeItem {
    /// Create new worktree item from path and branch
    #[must_use]
    pub fn new(branch: impl Into<String>, path: PathBuf, status: GitWorktreeStatus) -> Self {
        let display_name = path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("unknown")
            .to_string();
        Self {
            display_name,
            branch: branch.into(),
            path,
            status,
        }
    }

    /// Format git status as display string (ohmyzsh/magit style)
    ///
    /// Format: `[*↑3↓2]` or `[⊘]` for detached or `[?]` for no upstream
    pub(crate) fn status_display(&self) -> Option<String> {
        let s = &self.status;

        if s.detached {
            return Some("".to_string());
        }

        if s.no_upstream {
            return Some("?".to_string());
        }

        let mut parts = Vec::new();
        if s.dirty {
            parts.push("*".to_string());
        }
        if s.ahead > 0 {
            parts.push(format!("{}", s.ahead));
        }
        if s.behind > 0 {
            parts.push(format!("{}", s.behind));
        }

        if parts.is_empty() {
            None // Clean state - no indicator
        } else {
            Some(parts.join(""))
        }
    }

    /// Get color based on status
    pub(crate) fn status_color(&self) -> Color {
        let s = &self.status;
        if s.dirty {
            Color::Yellow
        } else if s.behind > 0 {
            Color::Red
        } else if s.ahead > 0 {
            Color::Cyan
        } else {
            Color::Green
        }
    }
}

/// Issue item for popup display
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueItem {
    /// Issue number
    pub number: u32,
    /// Issue title
    pub title: String,
    /// Labels (comma-separated for display)
    pub labels: String,
}

impl IssueItem {
    /// Create new issue item
    #[must_use]
    pub fn new(number: u32, title: impl Into<String>, labels: impl Into<String>) -> Self {
        Self {
            number,
            title: title.into(),
            labels: labels.into(),
        }
    }
}

/// Session item for popup list
#[derive(Debug, Clone)]
pub struct SessionItem {
    /// Session name
    pub name: String,
    /// Status indicator
    pub status: StatusIndicator,
    /// Branch name (if associated with a worktree)
    pub branch: Option<String>,
    /// Whether session has pending attention (awaiting user action)
    pub is_pending: bool,
}

impl SessionItem {
    /// Create new session item
    #[must_use]
    pub fn new(name: impl Into<String>, status: StatusIndicator) -> Self {
        Self {
            name: name.into(),
            status,
            branch: None,
            is_pending: false,
        }
    }

    /// Set branch name
    #[must_use]
    pub fn branch(mut self, branch: Option<String>) -> Self {
        self.branch = branch;
        self
    }

    /// Set pending status
    #[must_use]
    pub fn pending(mut self, is_pending: bool) -> Self {
        self.is_pending = is_pending;
        self
    }
}

// ListItemRenderable implementations

impl ListItemRenderable for SessionItem {
    fn to_list_item(&self) -> ListItem<'_> {
        let indicator = Span::styled(
            format!("{} ", self.status.symbol()),
            Style::default().fg(self.status.color()),
        );
        let name = Span::raw(&self.name);
        let mut spans = vec![indicator, name];

        if let Some(branch) = &self.branch {
            spans.push(Span::styled(
                format!(" ({branch})"),
                Style::default().fg(Color::Gray),
            ));
        }

        if self.is_pending {
            spans.push(Span::styled(" [!]", Style::default().fg(Color::Red)));
        }

        ListItem::new(Line::from(spans))
    }
}

impl ListItemRenderable for WorktreeItem {
    fn to_list_item(&self) -> ListItem<'_> {
        let indicator_color = self.status_color();
        let indicator = Span::styled("", Style::default().fg(indicator_color));

        let display_name = Span::styled(
            &self.display_name[..8.min(self.display_name.len())],
            Style::default().fg(Color::Yellow),
        );
        let branch = Span::styled(
            format!(" ({})", self.branch),
            Style::default().fg(Color::Gray),
        );

        let mut spans = vec![indicator, display_name, branch];

        if let Some(status_str) = self.status_display() {
            spans.push(Span::styled(
                format!(" [{status_str}]"),
                Style::default().fg(indicator_color),
            ));
        }

        ListItem::new(Line::from(spans))
    }
}

impl ListItemRenderable for IssueItem {
    fn to_list_item(&self) -> ListItem<'_> {
        let number = Span::styled(
            format!("#{} ", self.number),
            Style::default().fg(Color::Yellow),
        );
        let title = Span::raw(&self.title);
        let mut spans = vec![number, title];

        if !self.labels.is_empty() {
            spans.push(Span::styled(
                format!(" {}", self.labels),
                Style::default().fg(Color::Magenta),
            ));
        }

        ListItem::new(Line::from(spans))
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use rstest::rstest;

    // WorktreeItem tests
    #[test]
    fn worktree_item_new() {
        let item = WorktreeItem::new(
            "tazuna/abc123",
            PathBuf::from("/path/to/worktree"),
            GitWorktreeStatus::default(),
        );
        assert_eq!(item.display_name, "worktree");
        assert_eq!(item.branch, "tazuna/abc123");
        assert_eq!(item.path, PathBuf::from("/path/to/worktree"));
        assert!(!item.status.dirty);
    }

    #[test]
    fn worktree_item_status_display_clean() {
        let item = WorktreeItem::new("main", PathBuf::from("/path"), GitWorktreeStatus::default());
        assert_eq!(item.status_display(), None);
    }

    #[test]
    fn worktree_item_status_display_dirty() {
        let item = WorktreeItem::new(
            "main",
            PathBuf::from("/path"),
            GitWorktreeStatus {
                dirty: true,
                ..Default::default()
            },
        );
        assert_eq!(item.status_display(), Some("*".to_string()));
    }

    #[test]
    fn worktree_item_status_display_ahead_behind() {
        let item = WorktreeItem::new(
            "main",
            PathBuf::from("/path"),
            GitWorktreeStatus {
                ahead: 3,
                behind: 2,
                ..Default::default()
            },
        );
        assert_eq!(item.status_display(), Some("↑3↓2".to_string()));
    }

    #[test]
    fn worktree_item_status_display_detached() {
        let item = WorktreeItem::new(
            "HEAD",
            PathBuf::from("/path"),
            GitWorktreeStatus {
                detached: true,
                ..Default::default()
            },
        );
        assert_eq!(item.status_display(), Some("".to_string()));
    }

    #[test]
    fn worktree_item_status_display_no_upstream() {
        let item = WorktreeItem::new(
            "main",
            PathBuf::from("/path"),
            GitWorktreeStatus {
                no_upstream: true,
                ..Default::default()
            },
        );
        assert_eq!(item.status_display(), Some("?".to_string()));
    }

    // WorktreeItem status_color tests
    #[rstest]
    #[case(GitWorktreeStatus { dirty: true, ..Default::default() }, Color::Yellow)]
    #[case(GitWorktreeStatus { behind: 2, ..Default::default() }, Color::Red)]
    #[case(GitWorktreeStatus { ahead: 1, ..Default::default() }, Color::Cyan)]
    #[case(GitWorktreeStatus::default(), Color::Green)]
    fn worktree_item_status_color(#[case] status: GitWorktreeStatus, #[case] expected: Color) {
        let item = WorktreeItem::new("branch", PathBuf::from("/path"), status);
        assert_eq!(item.status_color(), expected);
    }

    // IssueItem tests
    #[test]
    fn issue_item_new() {
        let item = IssueItem::new(42, "Test issue", "bug, feature");
        assert_eq!(item.number, 42);
        assert_eq!(item.title, "Test issue");
        assert_eq!(item.labels, "bug, feature");
    }

    #[test]
    fn issue_item_new_empty_labels() {
        let item = IssueItem::new(1, "Simple", "");
        assert_eq!(item.number, 1);
        assert_eq!(item.title, "Simple");
        assert!(item.labels.is_empty());
    }

    // SessionItem tests
    #[test]
    fn test_session_item_with_branch() {
        let item = SessionItem::new("my-session", StatusIndicator::Running)
            .branch(Some("feature/auth".to_string()));

        assert_eq!(item.name, "my-session");
        assert_eq!(item.branch, Some("feature/auth".to_string()));
    }

    #[test]
    fn test_session_item_without_branch() {
        let item = SessionItem::new("my-session", StatusIndicator::Running);

        assert_eq!(item.name, "my-session");
        assert_eq!(item.branch, None);
    }

    #[test]
    fn test_session_item_pending() {
        let item = SessionItem::new("my-session", StatusIndicator::Running).pending(true);

        assert!(item.is_pending);
    }

    #[test]
    fn test_session_item_not_pending_by_default() {
        let item = SessionItem::new("my-session", StatusIndicator::Running);

        assert!(!item.is_pending);
    }
}