From fbfc6d1bea22d3de133d05b3b66e2f8da3d89595 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 7 Jul 2026 15:51:58 -0700 Subject: [PATCH 1/4] feat!: add no_truncate opt-out for table columns in human output Table output truncates any column value over 40 characters, which makes URL-bearing columns (e.g. godaddy-cli's `domain agreements`) unusable. Adds a `TableColumn::no_truncate` builder flag so callers can exempt specific columns from the width cap, bounded by a much higher NO_TRUNCATE_MAX_WIDTH to avoid unbounded padding for pathological values. BREAKING CHANGE: TableColumn gains a new public field (no_truncate). Since all of TableColumn's fields are public, this breaks any downstream code constructing it via an exhaustive struct literal instead of TableColumn::new(...)/the builder methods. --- src/output/human.rs | 62 +++++++++++++++++++++++++++++++++++++- tests/exhaustive_output.rs | 25 +++++++++++++++ tests/foundation.rs | 16 ++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/output/human.rs b/src/output/human.rs index ae0f49e..453da41 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -15,6 +15,8 @@ pub struct TableColumn { pub field: String, /// Display header. pub header: String, + /// When true, this column's values are never truncated in table output. + pub no_truncate: bool, } impl TableColumn { @@ -24,8 +26,16 @@ impl TableColumn { Self { field: field.into(), header: header.into(), + no_truncate: false, } } + + /// Opts this column out of the table renderer's column-width truncation. + #[must_use] + pub fn no_truncate(mut self, value: bool) -> Self { + self.no_truncate = value; + self + } } /// Human view definition keyed by schema id. @@ -342,6 +352,12 @@ fn append_next_actions(out: &mut String, actions: &[NextAction]) { } } +/// Upper bound on a `no_truncate` column's width, even though it otherwise +/// skips the normal 40-char cap. Prevents a pathologically long field value +/// (not expected in practice, but not guaranteed by any schema) from padding +/// every row and the separator line out to an unusable or memory-heavy width. +const NO_TRUNCATE_MAX_WIDTH: usize = 4096; + fn render_array_with_columns(items: &[Value], columns: &[TableColumn]) -> String { if items.is_empty() { return "(no results)\n".to_owned(); @@ -364,7 +380,11 @@ fn render_array_with_columns(items: &[Value], columns: &[TableColumn]) -> String .as_object() .and_then(|map| map.get(&column.field)) .map_or_else(String::new, format_value); - widths[index] = widths[index].max(value.len()).min(40); + widths[index] = if column.no_truncate { + widths[index].max(value.len()).min(NO_TRUNCATE_MAX_WIDTH) + } else { + widths[index].max(value.len()).min(40) + }; value }) .collect::>() @@ -584,4 +604,44 @@ mod tests { assert!(out.starts_with("Error:"), "{out}"); assert!(!out.contains("Next steps"), "{out}"); } + + #[test] + fn no_truncate_column_keeps_long_values_intact() { + let long_url = "https://example.com/legal/agreements/registration-agreement-v2"; + assert!(long_url.len() > 40, "fixture must exceed the default cap"); + let items = vec![json!({ "title": long_url, "url": long_url })]; + let columns = vec![ + TableColumn::new("title", "Title"), + TableColumn::new("url", "URL").no_truncate(true), + ]; + + let out = render_array_with_columns(&items, &columns); + + assert!( + out.contains("..."), + "default column should still truncate: {out}" + ); + assert!( + out.contains(long_url), + "no_truncate column must keep the full value: {out}" + ); + } + + #[test] + fn no_truncate_column_still_caps_pathologically_long_values() { + let huge_value = "x".repeat(NO_TRUNCATE_MAX_WIDTH * 2); + let items = vec![json!({ "url": huge_value })]; + let columns = vec![TableColumn::new("url", "URL").no_truncate(true)]; + + let out = render_array_with_columns(&items, &columns); + + assert!( + out.contains("..."), + "values far beyond the no_truncate cap should still be truncated: {out}" + ); + assert!( + !out.contains(&huge_value), + "the full pathological value should not be rendered verbatim: {out}" + ); + } } diff --git a/tests/exhaustive_output.rs b/tests/exhaustive_output.rs index 2e2b82d..a4a3e6b 100644 --- a/tests/exhaustive_output.rs +++ b/tests/exhaustive_output.rs @@ -211,6 +211,31 @@ fn human_view_columns_preserve_shape_for_empty_missing_and_nested_values() { ); } +#[test] +fn human_view_no_truncate_column_preserves_long_values_in_table_output() { + let long_url = "https://certs.godaddy.com/repository/registration-agreement.pdf"; + assert!(long_url.len() > 40, "fixture must exceed the default cap"); + let columns = vec![ + TableColumn::new("title", "Title"), + TableColumn::new("url", "URL").no_truncate(true), + ]; + let envelope = Envelope::success( + json!([{"title": "Registration Agreement", "url": long_url}]), + "agreements:list", + ); + + let rendered = render_human_with_view(&envelope, Some(&columns)); + + assert!( + rendered.contains(long_url), + "no_truncate column must render the full URL untruncated: {rendered}" + ); + assert!( + !rendered.contains("..."), + "no column in this fixture should be truncated: {rendered}" + ); +} + #[test] fn global_registries_tolerate_repeated_and_concurrent_registration() { let prefix = format!( diff --git a/tests/foundation.rs b/tests/foundation.rs index 1ea7c15..ea8e948 100644 --- a/tests/foundation.rs +++ b/tests/foundation.rs @@ -1140,10 +1140,12 @@ async fn cli_config_registers_modules_guides_views_and_init_once() { TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }, TableColumn { field: "enabled".to_owned(), header: "Enabled".to_owned(), + no_truncate: false, }, ], }); @@ -1271,6 +1273,7 @@ async fn cli_config_accepts_trait_based_command_modules() { columns: vec![TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }], }] } @@ -1544,10 +1547,12 @@ async fn cli_seeds_schema_and_human_views_from_global_registries() { TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }, TableColumn { field: "enabled".to_owned(), header: "Enabled".to_owned(), + no_truncate: false, }, ], }); @@ -7150,10 +7155,12 @@ async fn middleware_human_output_default_fields_narrows_view_columns() { TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }, TableColumn { field: "status".to_owned(), header: "Status".to_owned(), + no_truncate: false, }, ], }); @@ -7203,10 +7210,12 @@ async fn middleware_human_output_resolves_declared_view_id() { TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }, TableColumn { field: "status".to_owned(), header: "Status".to_owned(), + no_truncate: false, }, ], }); @@ -7248,6 +7257,7 @@ async fn middleware_human_output_uses_custom_view_function_before_columns() { columns: vec![TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }], }); middleware.human_views.register_func("things:list", |data| { @@ -8348,6 +8358,7 @@ fn human_renderer_column_mixed_object_scalar_array_falls_back_to_lines() { let columns = vec![TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }]; let envelope = Envelope::success( json!([ @@ -8372,10 +8383,12 @@ fn human_view_registry_renders_registered_columns_for_lists() { TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }, TableColumn { field: "enabled".to_owned(), header: "Enabled".to_owned(), + no_truncate: false, }, ], }); @@ -8401,10 +8414,12 @@ fn human_view_registry_renders_registered_columns_for_objects() { TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }, TableColumn { field: "missing".to_owned(), header: "Missing".to_owned(), + no_truncate: false, }, ]; let envelope = Envelope::success(json!({"name": "alpha", "ignored": "x"}), "things"); @@ -8422,6 +8437,7 @@ fn human_view_registry_custom_renderer_wins_over_columns_preserves_legacy_view_f columns: vec![TableColumn { field: "name".to_owned(), header: "Name".to_owned(), + no_truncate: false, }], }); registry.register_func("things", |data| { From 2dc5ac9c77a59296248e9ba54f2c5968be215b1f Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 7 Jul 2026 16:04:24 -0700 Subject: [PATCH 2/4] docs: clarify no_truncate is capped, not fully unbounded TableColumn::no_truncate and its rustdoc said values are "never truncated", but the renderer still caps them at NO_TRUNCATE_MAX_WIDTH. Reword to avoid downstream consumers assuming truly unbounded output. --- src/output/human.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index 453da41..c0efc2d 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -15,7 +15,9 @@ pub struct TableColumn { pub field: String, /// Display header. pub header: String, - /// When true, this column's values are never truncated in table output. + /// When true, this column's values skip the default 40-char width cap in + /// table output (still capped at `NO_TRUNCATE_MAX_WIDTH` to bound + /// pathologically long values). pub no_truncate: bool, } @@ -30,7 +32,8 @@ impl TableColumn { } } - /// Opts this column out of the table renderer's column-width truncation. + /// Opts this column out of the table renderer's default 40-char + /// column-width cap. Values are still capped at `NO_TRUNCATE_MAX_WIDTH`. #[must_use] pub fn no_truncate(mut self, value: bool) -> Self { self.no_truncate = value; From f4df30c16c1d47afb2ba1d185d2344fdd9152f44 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 7 Jul 2026 16:10:25 -0700 Subject: [PATCH 3/4] fix: prevent column width from shrinking below its header length widths[index] was seeded from the header length but then clamped by .min(40)/.min(NO_TRUNCATE_MAX_WIDTH), which could push it below the header's own length for an unusually long header. render_table never truncates headers (only pads), so that column's header would overflow past the padded separator/row cells, breaking table alignment. --- src/output/human.rs | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/output/human.rs b/src/output/human.rs index c0efc2d..83e87d3 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -384,9 +384,15 @@ fn render_array_with_columns(items: &[Value], columns: &[TableColumn]) -> String .and_then(|map| map.get(&column.field)) .map_or_else(String::new, format_value); widths[index] = if column.no_truncate { - widths[index].max(value.len()).min(NO_TRUNCATE_MAX_WIDTH) + widths[index] + .max(value.len()) + .min(NO_TRUNCATE_MAX_WIDTH) + .max(column.header.len()) } else { - widths[index].max(value.len()).min(40) + widths[index] + .max(value.len()) + .min(40) + .max(column.header.len()) }; value }) @@ -449,7 +455,7 @@ fn render_array(items: &[Value]) -> String { .as_object() .and_then(|map| map.get(col)) .map_or_else(String::new, format_value); - widths[index] = widths[index].max(value.len()).min(40); + widths[index] = widths[index].max(value.len()).min(40).max(col.len()); value }) .collect::>() @@ -647,4 +653,25 @@ mod tests { "the full pathological value should not be rendered verbatim: {out}" ); } + + #[test] + fn column_width_never_shrinks_below_a_long_header() { + let long_header = "A Very Long Header That Exceeds The Default Width Cap"; + assert!( + long_header.len() > 40, + "fixture must exceed the default cap" + ); + let items = vec![json!({ "field": "short" })]; + let columns = vec![TableColumn::new("field", long_header)]; + + let out = render_array_with_columns(&items, &columns); + let header_line = out.lines().next().expect("header line"); + let separator_line = out.lines().nth(1).expect("separator line"); + + assert_eq!( + header_line.len(), + separator_line.len(), + "header and separator must stay aligned when the header exceeds the cap: {out}" + ); + } } From 5d720ded558bff28a2a9ced9a1b2b74365666b8b Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 7 Jul 2026 16:16:01 -0700 Subject: [PATCH 4/4] docs: clarify NO_TRUNCATE_MAX_WIDTH bounds values, not headers The cap is applied before the header-length floor, so an (unrealistic) header exceeding the cap is never truncated or misaligned. Document this precedence explicitly rather than reordering, which would reintroduce the header-overflow bug this cap's neighbor fix addressed. --- src/output/human.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/output/human.rs b/src/output/human.rs index 83e87d3..8c4318c 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -359,6 +359,13 @@ fn append_next_actions(out: &mut String, actions: &[NextAction]) { /// skips the normal 40-char cap. Prevents a pathologically long field value /// (not expected in practice, but not guaranteed by any schema) from padding /// every row and the separator line out to an unusable or memory-heavy width. +/// +/// This bounds runtime *values*, not the column *header*: width is always +/// widened back up to `column.header.len()` after the cap is applied, so a +/// header can never be truncated or misaligned even in the (unrealistic) +/// case where it exceeds `NO_TRUNCATE_MAX_WIDTH` itself. Headers are static, +/// developer-authored labels, not the pathological runtime data this cap +/// guards against. const NO_TRUNCATE_MAX_WIDTH: usize = 4096; fn render_array_with_columns(items: &[Value], columns: &[TableColumn]) -> String {