|
|
//! 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!("{:<LABEL_WIDTH$}{}", "ID:", ticket.id),
|
|
|
format!("{:<LABEL_WIDTH$}{}", "Title:", ticket.title),
|
|
|
format!("{:<LABEL_WIDTH$}{}", "Body:", ticket.body),
|
|
|
format!("{:<LABEL_WIDTH$}{}", "Priority:", ticket.priority),
|
|
|
format!("{:<LABEL_WIDTH$}{}", "Status:", status_str(&ticket.status)),
|
|
|
format!(
|
|
|
"{:<LABEL_WIDTH$}{}",
|
|
|
"Type:",
|
|
|
ticket_type_str(&ticket.ticket_type)
|
|
|
),
|
|
|
format!("{:<LABEL_WIDTH$}{}", "Dependencies:", deps),
|
|
|
]
|
|
|
.join("\n")
|
|
|
}
|
|
|
|
|
|
/// Print a full tabular representation of a single ticket to stdout.
|
|
|
pub fn print_ticket(ticket: &Ticket) {
|
|
|
println!("{}", format_ticket(ticket));
|
|
|
}
|
|
|
|
|
|
/// Serialise a ticket as a [`serde_json::Value`], explicitly inserting the
|
|
|
/// `id` field at the front.
|
|
|
///
|
|
|
/// [`crate::ticket::Ticket::id`] is annotated with `#[serde(skip)]` so that
|
|
|
/// the stored `.json` files do not duplicate the filename stem. However,
|
|
|
/// CLI `--json` output should include `id` so that consumers have all the
|
|
|
/// information in one object. This helper re-inserts it into the serialised
|
|
|
/// value.
|
|
|
pub(crate) fn ticket_to_json_value(ticket: &Ticket) -> 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!(
|
|
|
"{:<width_id$}{:<width_pri$}{:<width_type$}{:<width_status$}{}",
|
|
|
"ID",
|
|
|
"PRI",
|
|
|
"TYPE",
|
|
|
"STATUS",
|
|
|
"TITLE",
|
|
|
width_id = COL_ID,
|
|
|
width_pri = COL_PRI,
|
|
|
width_type = COL_TYPE,
|
|
|
width_status = COL_STATUS,
|
|
|
);
|
|
|
|
|
|
for ticket in tickets {
|
|
|
out.push('\n');
|
|
|
out.push_str(&format!(
|
|
|
"{:<width_id$}{:<width_pri$}{:<width_type$}{:<width_status$}{}",
|
|
|
ticket.id,
|
|
|
ticket.priority,
|
|
|
ticket_type_str(&ticket.ticket_type),
|
|
|
status_str(&ticket.status),
|
|
|
ticket.title,
|
|
|
width_id = COL_ID,
|
|
|
width_pri = COL_PRI,
|
|
|
width_type = COL_TYPE,
|
|
|
width_status = COL_STATUS,
|
|
|
));
|
|
|
}
|
|
|
|
|
|
out
|
|
|
}
|
|
|
|
|
|
/// Print a short summary table of multiple tickets to stdout.
|
|
|
pub fn print_list(tickets: &[Ticket]) {
|
|
|
println!("{}", format_list(tickets));
|
|
|
}
|
|
|
|
|
|
/// Format a slice of tickets as a pretty-printed JSON array.
|
|
|
///
|
|
|
/// Each object includes an `id` field even though it is not stored in the
|
|
|
/// individual ticket files on disk.
|
|
|
pub fn format_list_json(tickets: &[Ticket]) -> String {
|
|
|
let values: Vec<serde_json::Value> = 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<serde_json::Value> = 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));
|
|
|
}
|