From e9b6e97116732b74833dbfcc3647a74449804f86 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sat, 21 Feb 2026 21:30:41 -0800 Subject: [PATCH] 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 --- nbd/PLAN.md | 11 ++-- nbd/src/display.rs | 158 +++++++++++++++++++++++++++++++++++++++++++++ nbd/src/tests.rs | 149 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 5 deletions(-) diff --git a/nbd/PLAN.md b/nbd/PLAN.md index f79b04b..4457dcc 100644 --- a/nbd/PLAN.md +++ b/nbd/PLAN.md @@ -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 --- diff --git a/nbd/src/display.rs b/nbd/src/display.rs index 1bc0284..a12a1bc 100644 --- a/nbd/src/display.rs +++ b/nbd/src/display.rs @@ -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 key–value 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!("{: 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!( + "{: 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)); +} diff --git a/nbd/src/tests.rs b/nbd/src/tests.rs index c9e0ed2..2a65705 100644 --- a/nbd/src/tests.rs +++ b/nbd/src/tests.rs @@ -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()); + } +}