diff --git a/.beans/claudbg-bt5p--xml-output-renderer.md b/.beans/claudbg-bt5p--xml-output-renderer.md index 72852a5..6a80843 100644 --- a/.beans/claudbg-bt5p--xml-output-renderer.md +++ b/.beans/claudbg-bt5p--xml-output-renderer.md @@ -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. , , , , ). Mechanically wraps each logical section. No DTD or schema validation required. +Implemented src/output/xml.rs with render_xml_rows() producing 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. diff --git a/.beans/claudbg-fkyt--json-output-renderer.md b/.beans/claudbg-fkyt--json-output-renderer.md index 157e40c..08ab1da 100644 --- a/.beans/claudbg-fkyt--json-output-renderer.md +++ b/.beans/claudbg-fkyt--json-output-renderer.md @@ -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() 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. diff --git a/.beans/claudbg-ftb9--table-output-renderer.md b/.beans/claudbg-ftb9--table-output-renderer.md index 7135518..e708c93 100644 --- a/.beans/claudbg-ftb9--table-output-renderer.md +++ b/.beans/claudbg-ftb9--table-output-renderer.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 4454303..02f57f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/src/lib.rs b/src/lib.rs index 11a65c7..3f44620 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/output/json.rs b/src/output/json.rs new file mode 100644 index 0000000..76f3ba7 --- /dev/null +++ b/src/output/json.rs @@ -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(value: &T) -> Result { + 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, + } + + /// 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 = 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); + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs new file mode 100644 index 0000000..f19aee2 --- /dev/null +++ b/src/output/mod.rs @@ -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; + +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`. + #[test] + fn row_type_alias() { + let row: Row = vec!["hello".to_string(), "world".to_string()]; + assert_eq!(row.len(), 2); + } +} diff --git a/src/output/table.rs b/src/output/table.rs new file mode 100644 index 0000000..395c1a6 --- /dev/null +++ b/src/output/table.rs @@ -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]) -> Result { + 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")); + } +} diff --git a/src/output/xml.rs b/src/output/xml.rs new file mode 100644 index 0000000..abc06b1 --- /dev/null +++ b/src/output/xml.rs @@ -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 `` root element, containing +/// `` 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 +/// +/// +/// +/// abc12345 +/// /home/user/project +/// +/// +/// ``` +pub fn render_xml_rows(headers: &[&str], rows: &[Vec]) -> Result { + let mut out = String::new(); + out.push_str("\n"); + out.push_str("\n"); + for row in rows { + out.push_str(" \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}\n")); + } + out.push_str(" \n"); + } + out.push_str("\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("")); + assert!(result.contains("")); + assert!(result.contains("")); + // No row elements when no rows supplied. + assert!(!result.contains("")); + } + + /// 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("")); + assert!(result.contains("")); + assert!(result.contains("abc12345")); + assert!(result.contains("/home/user")); + } + + /// 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"], &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); + } +}