feat(nbd): implement display layer (Phase 4)

Add output formatting for tickets via display.rs:
- print_ticket / format_ticket: key-value table view
- print_ticket_json / format_ticket_json: pretty JSON
- print_list / format_list: compact summary table with header
- print_list_json / format_list_json: JSON array output

format_* variants return String for testability. Unit tests added
covering field presence, dependency joining, header row, and JSON
validity. All 26 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 8d7ee96b99
commit e9b6e97116

@ -74,7 +74,7 @@ File I/O and directory traversal using `async-std`.
Output formatting.
- [ ] Implement `print_ticket(ticket: &Ticket)`: full tabular display
- [x] Implement `print_ticket(ticket: &Ticket)`: full tabular display
```
ID: a3f9c2
Title: Fix login bug
@ -84,15 +84,16 @@ Output formatting.
Type: bug
Dependencies: b7d41e, c9e823
```
- [ ] Implement `print_ticket_json(ticket: &Ticket)`: pretty-printed JSON to stdout
- [ ] Implement `print_list(tickets: &[Ticket])`: short table
- [x] Implement `print_ticket_json(ticket: &Ticket)`: pretty-printed JSON to stdout
- [x] Implement `print_list(tickets: &[Ticket])`: short table
```
ID PRI TYPE STATUS TITLE
a3f9c2 8 bug in_progress Fix login bug
b7d41e 5 task todo Add rate limiting
```
- [ ] Implement `print_list_json(tickets: &[Ticket])`: JSON array to stdout
- [ ] Unit tests: table output contains expected field values, JSON output is valid
- [x] Implement `print_list_json(tickets: &[Ticket])`: JSON array to stdout
- [x] Added `format_ticket`, `format_ticket_json`, `format_list`, `format_list_json` internal functions for testability
- [x] Unit tests: table output contains expected field values, JSON output is valid
---

@ -2,3 +2,161 @@
//!
//! 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::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",
}
}
/// 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));
}
/// Format a single ticket as a pretty-printed JSON object.
///
/// The output is suitable for piping or machine consumption.
pub fn format_ticket_json(ticket: &Ticket) -> String {
serde_json::to_string_pretty(ticket).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.
pub fn format_list_json(tickets: &[Ticket]) -> String {
serde_json::to_string_pretty(tickets).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));
}

@ -288,3 +288,152 @@ mod store {
assert_eq!(dir, Path::new("/tmp/project/.nbd/tickets"));
}
}
// ── display module ────────────────────────────────────────────────────────────
/// Tests for [`crate::display`].
mod display {
use crate::display::{format_list, format_list_json, format_ticket, format_ticket_json};
use crate::ticket::{Status, Ticket, TicketType};
/// Build a fully-populated ticket for use in display tests.
fn sample_ticket() -> Ticket {
Ticket {
id: "a3f9c2".to_string(),
title: "Fix login bug".to_string(),
body: "Users cannot log in with email addresses containing +".to_string(),
priority: 8,
status: Status::InProgress,
dependencies: vec!["b7d41e".to_string(), "c9e823".to_string()],
ticket_type: TicketType::Bug,
}
}
/// `format_ticket` includes all field values in its output.
#[test]
fn format_ticket_contains_all_fields() {
let t = sample_ticket();
let output = format_ticket(&t);
assert!(output.contains("a3f9c2"), "should contain ID");
assert!(output.contains("Fix login bug"), "should contain title");
assert!(
output.contains("Users cannot log in"),
"should contain body"
);
assert!(output.contains('8'), "should contain priority");
assert!(output.contains("in_progress"), "should contain status");
assert!(output.contains("bug"), "should contain ticket type");
assert!(output.contains("b7d41e"), "should contain first dependency");
assert!(
output.contains("c9e823"),
"should contain second dependency"
);
}
/// `format_ticket` renders dependency IDs as a comma-separated list.
#[test]
fn format_ticket_joins_dependencies() {
let t = sample_ticket();
let output = format_ticket(&t);
assert!(
output.contains("b7d41e, c9e823"),
"dependencies should be comma-separated: {output}"
);
}
/// `format_ticket` renders an empty `Dependencies:` line when there are none.
#[test]
fn format_ticket_empty_dependencies() {
let t = Ticket::new("aaaaaa".to_string(), "No deps".to_string());
let output = format_ticket(&t);
assert!(
output.contains("Dependencies:"),
"Dependencies label should always appear"
);
}
/// `format_ticket_json` produces valid, parseable JSON containing key fields.
#[test]
fn format_ticket_json_is_valid_json() {
let t = sample_ticket();
let output = format_ticket_json(&t);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert_eq!(parsed["id"], "a3f9c2");
assert_eq!(parsed["title"], "Fix login bug");
assert_eq!(parsed["priority"], 8);
assert_eq!(parsed["status"], "in_progress");
assert_eq!(parsed["ticket_type"], "bug");
}
/// `format_list` includes a header row with all column names.
#[test]
fn format_list_shows_header() {
let output = format_list(&[]);
assert!(output.contains("ID"), "header should contain ID");
assert!(output.contains("PRI"), "header should contain PRI");
assert!(output.contains("TYPE"), "header should contain TYPE");
assert!(output.contains("STATUS"), "header should contain STATUS");
assert!(output.contains("TITLE"), "header should contain TITLE");
}
/// `format_list` includes every ticket's key fields.
#[test]
fn format_list_contains_all_tickets() {
let mut t1 = Ticket::new("id0001".to_string(), "First ticket".to_string());
t1.priority = 9;
let mut t2 = Ticket::new("id0002".to_string(), "Second ticket".to_string());
t2.priority = 3;
let tickets = vec![t1, t2];
let output = format_list(&tickets);
assert!(output.contains("id0001"), "should contain first ID");
assert!(
output.contains("First ticket"),
"should contain first title"
);
assert!(output.contains("id0002"), "should contain second ID");
assert!(
output.contains("Second ticket"),
"should contain second title"
);
}
/// `format_list` renders the correct status and type strings.
#[test]
fn format_list_renders_status_and_type_strings() {
let t = sample_ticket(); // status: in_progress, type: bug
let output = format_list(&[t]);
assert!(
output.contains("in_progress"),
"should render status string"
);
assert!(output.contains("bug"), "should render type string");
}
/// `format_list_json` produces a valid JSON array with one object per ticket.
#[test]
fn format_list_json_is_valid_json_array() {
let tickets = vec![
Ticket::new("id0001".to_string(), "First".to_string()),
Ticket::new("id0002".to_string(), "Second".to_string()),
];
let output = format_list_json(&tickets);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert!(parsed.is_array(), "output should be a JSON array");
assert_eq!(parsed.as_array().unwrap().len(), 2);
assert_eq!(parsed[0]["id"], "id0001");
assert_eq!(parsed[1]["id"], "id0002");
}
/// `format_list_json` returns an empty JSON array for an empty slice.
#[test]
fn format_list_json_empty_slice() {
let output = format_list_json(&[]);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert!(parsed.is_array());
assert!(parsed.as_array().unwrap().is_empty());
}
}

Loading…
Cancel
Save