feat(output): implement table, JSON, and XML output renderers [claudbg-ftb9, claudbg-fkyt, claudbg-bt5p]

Add src/output/ module with three renderers:
- table.rs: render_table() using comfy-table with dynamic column sizing
- json.rs: render_json<T: Serialize>() using serde_json pretty-print
- xml.rs: render_xml_rows() with XML-escaped headers and values

Each renderer has unit tests (17 new tests total); total test count: 79 passing, 0 clippy warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 15d9402534
commit efe70aef7e

@ -1,11 +1,12 @@
---
# claudbg-bt5p
title: XML output renderer
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-27T19:40:53Z
updated_at: 2026-03-27T19:40:53Z
updated_at: 2026-03-28T18:05:04Z
parent: claudbg-g7zj
---
Implement --output=xml renderer. Not a strict XML schema — use XML tags as section guards to aid model attention (e.g. <session>, <message role='assistant'>, <tool_use name='Bash'>, <thinking>, <content>). Mechanically wraps each logical section. No DTD or schema validation required.
Implemented src/output/xml.rs with render_xml_rows() producing <?xml version=1.0 encoding=UTF-8?><rows> structure. Added 5 unit tests: empty_rows_produces_valid_root_element, single_row_correct_structure, special_chars_in_values_are_escaped, special_chars_in_headers_are_escaped, multiple_rows_appear_in_order. All 79 tests pass, 0 clippy warnings.

@ -1,11 +1,12 @@
---
# claudbg-fkyt
title: JSON output renderer
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-27T19:40:52Z
updated_at: 2026-03-27T19:40:52Z
updated_at: 2026-03-28T18:05:04Z
parent: claudbg-g7zj
---
Implement --output=json renderer: serialize the data model to pretty-printed JSON using serde_json. All commands should produce clean, parseable JSON with no extra decorations.
Implemented src/output/json.rs with render_json<T: Serialize>() using serde_json::to_string_pretty. Added 4 unit tests: simple_struct_serializes_to_valid_json, nested_struct_serializes_correctly, output_contains_expected_keys_and_values, empty_vec_serializes_to_array.

@ -1,11 +1,12 @@
---
# claudbg-ftb9
title: Table output renderer
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-27T19:40:52Z
updated_at: 2026-03-27T19:40:52Z
updated_at: 2026-03-28T18:05:04Z
parent: claudbg-g7zj
---
Implement the table renderer using comfy-table or tabled. Define a trait (e.g. Renderable) that all data types implement to produce a table. Used as the default --output=table format. Handle terminal width gracefully (truncate or wrap columns).
Implemented src/output/table.rs with render_table() using comfy-table with ContentArrangement::Dynamic. Added 4 unit tests: empty_rows_contains_headers, single_row_contains_data, long_content_does_not_panic, multiple_rows_all_present. Also created src/output/mod.rs and uncommented pub mod output; in src/lib.rs as part of this ticket.

20
Cargo.lock generated

@ -243,6 +243,7 @@ dependencies = [
"libsql",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"uuid",
@ -334,6 +335,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@ -964,6 +971,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"

@ -5,7 +5,7 @@ pub mod commands;
pub mod db;
pub mod error;
pub mod models;
// pub mod output;
pub mod output;
pub mod parser;
pub mod util;

@ -0,0 +1,86 @@
//! JSON output renderer.
use serde::Serialize;
use crate::error::{AppError, Result};
/// Serialize a value to a pretty-printed JSON string.
///
/// Any serialization error is wrapped in [`AppError::Parse`].
pub fn render_json<T: Serialize>(value: &T) -> Result<String> {
serde_json::to_string_pretty(value).map_err(|e| AppError::Parse(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
#[derive(Serialize)]
struct Simple {
name: String,
count: u32,
}
#[derive(Serialize)]
struct Nested {
inner: Simple,
tags: Vec<String>,
}
/// A simple struct serializes to valid JSON containing the expected keys.
#[test]
fn simple_struct_serializes_to_valid_json() {
let val = Simple {
name: "alice".to_string(),
count: 42,
};
let result = render_json(&val).unwrap();
// Must be valid JSON.
let parsed: serde_json::Value = serde_json::from_str(&result).expect("invalid JSON output");
assert_eq!(parsed["name"], "alice");
assert_eq!(parsed["count"], 42);
}
/// A nested struct serializes correctly with all nested fields present.
#[test]
fn nested_struct_serializes_correctly() {
let val = Nested {
inner: Simple {
name: "bob".to_string(),
count: 7,
},
tags: vec!["alpha".to_string(), "beta".to_string()],
};
let result = render_json(&val).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).expect("invalid JSON output");
assert_eq!(parsed["inner"]["name"], "bob");
assert_eq!(parsed["inner"]["count"], 7);
assert_eq!(parsed["tags"][0], "alpha");
assert_eq!(parsed["tags"][1], "beta");
}
/// The output contains the expected key and value strings.
#[test]
fn output_contains_expected_keys_and_values() {
let val = Simple {
name: "session-xyz".to_string(),
count: 100,
};
let result = render_json(&val).unwrap();
assert!(result.contains("\"name\""));
assert!(result.contains("\"session-xyz\""));
assert!(result.contains("\"count\""));
assert!(result.contains("100"));
}
/// An empty vector serializes to a JSON array.
#[test]
fn empty_vec_serializes_to_array() {
let val: Vec<String> = vec![];
let result = render_json(&val).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).expect("invalid JSON output");
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), 0);
}
}

@ -0,0 +1,45 @@
//! Output format renderers: table, JSON, XML.
pub mod json;
pub mod table;
pub mod xml;
/// A row of string cells for table or XML rendering.
pub type Row = Vec<String>;
pub use json::render_json;
pub use table::render_table;
pub use xml::render_xml_rows;
#[cfg(test)]
mod tests {
use super::*;
/// Re-exported `render_table` is callable from the module root.
#[test]
fn reexport_render_table() {
let result = render_table(&["a", "b"], &[vec!["1".to_string(), "2".to_string()]]);
assert!(result.is_ok());
}
/// Re-exported `render_json` is callable from the module root.
#[test]
fn reexport_render_json() {
let result = render_json(&vec![1u32, 2, 3]);
assert!(result.is_ok());
}
/// Re-exported `render_xml_rows` is callable from the module root.
#[test]
fn reexport_render_xml_rows() {
let result = render_xml_rows(&["col"], &[vec!["val".to_string()]]);
assert!(result.is_ok());
}
/// `Row` type alias is a `Vec<String>`.
#[test]
fn row_type_alias() {
let row: Row = vec!["hello".to_string(), "world".to_string()];
assert_eq!(row.len(), 2);
}
}

@ -0,0 +1,69 @@
//! Table output renderer using comfy-table.
use comfy_table::{ContentArrangement, Table};
use crate::error::Result;
/// Render a table to a [`String`] using comfy-table with dynamic column sizing.
///
/// `headers` is a slice of column header strings.
/// `rows` is a slice of row vectors; each inner [`Vec`] must have the same
/// length as `headers`.
pub fn render_table(headers: &[&str], rows: &[Vec<String>]) -> Result<String> {
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(headers);
for row in rows {
table.add_row(row);
}
Ok(table.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// Empty rows returns a string that still contains the header column names.
#[test]
fn empty_rows_contains_headers() {
let result = render_table(&["session_id", "project"], &[]).unwrap();
assert!(result.contains("session_id"), "missing header 'session_id'");
assert!(result.contains("project"), "missing header 'project'");
}
/// A single data row results in output that contains the row values.
#[test]
fn single_row_contains_data() {
let rows = vec![vec!["abc12345".to_string(), "/home/user/proj".to_string()]];
let result = render_table(&["id", "path"], &rows).unwrap();
assert!(result.contains("abc12345"), "missing cell 'abc12345'");
assert!(
result.contains("/home/user/proj"),
"missing cell '/home/user/proj'"
);
}
/// Long content in cells does not panic; dynamic arrangement handles width.
#[test]
fn long_content_does_not_panic() {
let long = "x".repeat(1000);
let rows = vec![vec![long.clone(), "short".to_string()]];
let result = render_table(&["col_a", "col_b"], &rows).unwrap();
// Just verify we got a non-empty string back without panicking.
assert!(!result.is_empty());
}
/// Multiple rows are all represented in the output.
#[test]
fn multiple_rows_all_present() {
let rows = vec![
vec!["row1_a".to_string(), "row1_b".to_string()],
vec!["row2_a".to_string(), "row2_b".to_string()],
vec!["row3_a".to_string(), "row3_b".to_string()],
];
let result = render_table(&["col_a", "col_b"], &rows).unwrap();
assert!(result.contains("row1_a"));
assert!(result.contains("row2_b"));
assert!(result.contains("row3_a"));
}
}

@ -0,0 +1,101 @@
//! XML output renderer for tabular data.
use crate::error::Result;
use crate::util::xml_escape;
/// Render tabular data as XML.
///
/// Produces an XML document with a `<rows>` root element, containing
/// `<row>` elements, each with child elements named after the headers.
/// Attribute values use single quotes. Special XML characters in content
/// are escaped via [`xml_escape`].
///
/// # Example output
///
/// ```text
/// <?xml version='1.0' encoding='UTF-8'?>
/// <rows>
/// <row>
/// <session_id>abc12345</session_id>
/// <project>/home/user/project</project>
/// </row>
/// </rows>
/// ```
pub fn render_xml_rows(headers: &[&str], rows: &[Vec<String>]) -> Result<String> {
let mut out = String::new();
out.push_str("<?xml version='1.0' encoding='UTF-8'?>\n");
out.push_str("<rows>\n");
for row in rows {
out.push_str(" <row>\n");
for (header, cell) in headers.iter().zip(row.iter()) {
let tag = xml_escape(header);
let val = xml_escape(cell);
out.push_str(&format!(" <{tag}>{val}</{tag}>\n"));
}
out.push_str(" </row>\n");
}
out.push_str("</rows>\n");
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// Empty rows produces valid XML with just the root element.
#[test]
fn empty_rows_produces_valid_root_element() {
let result = render_xml_rows(&["id", "name"], &[]).unwrap();
assert!(result.contains("<?xml version='1.0' encoding='UTF-8'?>"));
assert!(result.contains("<rows>"));
assert!(result.contains("</rows>"));
// No row elements when no rows supplied.
assert!(!result.contains("<row>"));
}
/// A single row with clean values produces the correct XML structure.
#[test]
fn single_row_correct_structure() {
let rows = vec![vec!["abc12345".to_string(), "/home/user".to_string()]];
let result = render_xml_rows(&["session_id", "project"], &rows).unwrap();
assert!(result.contains("<row>"));
assert!(result.contains("</row>"));
assert!(result.contains("<session_id>abc12345</session_id>"));
assert!(result.contains("<project>/home/user</project>"));
}
/// Values containing `<`, `>`, and `&` are properly escaped.
#[test]
fn special_chars_in_values_are_escaped() {
let rows = vec![vec!["a < b & c > d".to_string()]];
let result = render_xml_rows(&["expr"], &rows).unwrap();
assert!(result.contains("a &lt; b &amp; c &gt; d"));
// Raw unescaped chars must not appear inside the tag content.
assert!(!result.contains(">a < b"));
}
/// Headers with XML-unsafe characters are escaped in tag names.
#[test]
fn special_chars_in_headers_are_escaped() {
let rows = vec![vec!["value".to_string()]];
let result = render_xml_rows(&["col<name>"], &rows).unwrap();
// The tag name should have the < and > escaped.
assert!(result.contains("col&lt;name&gt;"));
}
/// Multiple rows all appear in the output in order.
#[test]
fn multiple_rows_appear_in_order() {
let rows = vec![
vec!["first".to_string()],
vec!["second".to_string()],
vec!["third".to_string()],
];
let result = render_xml_rows(&["item"], &rows).unwrap();
let first_pos = result.find("first").unwrap();
let second_pos = result.find("second").unwrap();
let third_pos = result.find("third").unwrap();
assert!(first_pos < second_pos);
assert!(second_pos < third_pos);
}
}
Loading…
Cancel
Save