diff --git a/src/output/human.rs b/src/output/human.rs index ae0f49e..8c4318c 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -15,6 +15,10 @@ pub struct TableColumn { pub field: String, /// Display header. pub header: String, + /// 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, } impl TableColumn { @@ -24,8 +28,17 @@ impl TableColumn { Self { field: field.into(), header: header.into(), + no_truncate: false, } } + + /// 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; + self + } } /// Human view definition keyed by schema id. @@ -342,6 +355,19 @@ 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. +/// +/// 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 { if items.is_empty() { return "(no results)\n".to_owned(); @@ -364,7 +390,17 @@ 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) + .max(column.header.len()) + } else { + widths[index] + .max(value.len()) + .min(40) + .max(column.header.len()) + }; value }) .collect::>() @@ -426,7 +462,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::>() @@ -584,4 +620,65 @@ 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}" + ); + } + + #[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}" + ); + } } 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| {