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
parent
15d9402534
commit
efe70aef7e
@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
# claudbg-bt5p
|
# claudbg-bt5p
|
||||||
title: XML output renderer
|
title: XML output renderer
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-03-27T19:40:53Z
|
created_at: 2026-03-27T19:40:53Z
|
||||||
updated_at: 2026-03-27T19:40:53Z
|
updated_at: 2026-03-28T18:05:04Z
|
||||||
parent: claudbg-g7zj
|
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
|
# claudbg-fkyt
|
||||||
title: JSON output renderer
|
title: JSON output renderer
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-03-27T19:40:52Z
|
created_at: 2026-03-27T19:40:52Z
|
||||||
updated_at: 2026-03-27T19:40:52Z
|
updated_at: 2026-03-28T18:05:04Z
|
||||||
parent: claudbg-g7zj
|
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
|
# claudbg-ftb9
|
||||||
title: Table output renderer
|
title: Table output renderer
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-03-27T19:40:52Z
|
created_at: 2026-03-27T19:40:52Z
|
||||||
updated_at: 2026-03-27T19:40:52Z
|
updated_at: 2026-03-28T18:05:04Z
|
||||||
parent: claudbg-g7zj
|
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.
|
||||||
|
|||||||
@ -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 < b & c > 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<name>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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…
Reference in New Issue