You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

265 lines
9.5 KiB
Rust

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! 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 keyvalue 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));
}