//! Output formatting for tickets. //! //! Provides functions for rendering tickets and ticket lists as human-readable //! tables or machine-readable JSON, depending on the caller's preference. //! //! Each `print_*` function writes directly to stdout and is intended for use //! in command handlers. The corresponding `format_*` functions return a //! `String` and are provided primarily for testing and composition. use crate::store::MigrateReport; use crate::ticket::{Status, Ticket, TicketType}; // ── Column widths for the summary list table ───────────────────────────────── /// Width of the ID column (6-char hex + padding). const COL_ID: usize = 9; /// Width of the priority column. const COL_PRI: usize = 5; /// Width of the ticket-type column ("project" = 7 chars, +2 padding). const COL_TYPE: usize = 9; /// Width of the status column ("in_progress" = 11 chars, +2 padding). const COL_STATUS: usize = 13; /// Width of each label in the full ticket view ("Dependencies:" = 13 + 1 space). const LABEL_WIDTH: usize = 14; // ── Internal helpers ────────────────────────────────────────────────────────── /// Return the canonical display string for a [`Status`] variant. /// /// The strings match the serde serialisation: `"todo"`, `"in_progress"`, /// `"done"`. fn status_str(status: &Status) -> &'static str { match status { Status::Todo => "todo", Status::InProgress => "in_progress", Status::Done => "done", Status::Closed => "closed", } } /// Return the canonical display string for a [`TicketType`] variant. /// /// The strings match the serde serialisation: `"project"`, `"feature"`, /// `"task"`, `"bug"`. fn ticket_type_str(ticket_type: &TicketType) -> &'static str { match ticket_type { TicketType::Project => "project", TicketType::Feature => "feature", TicketType::Task => "task", TicketType::Bug => "bug", } } // ── Public formatting functions ─────────────────────────────────────────────── /// Format a single ticket as a human-readable key–value table. /// /// Each field is displayed on its own line with a label padded to /// [`LABEL_WIDTH`] characters. An empty `Dependencies` field is rendered as /// an empty string rather than being omitted. /// /// ```text /// ID: a3f9c2 /// Title: Fix login bug /// Body: Users cannot log in with email addresses containing + /// Priority: 8 /// Status: in_progress /// Type: bug /// Dependencies: b7d41e, c9e823 /// ``` pub fn format_ticket(ticket: &Ticket) -> String { let deps = ticket.dependencies.join(", "); [ format!("{: serde_json::Value { let mut value = serde_json::to_value(ticket).expect("ticket serialisation must not fail"); // Re-insert id at the front of the object for ergonomic CLI output. if let serde_json::Value::Object(ref mut map) = value { // `serde_json::Map` preserves insertion order; insert id first by // rebuilding the map with id prepended. let old_map = std::mem::take(map); map.insert( "id".to_string(), serde_json::Value::String(ticket.id.clone()), ); map.extend(old_map); } value } /// Format a single ticket as a pretty-printed JSON object. /// /// The output is suitable for piping or machine consumption. The `id` field /// is included even though it is not stored in the ticket's JSON file. pub fn format_ticket_json(ticket: &Ticket) -> String { let value = ticket_to_json_value(ticket); serde_json::to_string_pretty(&value).expect("ticket serialisation must not fail") } /// Print a single ticket as pretty-printed JSON to stdout. pub fn print_ticket_json(ticket: &Ticket) { println!("{}", format_ticket_json(ticket)); } /// Format a slice of tickets as a short summary table with a header row. /// /// Columns are `ID`, `PRI`, `TYPE`, `STATUS`, and `TITLE`, each left-aligned /// to a fixed width. Rows are presented in the order given — callers are /// responsible for sorting before passing the slice. An empty slice still /// produces the header row. /// /// ```text /// ID PRI TYPE STATUS TITLE /// a3f9c2 8 bug in_progress Fix login bug /// b7d41e 5 task todo Add rate limiting /// ``` pub fn format_list(tickets: &[Ticket]) -> String { let mut out = format!( "{: String { let values: Vec = tickets.iter().map(ticket_to_json_value).collect(); serde_json::to_string_pretty(&values).expect("ticket list serialisation must not fail") } /// Print a ticket list as a JSON array to stdout. pub fn print_list_json(tickets: &[Ticket]) { println!("{}", format_list_json(tickets)); } /// Format a [`MigrateReport`] as a human-readable summary. /// /// ```text /// Migrated 3 tickets. /// Current 5 tickets (already up to date). /// Errors 1 ticket could not be migrated: /// bad_ticket.json: trailing comma at line 4 /// ``` pub fn format_migrate_report(report: &MigrateReport) -> String { let mut out = format!( "Migrated {} ticket{}.\nCurrent {} ticket{} (already up to date).", report.updated, if report.updated == 1 { "" } else { "s" }, report.already_current, if report.already_current == 1 { "" } else { "s" }, ); if report.skipped > 0 { out.push_str(&format!( "\nSkipped {} ticket{} (did not match filter).", report.skipped, if report.skipped == 1 { "" } else { "s" }, )); } if !report.errors.is_empty() { out.push_str(&format!( "\nErrors {} ticket{} could not be migrated:", report.errors.len(), if report.errors.len() == 1 { "" } else { "s" }, )); for (filename, msg) in &report.errors { out.push_str(&format!("\n {filename}: {msg}")); } } out } /// Print a [`MigrateReport`] as a human-readable summary to stdout. pub fn print_migrate_report(report: &MigrateReport) { println!("{}", format_migrate_report(report)); } /// Format a [`MigrateReport`] as a JSON object. /// /// The JSON has three keys: `updated`, `already_current`, and `errors`. /// `errors` is an array of objects with `filename` and `message` fields. pub fn format_migrate_report_json(report: &MigrateReport) -> String { let errors: Vec = report .errors .iter() .map(|(filename, msg)| { serde_json::json!({ "filename": filename, "message": msg, }) }) .collect(); let value = serde_json::json!({ "updated": report.updated, "already_current": report.already_current, "skipped": report.skipped, "errors": errors, }); serde_json::to_string_pretty(&value).expect("migrate report serialisation must not fail") } /// Print a [`MigrateReport`] as a JSON object to stdout. pub fn print_migrate_report_json(report: &MigrateReport) { println!("{}", format_migrate_report_json(report)); }