//! List command - display issues/MRs from local database. use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table}; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::paths::get_db_path; use crate::core::time::{ms_to_iso, now_ms, parse_since}; /// Issue row for display. #[derive(Debug, Serialize)] pub struct IssueListRow { pub iid: i64, pub title: String, pub state: String, pub author_username: String, pub created_at: i64, pub updated_at: i64, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub discussion_count: i64, pub unresolved_count: i64, } /// Serializable version for JSON output. #[derive(Serialize)] pub struct IssueListRowJson { pub iid: i64, pub title: String, pub state: String, pub author_username: String, pub labels: Vec, pub assignees: Vec, pub discussion_count: i64, pub unresolved_count: i64, pub created_at_iso: String, pub updated_at_iso: String, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, pub project_path: String, } impl From<&IssueListRow> for IssueListRowJson { fn from(row: &IssueListRow) -> Self { Self { iid: row.iid, title: row.title.clone(), state: row.state.clone(), author_username: row.author_username.clone(), labels: row.labels.clone(), assignees: row.assignees.clone(), discussion_count: row.discussion_count, unresolved_count: row.unresolved_count, created_at_iso: ms_to_iso(row.created_at), updated_at_iso: ms_to_iso(row.updated_at), web_url: row.web_url.clone(), project_path: row.project_path.clone(), } } } /// Result of list query. #[derive(Serialize)] pub struct ListResult { pub issues: Vec, pub total_count: usize, } /// JSON output structure. #[derive(Serialize)] pub struct ListResultJson { pub issues: Vec, pub total_count: usize, pub showing: usize, } impl From<&ListResult> for ListResultJson { fn from(result: &ListResult) -> Self { Self { issues: result.issues.iter().map(IssueListRowJson::from).collect(), total_count: result.total_count, showing: result.issues.len(), } } } /// MR row for display. #[derive(Debug, Serialize)] pub struct MrListRow { pub iid: i64, pub title: String, pub state: String, pub draft: bool, pub author_username: String, pub source_branch: String, pub target_branch: String, pub created_at: i64, pub updated_at: i64, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, pub discussion_count: i64, pub unresolved_count: i64, } /// Serializable version for JSON output. #[derive(Serialize)] pub struct MrListRowJson { pub iid: i64, pub title: String, pub state: String, pub draft: bool, pub author_username: String, pub source_branch: String, pub target_branch: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, pub discussion_count: i64, pub unresolved_count: i64, pub created_at_iso: String, pub updated_at_iso: String, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, pub project_path: String, } impl From<&MrListRow> for MrListRowJson { fn from(row: &MrListRow) -> Self { Self { iid: row.iid, title: row.title.clone(), state: row.state.clone(), draft: row.draft, author_username: row.author_username.clone(), source_branch: row.source_branch.clone(), target_branch: row.target_branch.clone(), labels: row.labels.clone(), assignees: row.assignees.clone(), reviewers: row.reviewers.clone(), discussion_count: row.discussion_count, unresolved_count: row.unresolved_count, created_at_iso: ms_to_iso(row.created_at), updated_at_iso: ms_to_iso(row.updated_at), web_url: row.web_url.clone(), project_path: row.project_path.clone(), } } } /// Result of MR list query. #[derive(Serialize)] pub struct MrListResult { pub mrs: Vec, pub total_count: usize, } /// JSON output structure for MRs. #[derive(Serialize)] pub struct MrListResultJson { pub mrs: Vec, pub total_count: usize, pub showing: usize, } impl From<&MrListResult> for MrListResultJson { fn from(result: &MrListResult) -> Self { Self { mrs: result.mrs.iter().map(MrListRowJson::from).collect(), total_count: result.total_count, showing: result.mrs.len(), } } } /// Filter options for issue list query. pub struct ListFilters<'a> { pub limit: usize, pub project: Option<&'a str>, pub state: Option<&'a str>, pub author: Option<&'a str>, pub assignee: Option<&'a str>, pub labels: Option<&'a [String]>, pub milestone: Option<&'a str>, pub since: Option<&'a str>, pub due_before: Option<&'a str>, pub has_due_date: bool, pub sort: &'a str, pub order: &'a str, } /// Filter options for MR list query. pub struct MrListFilters<'a> { pub limit: usize, pub project: Option<&'a str>, pub state: Option<&'a str>, pub author: Option<&'a str>, pub assignee: Option<&'a str>, pub reviewer: Option<&'a str>, pub labels: Option<&'a [String]>, pub since: Option<&'a str>, pub draft: bool, pub no_draft: bool, pub target_branch: Option<&'a str>, pub source_branch: Option<&'a str>, pub sort: &'a str, pub order: &'a str, } /// Run the list issues command. pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let result = query_issues(&conn, &filters)?; Ok(result) } /// Query issues from database with enriched data. fn query_issues(conn: &Connection, filters: &ListFilters) -> Result { // Build WHERE clause let mut where_clauses = Vec::new(); let mut params: Vec> = Vec::new(); if let Some(project) = filters.project { // Exact match or suffix match after '/' to avoid partial matches // e.g. "foo" matches "group/foo" but NOT "group/foobar" where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)"); params.push(Box::new(project.to_string())); params.push(Box::new(format!("%/{project}"))); } if let Some(state) = filters.state && state != "all" { where_clauses.push("i.state = ?"); params.push(Box::new(state.to_string())); } // Handle author filter (strip leading @ if present) if let Some(author) = filters.author { let username = author.strip_prefix('@').unwrap_or(author); where_clauses.push("i.author_username = ?"); params.push(Box::new(username.to_string())); } // Handle assignee filter (strip leading @ if present) if let Some(assignee) = filters.assignee { let username = assignee.strip_prefix('@').unwrap_or(assignee); where_clauses.push( "EXISTS (SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.username = ?)", ); params.push(Box::new(username.to_string())); } // Handle since filter if let Some(since_str) = filters.since && let Some(cutoff_ms) = parse_since(since_str) { where_clauses.push("i.updated_at >= ?"); params.push(Box::new(cutoff_ms)); } // Handle label filters (AND logic - all labels must be present) if let Some(labels) = filters.labels { for label in labels { where_clauses.push( "EXISTS (SELECT 1 FROM issue_labels il JOIN labels l ON il.label_id = l.id WHERE il.issue_id = i.id AND l.name = ?)", ); params.push(Box::new(label.clone())); } } // Handle milestone filter if let Some(milestone) = filters.milestone { where_clauses.push("i.milestone_title = ?"); params.push(Box::new(milestone.to_string())); } // Handle due_before filter if let Some(due_before) = filters.due_before { where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?"); params.push(Box::new(due_before.to_string())); } // Handle has_due_date filter if filters.has_due_date { where_clauses.push("i.due_date IS NOT NULL"); } let where_sql = if where_clauses.is_empty() { String::new() } else { format!("WHERE {}", where_clauses.join(" AND ")) }; // Get total count let count_sql = format!( "SELECT COUNT(*) FROM issues i JOIN projects p ON i.project_id = p.id {where_sql}" ); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; let total_count = total_count as usize; // Build ORDER BY let sort_column = match filters.sort { "created" => "i.created_at", "iid" => "i.iid", _ => "i.updated_at", // default }; let order = if filters.order == "asc" { "ASC" } else { "DESC" }; // Get issues with enriched data let query_sql = format!( "SELECT i.iid, i.title, i.state, i.author_username, i.created_at, i.updated_at, i.web_url, p.path_with_namespace, (SELECT GROUP_CONCAT(l.name, X'1F') FROM issue_labels il JOIN labels l ON il.label_id = l.id WHERE il.issue_id = i.id) AS labels_csv, (SELECT GROUP_CONCAT(ia.username, X'1F') FROM issue_assignees ia WHERE ia.issue_id = i.id) AS assignees_csv, COALESCE(d.total, 0) AS discussion_count, COALESCE(d.unresolved, 0) AS unresolved_count FROM issues i JOIN projects p ON i.project_id = p.id LEFT JOIN ( SELECT issue_id, COUNT(*) as total, SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved FROM discussions WHERE issue_id IS NOT NULL GROUP BY issue_id ) d ON d.issue_id = i.id {where_sql} ORDER BY {sort_column} {order} LIMIT ?" ); params.push(Box::new(filters.limit as i64)); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&query_sql)?; let issues: Vec = stmt .query_map(param_refs.as_slice(), |row| { let labels_csv: Option = row.get(8)?; let labels = labels_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let assignees_csv: Option = row.get(9)?; let assignees = assignees_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); Ok(IssueListRow { iid: row.get(0)?, title: row.get(1)?, state: row.get(2)?, author_username: row.get(3)?, created_at: row.get(4)?, updated_at: row.get(5)?, web_url: row.get(6)?, project_path: row.get(7)?, labels, assignees, discussion_count: row.get(10)?, unresolved_count: row.get(11)?, }) })? .collect::, _>>()?; Ok(ListResult { issues, total_count, }) } /// Run the list MRs command. pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let result = query_mrs(&conn, &filters)?; Ok(result) } /// Query MRs from database with enriched data. fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result { // Build WHERE clause let mut where_clauses = Vec::new(); let mut params: Vec> = Vec::new(); if let Some(project) = filters.project { // Exact match or suffix match after '/' to avoid partial matches // e.g. "foo" matches "group/foo" but NOT "group/foobar" where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)"); params.push(Box::new(project.to_string())); params.push(Box::new(format!("%/{project}"))); } if let Some(state) = filters.state && state != "all" { where_clauses.push("m.state = ?"); params.push(Box::new(state.to_string())); } // Handle author filter (strip leading @ if present) if let Some(author) = filters.author { let username = author.strip_prefix('@').unwrap_or(author); where_clauses.push("m.author_username = ?"); params.push(Box::new(username.to_string())); } // Handle assignee filter (strip leading @ if present) if let Some(assignee) = filters.assignee { let username = assignee.strip_prefix('@').unwrap_or(assignee); where_clauses.push( "EXISTS (SELECT 1 FROM mr_assignees ma WHERE ma.merge_request_id = m.id AND ma.username = ?)", ); params.push(Box::new(username.to_string())); } // Handle reviewer filter (strip leading @ if present) if let Some(reviewer) = filters.reviewer { let username = reviewer.strip_prefix('@').unwrap_or(reviewer); where_clauses.push( "EXISTS (SELECT 1 FROM mr_reviewers mr WHERE mr.merge_request_id = m.id AND mr.username = ?)", ); params.push(Box::new(username.to_string())); } // Handle since filter if let Some(since_str) = filters.since && let Some(cutoff_ms) = parse_since(since_str) { where_clauses.push("m.updated_at >= ?"); params.push(Box::new(cutoff_ms)); } // Handle label filters (AND logic - all labels must be present) if let Some(labels) = filters.labels { for label in labels { where_clauses.push( "EXISTS (SELECT 1 FROM mr_labels ml JOIN labels l ON ml.label_id = l.id WHERE ml.merge_request_id = m.id AND l.name = ?)", ); params.push(Box::new(label.clone())); } } // Handle draft filter if filters.draft { where_clauses.push("m.draft = 1"); } else if filters.no_draft { where_clauses.push("m.draft = 0"); } // Handle target branch filter if let Some(target_branch) = filters.target_branch { where_clauses.push("m.target_branch = ?"); params.push(Box::new(target_branch.to_string())); } // Handle source branch filter if let Some(source_branch) = filters.source_branch { where_clauses.push("m.source_branch = ?"); params.push(Box::new(source_branch.to_string())); } let where_sql = if where_clauses.is_empty() { String::new() } else { format!("WHERE {}", where_clauses.join(" AND ")) }; // Get total count let count_sql = format!( "SELECT COUNT(*) FROM merge_requests m JOIN projects p ON m.project_id = p.id {where_sql}" ); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; let total_count = total_count as usize; // Build ORDER BY let sort_column = match filters.sort { "created" => "m.created_at", "iid" => "m.iid", _ => "m.updated_at", // default }; let order = if filters.order == "asc" { "ASC" } else { "DESC" }; // Get MRs with enriched data let query_sql = format!( "SELECT m.iid, m.title, m.state, m.draft, m.author_username, m.source_branch, m.target_branch, m.created_at, m.updated_at, m.web_url, p.path_with_namespace, (SELECT GROUP_CONCAT(l.name, X'1F') FROM mr_labels ml JOIN labels l ON ml.label_id = l.id WHERE ml.merge_request_id = m.id) AS labels_csv, (SELECT GROUP_CONCAT(ma.username, X'1F') FROM mr_assignees ma WHERE ma.merge_request_id = m.id) AS assignees_csv, (SELECT GROUP_CONCAT(mr.username, X'1F') FROM mr_reviewers mr WHERE mr.merge_request_id = m.id) AS reviewers_csv, COALESCE(d.total, 0) AS discussion_count, COALESCE(d.unresolved, 0) AS unresolved_count FROM merge_requests m JOIN projects p ON m.project_id = p.id LEFT JOIN ( SELECT merge_request_id, COUNT(*) as total, SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved FROM discussions WHERE merge_request_id IS NOT NULL GROUP BY merge_request_id ) d ON d.merge_request_id = m.id {where_sql} ORDER BY {sort_column} {order} LIMIT ?" ); params.push(Box::new(filters.limit as i64)); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&query_sql)?; let mrs: Vec = stmt .query_map(param_refs.as_slice(), |row| { let labels_csv: Option = row.get(11)?; let labels = labels_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let assignees_csv: Option = row.get(12)?; let assignees = assignees_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let reviewers_csv: Option = row.get(13)?; let reviewers = reviewers_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let draft_int: i64 = row.get(3)?; Ok(MrListRow { iid: row.get(0)?, title: row.get(1)?, state: row.get(2)?, draft: draft_int == 1, author_username: row.get(4)?, source_branch: row.get(5)?, target_branch: row.get(6)?, created_at: row.get(7)?, updated_at: row.get(8)?, web_url: row.get(9)?, project_path: row.get(10)?, labels, assignees, reviewers, discussion_count: row.get(14)?, unresolved_count: row.get(15)?, }) })? .collect::, _>>()?; Ok(MrListResult { mrs, total_count }) } /// Format relative time from ms epoch. fn format_relative_time(ms_epoch: i64) -> String { let now = now_ms(); let diff = now - ms_epoch; if diff < 0 { return "in the future".to_string(); } match diff { d if d < 60_000 => "just now".to_string(), d if d < 3_600_000 => format!("{} min ago", d / 60_000), d if d < 86_400_000 => format!("{} hours ago", d / 3_600_000), d if d < 604_800_000 => format!("{} days ago", d / 86_400_000), d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000), _ => format!("{} months ago", diff / 2_592_000_000), } } /// Truncate string to max width with ellipsis. fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { if s.chars().count() <= max_width { s.to_string() } else { let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect(); format!("{truncated}...") } } /// Format labels for display: [bug, urgent +2] fn format_labels(labels: &[String], max_shown: usize) -> String { if labels.is_empty() { return String::new(); } let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect(); let overflow = labels.len().saturating_sub(max_shown); if overflow > 0 { format!("[{} +{}]", shown.join(", "), overflow) } else { format!("[{}]", shown.join(", ")) } } /// Format assignees for display: @user1, @user2 +1 fn format_assignees(assignees: &[String]) -> String { if assignees.is_empty() { return "-".to_string(); } let max_shown = 2; let shown: Vec = assignees .iter() .take(max_shown) .map(|s| format!("@{}", truncate_with_ellipsis(s, 10))) .collect(); let overflow = assignees.len().saturating_sub(max_shown); if overflow > 0 { format!("{} +{}", shown.join(", "), overflow) } else { shown.join(", ") } } /// Format discussion count: "3/1!" (3 total, 1 unresolved) fn format_discussions(total: i64, unresolved: i64) -> String { if total == 0 { return String::new(); } if unresolved > 0 { format!("{total}/{unresolved}!") } else { format!("{total}") } } /// Format branch info: target <- source fn format_branches(target: &str, source: &str, max_width: usize) -> String { let full = format!("{} <- {}", target, source); truncate_with_ellipsis(&full, max_width) } /// Print issues list as a formatted table. pub fn print_list_issues(result: &ListResult) { if result.issues.is_empty() { println!("No issues found."); return; } println!( "Issues (showing {} of {})\n", result.issues.len(), result.total_count ); let mut table = Table::new(); table .set_content_arrangement(ContentArrangement::Dynamic) .set_header(vec![ Cell::new("IID").add_attribute(Attribute::Bold), Cell::new("Title").add_attribute(Attribute::Bold), Cell::new("State").add_attribute(Attribute::Bold), Cell::new("Assignee").add_attribute(Attribute::Bold), Cell::new("Labels").add_attribute(Attribute::Bold), Cell::new("Disc").add_attribute(Attribute::Bold), Cell::new("Updated").add_attribute(Attribute::Bold), ]); for issue in &result.issues { let title = truncate_with_ellipsis(&issue.title, 45); let relative_time = format_relative_time(issue.updated_at); let labels = format_labels(&issue.labels, 2); let assignee = format_assignees(&issue.assignees); let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); let state_cell = if issue.state == "opened" { Cell::new(&issue.state).fg(Color::Green) } else { Cell::new(&issue.state).fg(Color::DarkGrey) }; table.add_row(vec![ Cell::new(format!("#{}", issue.iid)).fg(Color::Cyan), Cell::new(title), state_cell, Cell::new(assignee).fg(Color::Magenta), Cell::new(labels).fg(Color::Yellow), Cell::new(discussions), Cell::new(relative_time).fg(Color::DarkGrey), ]); } println!("{table}"); } /// Print issues list as JSON. pub fn print_list_issues_json(result: &ListResult) { let json_result = ListResultJson::from(result); match serde_json::to_string_pretty(&json_result) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } /// Open issue in browser. Returns the URL that was opened. pub fn open_issue_in_browser(result: &ListResult) -> Option { let first_issue = result.issues.first()?; let url = first_issue.web_url.as_ref()?; match open::that(url) { Ok(()) => { println!("Opened: {url}"); Some(url.clone()) } Err(e) => { eprintln!("Failed to open browser: {e}"); None } } } /// Print MRs list as a formatted table. pub fn print_list_mrs(result: &MrListResult) { if result.mrs.is_empty() { println!("No merge requests found."); return; } println!( "Merge Requests (showing {} of {})\n", result.mrs.len(), result.total_count ); let mut table = Table::new(); table .set_content_arrangement(ContentArrangement::Dynamic) .set_header(vec![ Cell::new("IID").add_attribute(Attribute::Bold), Cell::new("Title").add_attribute(Attribute::Bold), Cell::new("State").add_attribute(Attribute::Bold), Cell::new("Author").add_attribute(Attribute::Bold), Cell::new("Branches").add_attribute(Attribute::Bold), Cell::new("Disc").add_attribute(Attribute::Bold), Cell::new("Updated").add_attribute(Attribute::Bold), ]); for mr in &result.mrs { // Add [DRAFT] prefix for draft MRs let title = if mr.draft { format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38)) } else { truncate_with_ellipsis(&mr.title, 45) }; let relative_time = format_relative_time(mr.updated_at); let branches = format_branches(&mr.target_branch, &mr.source_branch, 25); let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); let state_cell = match mr.state.as_str() { "opened" => Cell::new(&mr.state).fg(Color::Green), "merged" => Cell::new(&mr.state).fg(Color::Magenta), "closed" => Cell::new(&mr.state).fg(Color::Red), "locked" => Cell::new(&mr.state).fg(Color::Yellow), _ => Cell::new(&mr.state).fg(Color::DarkGrey), }; table.add_row(vec![ Cell::new(format!("!{}", mr.iid)).fg(Color::Cyan), Cell::new(title), state_cell, Cell::new(format!( "@{}", truncate_with_ellipsis(&mr.author_username, 12) )) .fg(Color::Magenta), Cell::new(branches).fg(Color::Blue), Cell::new(discussions), Cell::new(relative_time).fg(Color::DarkGrey), ]); } println!("{table}"); } /// Print MRs list as JSON. pub fn print_list_mrs_json(result: &MrListResult) { let json_result = MrListResultJson::from(result); match serde_json::to_string_pretty(&json_result) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } /// Open MR in browser. Returns the URL that was opened. pub fn open_mr_in_browser(result: &MrListResult) -> Option { let first_mr = result.mrs.first()?; let url = first_mr.web_url.as_ref()?; match open::that(url) { Ok(()) => { println!("Opened: {url}"); Some(url.clone()) } Err(e) => { eprintln!("Failed to open browser: {e}"); None } } } #[cfg(test)] mod tests { use super::*; #[test] fn truncate_leaves_short_strings_alone() { assert_eq!(truncate_with_ellipsis("short", 10), "short"); } #[test] fn truncate_adds_ellipsis_to_long_strings() { assert_eq!( truncate_with_ellipsis("this is a very long title", 15), "this is a ve..." ); } #[test] fn truncate_handles_exact_length() { assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!"); } #[test] fn relative_time_formats_correctly() { let now = now_ms(); assert_eq!(format_relative_time(now - 30_000), "just now"); // 30s ago assert_eq!(format_relative_time(now - 120_000), "2 min ago"); // 2 min ago assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); // 2 hours ago assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); // 2 days ago } #[test] fn format_labels_empty() { assert_eq!(format_labels(&[], 2), ""); } #[test] fn format_labels_single() { assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]"); } #[test] fn format_labels_multiple() { let labels = vec!["bug".to_string(), "urgent".to_string()]; assert_eq!(format_labels(&labels, 2), "[bug, urgent]"); } #[test] fn format_labels_overflow() { let labels = vec![ "bug".to_string(), "urgent".to_string(), "wip".to_string(), "blocked".to_string(), ]; assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]"); } #[test] fn format_discussions_empty() { assert_eq!(format_discussions(0, 0), ""); } #[test] fn format_discussions_no_unresolved() { assert_eq!(format_discussions(5, 0), "5"); } #[test] fn format_discussions_with_unresolved() { assert_eq!(format_discussions(5, 2), "5/2!"); } }