From 07c363a87c72aa45742e1caf8ca84869d3831593 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 29 Jun 2026 16:05:24 +0200 Subject: [PATCH] feat(cli, term): Show conversation attachments in `jp conversation show` `jp conversation show` now lists the attachments associated with a conversation under an "Attachments" field. Each attachment renders as `scheme (description): url` when the URL carries a `description` query parameter, and as the bare URL otherwise. The JSON output serializes each attachment as an object with `scheme`, `description`, and `url` fields. To support list-valued fields in the details view, `jp_term::table` gains three new types: `DetailRow`, `DetailValue`, and `DetailItem`. `DetailRow` replaces the raw `comfy_table::Row` type throughout `print_details`, `details`, `details_markdown`, and `details_json`. A `DetailValue::List` renders as a bulleted block in the pretty terminal view, expands to one row per item in the pipe-delimited markdown view, and becomes a JSON array in the JSON views. The `DetailItem` type lets the text and JSON representations differ so an attachment can read naturally in the terminal while still being structured in JSON. `DetailsFmt` in `jp_cli` gains an `attachments` field and a `with_attachments` builder method, and the `attachment_detail_item` helper in `jp_cli::format` constructs the right `DetailItem` from any attachment URL. Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/attachment/ls.rs | 6 +- crates/jp_cli/src/cmd/conversation/show.rs | 15 +- crates/jp_cli/src/format.rs | 32 ++++ crates/jp_cli/src/format/conversation.rs | 138 +++++++------ crates/jp_cli/src/lib.rs | 20 +- crates/jp_cli/src/output.rs | 6 +- crates/jp_cli/src/output_tests.rs | 13 +- crates/jp_term/src/table.rs | 213 ++++++++++++++++++--- crates/jp_term/src/table_tests.rs | 106 +++++++--- 9 files changed, 406 insertions(+), 143 deletions(-) diff --git a/crates/jp_cli/src/cmd/attachment/ls.rs b/crates/jp_cli/src/cmd/attachment/ls.rs index e5c386d7..d59ab25a 100644 --- a/crates/jp_cli/src/cmd/attachment/ls.rs +++ b/crates/jp_cli/src/cmd/attachment/ls.rs @@ -1,4 +1,4 @@ -use comfy_table::{Cell, Row}; +use jp_term::table::DetailRow; use crate::{cmd::Output, ctx::Ctx, output::print_details}; @@ -19,9 +19,7 @@ impl Ls { let mut rows = vec![]; for uri in uris { - let mut row = Row::new(); - row.add_cell(Cell::new(uri.to_url()?)); - rows.push(row); + rows.push(DetailRow::bare(uri.to_url()?)); } print_details(&ctx.printer, title.as_deref(), rows); diff --git a/crates/jp_cli/src/cmd/conversation/show.rs b/crates/jp_cli/src/cmd/conversation/show.rs index 35854af2..40b53afe 100644 --- a/crates/jp_cli/src/cmd/conversation/show.rs +++ b/crates/jp_cli/src/cmd/conversation/show.rs @@ -1,10 +1,11 @@ +use jp_conversation::Error as ConversationError; use jp_storage::backend::StoragePresence; use jp_workspace::ConversationHandle; use crate::{ cmd::{ConversationLoadRequest, Output, conversation_id::PositionalIds}, ctx::Ctx, - format::conversation::DetailsFmt, + format::{attachment_detail_item, conversation::DetailsFmt}, output::print_details, }; @@ -28,6 +29,17 @@ impl Show { ctx.workspace.conversation_presence(&id) == Some(StoragePresence::UserLocalOnly); let conversation = ctx.workspace.metadata(&handle)?; let events = ctx.workspace.events(&handle)?; + + let mut attachments = vec![]; + for attachment in events + .config() + .map_err(ConversationError::from)? + .conversation + .attachments + { + attachments.push(attachment_detail_item(&attachment.to_url()?)); + } + let details = DetailsFmt::new(id) .with_last_message_at(events.last().map(|v| v.event.timestamp)) .with_event_count(events.len()) @@ -38,6 +50,7 @@ impl Show { .with_local_flag(local) .with_active_conversation(active_id.unwrap_or(id)) .with_expires_at(conversation.expires_at) + .with_attachments(attachments) .with_pretty_printing(ctx.printer.pretty_printing_enabled()); print_details(&ctx.printer, details.title.as_deref(), details.rows()); diff --git a/crates/jp_cli/src/format.rs b/crates/jp_cli/src/format.rs index 85654e0b..03b248c1 100644 --- a/crates/jp_cli/src/format.rs +++ b/crates/jp_cli/src/format.rs @@ -2,6 +2,38 @@ pub(crate) mod conversation; pub(crate) mod datetime; use jp_config::types::color::Color; +use jp_term::table::DetailItem; +use serde_json::json; +use url::Url; + +/// Build a list item for an attachment URL. +/// +/// The terminal text reads as `scheme (description): url` when the attachment +/// carries a `description` query parameter, and as the bare URL otherwise. +/// The JSON form is always an object with `scheme`, `description` (null when +/// absent), and the canonical `url`. +pub(crate) fn attachment_detail_item(url: &Url) -> DetailItem { + let scheme = url.scheme(); + let description = url + .query_pairs() + .find(|(key, _)| key == "description") + .map(|(_, value)| value.into_owned()); + let url_str = url.to_string(); + + let text = match &description { + Some(description) => format!("{scheme} ({description}): {url_str}"), + None => url_str.clone(), + }; + + DetailItem::new( + text, + json!({ + "scheme": scheme, + "description": description, + "url": url_str, + }), + ) +} /// Convert a [`Color`] to an SGR background parameter string. pub(crate) fn color_to_bg_param(color: Color) -> String { diff --git a/crates/jp_cli/src/format/conversation.rs b/crates/jp_cli/src/format/conversation.rs index 70e19091..88926cc2 100644 --- a/crates/jp_cli/src/format/conversation.rs +++ b/crates/jp_cli/src/format/conversation.rs @@ -1,9 +1,9 @@ use std::fmt; use chrono::{DateTime, Utc}; -use comfy_table::{Cell, CellAlignment, Row, Table}; use crossterm::style::Stylize as _; use jp_conversation::ConversationId; +use jp_term::table::{DetailItem, DetailRow, details}; use super::datetime::DateTimeFmt; @@ -43,6 +43,9 @@ pub struct DetailsFmt { /// Display the timestamp of conversation expiration. pub expires_at: Option>, + /// Attachments associated with the conversation. + pub attachments: Vec, + /// Pretty-print the output. pub pretty: bool, } @@ -62,6 +65,7 @@ impl DetailsFmt { last_message_at: None, last_activated_at: None, expires_at: None, + attachments: vec![], pretty: true, } } @@ -96,6 +100,12 @@ impl DetailsFmt { self } + #[must_use] + pub fn with_attachments(mut self, attachments: Vec) -> Self { + self.attachments = attachments; + self + } + #[must_use] pub fn with_title(mut self, title: Option>) -> Self { self.title = title.map(Into::into); @@ -141,111 +151,95 @@ impl DetailsFmt { /// Return rows for a table displaying the conversation details. #[must_use] - pub fn rows(&self) -> Vec { - let mut map = vec![]; + pub fn rows(&self) -> Vec { + let mut rows = vec![]; - map.push(("ID".to_owned(), self.id.to_string())); + rows.push(self.scalar("ID", self.id.to_string())); if let Some(name) = self.assistant_name.clone() { - map.push(("Assistant".to_owned(), name)); + rows.push(self.scalar("Assistant", name)); } if self.message_count > 0 { - map.push(("Events".to_owned(), self.message_count.to_string())); + rows.push(self.scalar("Events", self.message_count.to_string())); } if self.turn_count > 0 { - map.push(("Turns".to_owned(), self.turn_count.to_string())); + rows.push(self.scalar("Turns", self.turn_count.to_string())); } if let Some(last_message_at) = self.last_message_at { - map.push(( - "Latest Message".to_owned(), + rows.push(self.scalar( + "Latest Message", DateTimeFmt::new(last_message_at).to_string(), )); } if let Some(active) = self.active_conversation { - map.push(( - "Last Activated".to_owned(), - if active == self.id && self.pretty { - "Currently Active".green().bold().to_string() - } else if active == self.id { - "Currently Active".to_owned() - } else if let Some(last_activated_at) = self.last_activated_at { - DateTimeFmt::new(last_activated_at).to_string() - } else { - "Unknown".to_owned() - }, - )); + let value = if active == self.id && self.pretty { + "Currently Active".green().bold().to_string() + } else if active == self.id { + "Currently Active".to_owned() + } else if let Some(last_activated_at) = self.last_activated_at { + DateTimeFmt::new(last_activated_at).to_string() + } else { + "Unknown".to_owned() + }; + rows.push(self.scalar("Last Activated", value)); } if let Some(expires_at) = self.expires_at { - map.push(( - "Expires In".to_owned(), - if expires_at < Utc::now() { - "On Deactivation".to_string() - } else { - DateTimeFmt::new(expires_at).to_string() - }, - )); + let value = if expires_at < Utc::now() { + "On Deactivation".to_string() + } else { + DateTimeFmt::new(expires_at).to_string() + }; + rows.push(self.scalar("Expires In", value)); } if let Some(pinned) = self.pinned { - map.push(( - "Pinned".to_owned(), - if pinned { - "Yes".bold().blue().to_string() - } else { - "No".to_string() - }, - )); + let value = if pinned { + "Yes".bold().blue().to_string() + } else { + "No".to_string() + }; + rows.push(self.scalar("Pinned", value)); } if let Some(local) = self.local { - map.push(( - "Local".to_owned(), - if local { - "Yes".bold().yellow().to_string() - } else { - "No".to_string() - }, - )); + let value = if local { + "Yes".bold().yellow().to_string() + } else { + "No".to_string() + }; + rows.push(self.scalar("Local", value)); } - let mut rows = vec![]; - for (key, value) in map { - let mut row = Row::new(); - row.add_cell( - Cell::new(if self.pretty { - key.bold().to_string() - } else { - key - }) - .set_alignment(CellAlignment::Right), - ); - row.add_cell(Cell::new(value).set_alignment(CellAlignment::Left)); - rows.push(row); + if !self.attachments.is_empty() { + rows.push(DetailRow::list( + self.styled_label("Attachments"), + self.attachments.clone(), + )); } rows } -} - -impl fmt::Display for DetailsFmt { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let rows = self.rows(); - let mut buf = String::new(); - if let Some(title) = self.title() { - buf.push_str(title); - buf.push_str("\n\n"); + /// Bold the label when pretty-printing is enabled. + fn styled_label(&self, label: &str) -> String { + if self.pretty { + label.bold().to_string() + } else { + label.to_owned() } + } - let mut table = Table::new(); - table.load_preset(comfy_table::presets::NOTHING); - table.add_rows(rows); - buf.push_str(&table.trim_fmt()); + fn scalar(&self, label: &str, value: String) -> DetailRow { + DetailRow::scalar(self.styled_label(label), value) + } +} - write!(f, "{buf}") +impl fmt::Display for DetailsFmt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", details(self.title(), self.rows())) } } diff --git a/crates/jp_cli/src/lib.rs b/crates/jp_cli/src/lib.rs index 29d50187..a1bd7042 100644 --- a/crates/jp_cli/src/lib.rs +++ b/crates/jp_cli/src/lib.rs @@ -35,7 +35,6 @@ use clap::{ builder::{BoolValueParser, TypedValueParser as _}, }; use cmd::Commands; -use comfy_table::{Cell, CellAlignment, Row}; use crossterm::style::Stylize as _; use ctx::{Ctx, IntoPartialAppConfig}; use error::{Error, Result}; @@ -50,7 +49,7 @@ use jp_config::{ }; use jp_printer::{OutputFormat, Printer}; use jp_storage::backend::{FsStorageBackend, NullLockBackend, NullPersistBackend}; -use jp_term::table::{details, details_markdown}; +use jp_term::table::{DetailRow, details, details_markdown}; use jp_workspace::{Workspace, user_data_dir}; use relative_path::RelativePath; use serde_json::Value; @@ -619,19 +618,14 @@ fn parse_error(error: cmd::Error, format: OutputFormat) -> (u8, String) { } = error; if !format.is_json() { - let rows: Vec = metadata + let rows: Vec = metadata .into_iter() .map(|(k, v)| { - let mut row = Row::new(); - row.add_cell(Cell::new(k).set_alignment(CellAlignment::Right)) - .add_cell( - Cell::new(match v { - Value::String(s) => s, - v => format!("{v:#}"), - }) - .set_alignment(CellAlignment::Left), - ); - row + let value = match v { + Value::String(s) => s, + v => format!("{v:#}"), + }; + DetailRow::scalar(k, value) }) .collect(); diff --git a/crates/jp_cli/src/output.rs b/crates/jp_cli/src/output.rs index 757cfd25..a3c62ab9 100644 --- a/crates/jp_cli/src/output.rs +++ b/crates/jp_cli/src/output.rs @@ -6,7 +6,9 @@ use comfy_table::Row; use jp_printer::{OutputFormat, Printer}; -use jp_term::table::{details, details_json, details_markdown, list, list_json, list_markdown}; +use jp_term::table::{ + DetailRow, details, details_json, details_markdown, list, list_json, list_markdown, +}; use serde_json::{Value, to_string, to_string_pretty}; /// Print a list table (header + rows) in the format dictated by the printer. @@ -38,7 +40,7 @@ pub fn print_table(printer: &Printer, header: Row, rows: Vec, footer: bool) /// - `TextPretty` → borderless aligned table with optional title /// - `Text` → pipe-delimited markdown table with optional title /// - `Json` / `JsonPretty` → JSON object -pub fn print_details(printer: &Printer, title: Option<&str>, rows: Vec) { +pub fn print_details(printer: &Printer, title: Option<&str>, rows: Vec) { let output = match printer.format() { OutputFormat::TextPretty => details(title, rows), OutputFormat::Text => details_markdown(title, rows), diff --git a/crates/jp_cli/src/output_tests.rs b/crates/jp_cli/src/output_tests.rs index 6e18835c..e10f91b5 100644 --- a/crates/jp_cli/src/output_tests.rs +++ b/crates/jp_cli/src/output_tests.rs @@ -90,7 +90,7 @@ fn table_empty_rows() { #[test] fn details_text_pretty_with_title() { let (printer, out, _) = Printer::memory(OutputFormat::TextPretty); - let rows = vec![row(&["Key", "Value"])]; + let rows = vec![DetailRow::scalar("Key", "Value")]; print_details(&printer, Some("My Title"), rows); let output = flush_stdout(&printer, &out); @@ -103,7 +103,7 @@ fn details_text_pretty_with_title() { #[test] fn details_text_renders_markdown() { let (printer, out, _) = Printer::memory(OutputFormat::Text); - let rows = vec![row(&["color", "red"])]; + let rows = vec![DetailRow::scalar("color", "red")]; print_details(&printer, None, rows); let output = flush_stdout(&printer, &out); @@ -116,7 +116,10 @@ fn details_text_renders_markdown() { #[test] fn details_json_compact() { let (printer, out, _) = Printer::memory(OutputFormat::Json); - let rows = vec![row(&["name", "jp"]), row(&["version", "1.0"])]; + let rows = vec![ + DetailRow::scalar("name", "jp"), + DetailRow::scalar("version", "1.0"), + ]; print_details(&printer, Some("info"), rows); let output = flush_stdout(&printer, &out); @@ -131,7 +134,7 @@ fn details_json_compact() { #[test] fn details_json_pretty_is_indented() { let (printer, out, _) = Printer::memory(OutputFormat::JsonPretty); - let rows = vec![row(&["k", "v"])]; + let rows = vec![DetailRow::scalar("k", "v")]; print_details(&printer, None, rows); let output = flush_stdout(&printer, &out); @@ -144,7 +147,7 @@ fn details_json_pretty_is_indented() { #[test] fn details_no_title_json() { let (printer, out, _) = Printer::memory(OutputFormat::Json); - let rows = vec![row(&["a", "b"])]; + let rows = vec![DetailRow::scalar("a", "b")]; print_details(&printer, None, rows); let output = flush_stdout(&printer, &out); diff --git a/crates/jp_term/src/table.rs b/crates/jp_term/src/table.rs index 986d1dc3..3f0e70b9 100644 --- a/crates/jp_term/src/table.rs +++ b/crates/jp_term/src/table.rs @@ -1,8 +1,92 @@ -use comfy_table::{Row, Table}; +use comfy_table::{Cell, CellAlignment, Row, Table}; pub const EMPTY: &str = " "; pub const UTF8_FULL: &str = "││──├──┤ ──╭╮╰╯"; +/// A value rendered in a key-value details view. +#[derive(Debug, Clone)] +pub enum DetailValue { + /// A single value. + Scalar(String), + + /// A list of items: a bulleted multi-line cell in the pretty view, one row + /// per item in markdown, and a JSON array in the JSON views. + List(Vec), +} + +/// An item in a [`DetailValue::List`]. +/// +/// Carries a human-facing `text` form (pretty + markdown) and a structured +/// `json` form (JSON views) so the two can differ: a list can read as `cmd +/// (Current Date): cmd://...` in the terminal while serializing as an object in +/// JSON. +#[derive(Debug, Clone)] +pub struct DetailItem { + pub text: String, + pub json: serde_json::Value, +} + +impl DetailItem { + /// An item with distinct text and JSON forms. + #[must_use] + pub fn new(text: impl Into, json: serde_json::Value) -> Self { + Self { + text: text.into(), + json, + } + } + + /// An item whose text and JSON forms are the same plain string. + #[must_use] + pub fn plain(text: impl Into) -> Self { + let text = text.into(); + Self { + json: serde_json::Value::String(text.clone()), + text, + } + } +} + +/// A labeled row in a key-value details view. +/// +/// A `None` label produces a label-less row: a single value column in the +/// pretty and markdown views. +/// Listing commands use this to render a titled column of values without keys. +#[derive(Debug, Clone)] +pub struct DetailRow { + pub label: Option, + pub value: DetailValue, +} + +impl DetailRow { + /// A labeled single-value row. + #[must_use] + pub fn scalar(label: impl Into, value: impl Into) -> Self { + Self { + label: Some(label.into()), + value: DetailValue::Scalar(value.into()), + } + } + + /// A labeled multi-value row. + #[must_use] + pub fn list(label: impl Into, values: Vec) -> Self { + Self { + label: Some(label.into()), + value: DetailValue::List(values), + } + } + + /// A label-less single-value row. + #[must_use] + pub fn bare(value: impl Into) -> Self { + Self { + label: None, + value: DetailValue::Scalar(value.into()), + } + } +} + /// Render a list table with unicode box-drawing characters. /// /// When `footer` is true, the header row is repeated at the bottom of the table @@ -118,7 +202,7 @@ pub fn list_json(header: Row, rows: Vec) -> serde_json::Value { /// Render a key-value details table with no borders. #[must_use] -pub fn details(title: Option<&str>, rows: Vec) -> String { +pub fn details(title: Option<&str>, rows: Vec) -> String { let mut buf = String::new(); if let Some(title) = title { @@ -130,16 +214,43 @@ pub fn details(title: Option<&str>, rows: Vec) -> String { let mut table = Table::new(); table.load_preset(EMPTY); - table.add_rows(rows); + for row in rows { + table.add_row(detail_pretty_row(row)); + } buf.push_str(&table.trim_fmt()); buf } +/// Build a pretty (borderless table) row from a detail row. +/// +/// A list value renders with the label on its own line and the items bulleted +/// beneath it (the leading newline pushes the items below the label, indented +/// into the value column). +fn detail_pretty_row(row: DetailRow) -> Row { + let value = match row.value { + DetailValue::Scalar(s) => s, + DetailValue::List(items) => { + let bullets = items + .into_iter() + .map(|item| format!("- {}", item.text)) + .collect::>() + .join("\n"); + format!("\n{bullets}") + } + }; + + let mut r = Row::new(); + if let Some(label) = row.label { + r.add_cell(Cell::new(label).set_alignment(CellAlignment::Right)); + } + r.add_cell(Cell::new(value).set_alignment(CellAlignment::Left)); + r +} + /// Render a key-value details table as a pipe-delimited markdown table. #[must_use] -#[expect(clippy::needless_pass_by_value)] -pub fn details_markdown(title: Option<&str>, rows: Vec) -> String { +pub fn details_markdown(title: Option<&str>, rows: Vec) -> String { let mut buf = String::new(); if let Some(title) = title { @@ -153,36 +264,77 @@ pub fn details_markdown(title: Option<&str>, rows: Vec) -> String { return buf; } - let row_refs: Vec<&Row> = rows.iter().collect(); + let md_rows = detail_markdown_rows(rows); + let row_refs: Vec<&Row> = md_rows.iter().collect(); let col_count = max_columns(&row_refs); let widths = column_widths(&row_refs, col_count); - for row in &rows { + for row in &md_rows { push_md_row(&mut buf, row, &widths, col_count); } buf } -/// Render key-value details as JSON. -#[must_use] -pub fn details_json(title: Option<&str>, rows: Vec) -> serde_json::Value { - let mut details = serde_json::Map::new(); +/// Flatten detail rows into pipe-table rows. +/// +/// A list value expands to one row per item: the label sits on the first item's +/// row and continuation rows carry a blank label cell so the table stays +/// aligned. +fn detail_markdown_rows(rows: Vec) -> Vec { + let mut out = Vec::new(); for row in rows { - let mut iter = row.cell_iter(); - let Some(key) = iter - .next() - .map(|c| strip_ansi_escapes::strip_str(c.content())) - else { - continue; - }; + match row.value { + DetailValue::Scalar(s) => out.push(md_row(row.label.as_deref(), &s)), + DetailValue::List(items) => { + for (idx, item) in items.iter().enumerate() { + let label = match (row.label.as_deref(), idx) { + (Some(label), 0) => Some(label), + (Some(_), _) => Some(""), + (None, _) => None, + }; + out.push(md_row(label, &item.text)); + } + } + } + } + out +} - let value = iter - .next() - .map(|c| strip_ansi_escapes::strip_str(c.content())) - .unwrap_or_default(); +fn md_row(label: Option<&str>, value: &str) -> Row { + let mut r = Row::new(); + if let Some(label) = label { + r.add_cell(Cell::new(label)); + } + r.add_cell(Cell::new(value)); + r +} - details.insert(key, value.into()); +/// Render key-value details as JSON. +/// +/// A scalar value becomes a string; a list value becomes a JSON array. +#[must_use] +pub fn details_json(title: Option<&str>, rows: Vec) -> serde_json::Value { + let mut details = serde_json::Map::new(); + for DetailRow { label, value } in rows { + match label { + Some(label) => { + details.insert(strip(&label), detail_json_value(value)); + } + // A label-less row has no key of its own; emit each value as a key + // with an empty value, matching the label-less column rendering + // used by listing commands. + None => match value { + DetailValue::Scalar(s) => { + details.insert(strip(&s), String::new().into()); + } + DetailValue::List(items) => { + for item in items { + details.insert(strip(&item.text), String::new().into()); + } + } + }, + } } serde_json::json!({ @@ -191,6 +343,21 @@ pub fn details_json(title: Option<&str>, rows: Vec) -> serde_json::Value { }) } +fn detail_json_value(value: DetailValue) -> serde_json::Value { + match value { + DetailValue::Scalar(s) => strip(&s).into(), + DetailValue::List(items) => items + .into_iter() + .map(|item| item.json) + .collect::>() + .into(), + } +} + +fn strip(s: &str) -> String { + strip_ansi_escapes::strip_str(s) +} + /// Find the maximum column count across all rows. fn max_columns(rows: &[&Row]) -> usize { rows.iter() diff --git a/crates/jp_term/src/table_tests.rs b/crates/jp_term/src/table_tests.rs index 61755377..5979a175 100644 --- a/crates/jp_term/src/table_tests.rs +++ b/crates/jp_term/src/table_tests.rs @@ -42,14 +42,10 @@ fn markdown_list_table() { #[test] fn markdown_details_with_title() { - let mut r1 = Row::new(); - r1.add_cell(Cell::new("key1")); - r1.add_cell(Cell::new("val1")); - let mut r2 = Row::new(); - r2.add_cell(Cell::new("longer-key")); - r2.add_cell(Cell::new("v2")); - - let output = details_markdown(Some("Info"), vec![r1, r2]); + let output = details_markdown(Some("Info"), vec![ + DetailRow::scalar("key1", "val1"), + DetailRow::scalar("longer-key", "v2"), + ]); assert_eq!( output, "Info @@ -61,11 +57,7 @@ fn markdown_details_with_title() { #[test] fn markdown_details_no_title() { - let mut r = Row::new(); - r.add_cell(Cell::new("a")); - r.add_cell(Cell::new("b")); - - let output = details_markdown(None, vec![r]); + let output = details_markdown(None, vec![DetailRow::scalar("a", "b")]); assert_eq!( output, "| a | b | @@ -73,6 +65,79 @@ fn markdown_details_no_title() { ); } +#[test] +fn pretty_details_list_puts_label_above_bulleted_items() { + let output = details(None, vec![DetailRow::list("Attachments", vec![ + DetailItem::plain("a://x"), + DetailItem::plain("b://y"), + ])]); + + let lines: Vec<&str> = output.lines().collect(); + // Label sits on its own line; items are bulleted beneath it. + assert!( + lines[0].trim_end().ends_with("Attachments"), + "got: {output}" + ); + assert!(!lines[0].contains("a://x"), "got: {output}"); + assert!(output.contains("- a://x"), "got: {output}"); + assert!(output.contains("- b://y"), "got: {output}"); +} + +#[test] +fn markdown_details_list_expands_to_one_row_per_item() { + let output = details_markdown(None, vec![DetailRow::list("Attachments", vec![ + DetailItem::plain("a://x"), + DetailItem::plain("b://y"), + ])]); + + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 2, "got: {output}"); + assert!(lines[0].contains("Attachments"), "got: {output}"); + assert!(lines[0].contains("a://x"), "got: {output}"); + // Continuation row carries a blank label, not a repeated one. + assert!(!lines[1].contains("Attachments"), "got: {output}"); + assert!(lines[1].contains("b://y"), "got: {output}"); +} + +#[test] +fn json_details_list_of_plain_items_is_string_array() { + let json = details_json(None, vec![DetailRow::list("Attachments", vec![ + DetailItem::plain("a://x"), + DetailItem::plain("b://y"), + ])]); + + assert_eq!( + json["details"]["Attachments"], + serde_json::json!(["a://x", "b://y"]) + ); +} + +#[test] +fn list_item_text_and_json_forms_can_differ() { + let item = DetailItem::new( + "cmd (Desc): cmd://x", + serde_json::json!({ "scheme": "cmd", "url": "cmd://x" }), + ); + let rows = vec![DetailRow::list("Attachments", vec![item])]; + + // Pretty uses the text form. + assert!( + details(None, rows.clone()).contains("- cmd (Desc): cmd://x"), + "text form should drive the pretty view" + ); + + // JSON uses the structured form. + let json = details_json(None, rows); + assert_eq!(json["details"]["Attachments"][0]["scheme"], "cmd"); + assert_eq!(json["details"]["Attachments"][0]["url"], "cmd://x"); +} + +#[test] +fn json_details_bare_row_uses_value_as_key() { + let json = details_json(None, vec![DetailRow::bare("a://x")]); + assert_eq!(json["details"]["a://x"], ""); +} + #[test] fn json_list() { let json = list_json(header(), rows()); @@ -86,22 +151,17 @@ fn json_list() { #[test] fn json_details() { - let mut r = Row::new(); - r.add_cell(Cell::new("ID")); - r.add_cell(Cell::new("jp-c123")); - - let json = details_json(Some("title"), vec![r]); + let json = details_json(Some("title"), vec![DetailRow::scalar("ID", "jp-c123")]); assert_eq!(json["title"], "title"); assert_eq!(json["details"]["ID"], "jp-c123"); } #[test] fn json_details_strips_ansi() { - let mut r = Row::new(); - r.add_cell(Cell::new("\x1b[1mKey\x1b[0m")); - r.add_cell(Cell::new("\x1b[32mVal\x1b[0m")); - - let json = details_json(None, vec![r]); + let json = details_json(None, vec![DetailRow::scalar( + "\x1b[1mKey\x1b[0m", + "\x1b[32mVal\x1b[0m", + )]); assert_eq!(json["details"]["Key"], "Val"); }