Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -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())
};
Comment thread
jpage-godaddy marked this conversation as resolved.
Comment thread
jpage-godaddy marked this conversation as resolved.
Comment thread
jpage-godaddy marked this conversation as resolved.
value
})
.collect::<Vec<_>>()
Expand Down Expand Up @@ -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::<Vec<_>>()
Expand Down Expand Up @@ -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}"
);
}
}
25 changes: 25 additions & 0 deletions tests/exhaustive_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
16 changes: 16 additions & 0 deletions tests/foundation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
});
Expand Down Expand Up @@ -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,
}],
}]
}
Expand Down Expand Up @@ -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,
},
],
});
Expand Down Expand Up @@ -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,
},
],
});
Expand Down Expand Up @@ -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,
},
],
});
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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!([
Expand All @@ -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,
},
],
});
Expand All @@ -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");
Expand All @@ -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| {
Expand Down