Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
62 changes: 61 additions & 1 deletion src/output/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
jpage-godaddy marked this conversation as resolved.
Comment thread
jpage-godaddy marked this conversation as resolved.
Outdated
}

impl TableColumn {
Expand All @@ -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;
Comment thread
jpage-godaddy marked this conversation as resolved.
Outdated
self
}
}

/// Human view definition keyed by schema id.
Expand Down Expand Up @@ -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();
Expand All @@ -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)
};
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 @@ -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}"
);
}
}
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