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
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);
|
|
}
|