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.

474 lines
16 KiB
Rust

//! 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 std::collections::HashSet;
use serde::Serialize;
use crate::graph::TicketGraph;
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 diff view ("dependencies:" = 13 + 1 space).
const LABEL_WIDTH: usize = 14;
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Ticket metadata serialised into the TOML frontmatter block for display.
///
/// Mirrors the on-disk `MarkdownFrontmatter` in `store.rs` but adds `id` as
/// the first field so that human-readable output is self-contained.
#[derive(Serialize)]
struct DisplayFrontmatter<'a> {
id: &'a str,
title: &'a str,
priority: u8,
status: &'a Status,
ticket_type: &'a TicketType,
dependencies: &'a [String],
}
/// Return the canonical display string for a [`Status`] variant.
///
/// The strings match the serde serialisation: `"todo"`, `"in_progress"`,
/// `"done"`, etc.
fn status_str(status: &Status) -> &'static str {
match status {
Status::Todo => "todo",
Status::InProgress => "in_progress",
Status::Done => "done",
Status::Closed => "closed",
Status::Archived => "archived",
Status::Backlog => "backlog",
}
}
/// 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 TOML-frontmatter markdown document.
///
/// The output mirrors the `.md` file format used on disk, with `id` added as
/// the first frontmatter key so the output is self-contained. The ticket body
/// follows the closing `+++` delimiter.
///
/// ```text
/// +++
/// id = "a3f9c2"
/// title = "Fix login bug"
/// priority = 8
/// status = "in_progress"
/// ticket_type = "bug"
/// dependencies = ["b7d41e", "c9e823"]
/// +++
/// Users cannot log in with email addresses containing +
/// ```
pub fn format_ticket(ticket: &Ticket) -> String {
let fm = DisplayFrontmatter {
id: &ticket.id,
title: &ticket.title,
priority: ticket.priority,
status: &ticket.status,
ticket_type: &ticket.ticket_type,
dependencies: &ticket.dependencies,
};
let toml_str = toml::to_string(&fm).expect("frontmatter serialisation must not fail");
format!("+++\n{toml_str}+++\n{}", ticket.body)
}
/// 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));
}
/// Format a git-diff-style `- old / + new` summary of what changed between
/// two [`Ticket`] snapshots.
///
/// Each field that differs produces two lines:
///
/// ```text
/// - status: todo
/// + status: in_progress
/// ```
///
/// Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, and
/// `dependencies`. The `id` field is never shown because it cannot change.
///
/// Returns `"(no changes)"` when every compared field is identical.
pub fn format_diff(old: &Ticket, new: &Ticket) -> String {
let mut lines: Vec<String> = Vec::new();
macro_rules! diff_field {
($label:expr, $old_val:expr, $new_val:expr) => {{
let old_s: String = $old_val;
let new_s: String = $new_val;
if old_s != new_s {
lines.push(format!("- {:<LABEL_WIDTH$}{}", $label, old_s));
lines.push(format!("+ {:<LABEL_WIDTH$}{}", $label, new_s));
}
}};
}
diff_field!("title:", old.title.clone(), new.title.clone());
diff_field!("body:", old.body.clone(), new.body.clone());
diff_field!(
"priority:",
old.priority.to_string(),
new.priority.to_string()
);
diff_field!(
"status:",
status_str(&old.status).to_string(),
status_str(&new.status).to_string()
);
diff_field!(
"type:",
ticket_type_str(&old.ticket_type).to_string(),
ticket_type_str(&new.ticket_type).to_string()
);
diff_field!(
"dependencies:",
old.dependencies.join(", "),
new.dependencies.join(", ")
);
if lines.is_empty() {
"(no changes)".to_string()
} else {
lines.join("\n")
}
}
/// Print a git-diff-style summary of what changed between two tickets to stdout.
pub fn print_diff(old: &Ticket, new: &Ticket) {
println!("{}", format_diff(old, new));
}
// ── Graph rendering ───────────────────────────────────────────────────────────
/// Format the full dependency forest as an ASCII tree string.
///
/// Roots (tickets that no other ticket depends on — top-level goals) appear
/// at column 0, sorted by priority descending. Each root's dependencies
/// (prerequisites it needs completed first) are indented below using
/// box-drawing characters:
///
/// ```text
/// a3f9c2 [todo] Fix login bug
/// ├── b7d41e [in_progress] Add rate limiting
/// │ └── c9e823 [todo] Write tests
/// └── d1f302 [done] Update docs
/// e4a781 [todo] New feature (no prereqs)
/// ```
///
/// Nodes that appear in multiple subtrees are marked `*` on subsequent
/// occurrences rather than looping forever.
pub fn format_graph(graph: &TicketGraph<'_>) -> String {
let mut out = String::new();
let mut visited: HashSet<String> = HashSet::new();
for root in graph.roots() {
render_node(graph, &root.id, "", "", "", &mut visited, &mut out);
}
out
}
/// Print the full dependency forest to stdout.
pub fn print_graph(graph: &TicketGraph<'_>) {
println!("{}", format_graph(graph));
}
/// Format the subtree rooted at `root_id` as an ASCII tree string.
///
/// Renders `root_id` and every ticket it transitively depends on
/// (via dependency edges), using the same box-drawing format as
/// [`format_graph`]. Returns an empty string when `root_id` is not in the
/// graph.
pub fn format_subtree(graph: &TicketGraph<'_>, root_id: &str) -> String {
let mut out = String::new();
let mut visited: HashSet<String> = HashSet::new();
if graph.get_node(root_id).is_some() {
render_node(graph, root_id, "", "", "", &mut visited, &mut out);
}
out
}
/// Print the subtree rooted at `root_id` to stdout.
pub fn print_subtree(graph: &TicketGraph<'_>, root_id: &str) {
println!("{}", format_subtree(graph, root_id));
}
// ── Internal graph helpers ────────────────────────────────────────────────────
/// Recursively render a single node and its dependencies into `out`.
///
/// Parameters:
/// - `prefix` — the indentation printed before `connector` on this node's line.
/// This is the `child_base` that the parent passed down.
/// - `connector` — the box-drawing connector for this node (`""` for roots,
/// `"├── "` or `"└── "` for children).
/// - `child_base` — the base prefix handed to this node's *children* (it
/// becomes their `prefix`). It accumulates one more level of `│ ` or
/// ` ` with each descent.
///
/// When a node ID is already in `visited`, it is rendered as `*` and
/// the recursion stops, preventing infinite loops in cyclic or shared data.
fn render_node(
graph: &TicketGraph<'_>,
id: &str,
prefix: &str,
connector: &str,
child_base: &str,
visited: &mut HashSet<String>,
out: &mut String,
) {
// Already-visited detection: this node appears elsewhere in the tree.
if visited.contains(id) {
append_line(out, &format!("{prefix}{connector}{id} *"));
return;
}
visited.insert(id.to_string());
// Extract the node data we need, cloning so we can drop the borrow before
// recursing (the recursive call needs a mutable `visited`).
let (status_s, title, dependencies) = match graph.get_node(id) {
Some(node) => {
let s = status_str(&node.ticket.status);
let t = node.ticket.title.clone();
let d: Vec<String> = node.dependencies.iter().map(|s| s.to_string()).collect();
(s, t, d)
}
None => return,
};
append_line(
out,
&format!("{prefix}{connector}{id} [{status_s}] {title}"),
);
let n = dependencies.len();
for (i, dep_id) in dependencies.iter().enumerate() {
let is_last = i == n - 1;
// The child's connector on its own line.
let child_connector = if is_last { "└── " } else { "├── " };
// The grandchildren's base: extend child_base by one more level.
let grandchild_base = format!("{child_base}{}", if is_last { " " } else { "│ " });
render_node(
graph,
dep_id,
child_base,
child_connector,
&grandchild_base,
visited,
out,
);
}
}
/// Append `line` to `out`, preceded by a newline if `out` is non-empty.
fn append_line(out: &mut String, line: &str) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
}