//! 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!( "{: 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)); } /// 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 = 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!("- {:) -> String { let mut out = String::new(); let mut visited: HashSet = 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 that transitively depends on it /// (via dependent 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 = 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 dependents 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, 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, dependents) = match graph.get_node(id) { Some(node) => { let s = status_str(&node.ticket.status); let t = node.ticket.title.clone(); let d: Vec = node.dependents.iter().map(|s| s.to_string()).collect(); (s, t, d) } None => return, }; append_line( out, &format!("{prefix}{connector}{id} [{status_s}] {title}"), ); let n = dependents.len(); for (i, dep_id) in dependents.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); }