tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};

use crate::assignee::assignee_display;
use crate::model::Priority;

use super::board::Card;

pub fn priority_glyph(priority: &Priority) -> &'static str {
    match priority {
        Priority::High => "!",
        Priority::Other(s) if s.eq_ignore_ascii_case("urgent") => "!!",
        _ => "",
    }
}

pub fn priority_style(priority: &Priority) -> Style {
    match priority {
        Priority::High => Style::default().fg(Color::Red),
        Priority::Other(s) if s.eq_ignore_ascii_case("urgent") => {
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
        }
        Priority::Low => Style::default().fg(Color::DarkGray),
        _ => Style::default(),
    }
}

pub fn card_line(card: &Card, _width: usize) -> Line<'static> {
    let glyph = priority_glyph(&card.priority);
    let num = format!("#{}", card.number);
    let assignee_str = card
        .assignee
        .as_deref()
        .map(|a| format!(" {}", assignee_display(a)))
        .unwrap_or_default();
    let labels_str: String = card
        .labels
        .iter()
        .take(3)
        .map(|l| format!(" [{l}]"))
        .collect();

    let mut spans: Vec<Span<'static>> = Vec::new();
    spans.push(Span::styled(num, Style::default().fg(Color::Cyan)));
    spans.push(Span::raw(" "));
    spans.push(Span::styled(card.title.clone(), Style::default()));
    if !glyph.is_empty() {
        spans.push(Span::styled(
            format!(" {glyph}"),
            priority_style(&card.priority),
        ));
    }
    if !assignee_str.is_empty() {
        spans.push(Span::styled(
            assignee_str,
            Style::default().fg(Color::Green),
        ));
    }
    if !labels_str.is_empty() {
        spans.push(Span::styled(labels_str, Style::default().fg(Color::Yellow)));
    }

    Line::from(spans)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn card(priority: Priority) -> Card {
        Card {
            number: 1,
            title: "Test".into(),
            priority,
            labels: vec![],
            assignee: None,
            filename: "test.md".into(),
            commit: None,
        }
    }

    #[test]
    fn glyph_high_is_bang() {
        assert_eq!(priority_glyph(&Priority::High), "!");
    }

    #[test]
    fn glyph_urgent_is_double_bang() {
        assert_eq!(priority_glyph(&Priority::Other("urgent".into())), "!!");
    }

    #[test]
    fn glyph_medium_is_empty() {
        assert_eq!(priority_glyph(&Priority::Medium), "");
    }

    #[test]
    fn card_line_renders_number_and_title() {
        let c = card(Priority::Medium);
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("#1"));
        assert!(text.contains("Test"));
    }

    #[test]
    fn card_line_high_priority_shows_glyph() {
        let c = card(Priority::High);
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("!"));
    }

    #[test]
    fn card_line_assignee_shows_at_prefix() {
        let c = Card {
            assignee: Some("alice".into()),
            ..card(Priority::Medium)
        };
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("@alice"));
    }

    #[test]
    fn card_line_identity_shows_name_only() {
        let c = Card {
            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
            ..card(Priority::Medium)
        };
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("Herbert Maschke"));

        assert!(
            !text.contains("thyseus@pm.me"),
            "identity email must not leak into the board line: {text:?}"
        );
    }

    #[test]
    fn card_line_shows_first_three_labels() {
        let c = Card {
            labels: vec!["a".into(), "b".into(), "c".into(), "d".into()],
            ..card(Priority::Medium)
        };
        let line = card_line(&c, 40);
        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(text.contains("[a]"));
        assert!(text.contains("[b]"));
        assert!(text.contains("[c]"));
        assert!(!text.contains("[d]"));
    }
}