use super::Render;
use super::width::visible_width;
#[allow(dead_code)]
#[track_caller]
pub fn render_table<T: Render>(data: &T) -> String {
let headers = T::headers();
let rows = data.rows();
for row in &rows {
assert_eq!(
row.len(),
headers.len(),
"{}::rows() returned a row with {} cells, but headers() has {}",
std::any::type_name::<T>(),
row.len(),
headers.len(),
);
}
let mut widths: Vec<usize> = headers.iter().copied().map(visible_width).collect();
for row in &rows {
for (width, cell) in widths.iter_mut().zip(row) {
*width = (*width).max(visible_width(cell));
}
}
let mut out = String::new();
write_row(&mut out, headers.iter().copied(), &widths);
for row in &rows {
write_row(&mut out, row.iter().map(String::as_str), &widths);
}
out
}
fn write_row<'a>(out: &mut String, cells: impl Iterator<Item = &'a str>, widths: &[usize]) {
let cells: Vec<&str> = cells.collect();
let last = cells.len().saturating_sub(1);
for (i, cell) in cells.into_iter().enumerate() {
out.push_str(cell);
if i != last {
let pad = widths[i].saturating_sub(visible_width(cell));
out.extend(core::iter::repeat_n(' ', pad));
out.push_str(" ");
}
}
out.push('\n');
}
#[allow(dead_code)]
#[must_use]
pub fn human_duration(ms: u64) -> String {
const SECOND_MS: u64 = 1_000;
const MINUTE_MS: u64 = 60 * SECOND_MS;
const HOUR_MS: u64 = 60 * MINUTE_MS;
const DAY_MS: u64 = 24 * HOUR_MS;
let units: [(u64, &str); 4] = [
(ms / DAY_MS, "d"),
((ms % DAY_MS) / HOUR_MS, "h"),
((ms % HOUR_MS) / MINUTE_MS, "m"),
((ms % MINUTE_MS) / SECOND_MS, "s"),
];
let mut nonzero = units.iter().filter(|(value, _)| *value > 0);
match (nonzero.next(), nonzero.next()) {
(Some(&(a, au)), Some(&(b, bu))) => format!("{a}{au} {b}{bu}"),
(Some(&(a, au)), None) => format!("{a}{au}"),
(None, _) => "0s".to_string(),
}
}
#[must_use]
pub fn local_timestamp(at_ms: u64) -> String {
let Ok(millis) = i64::try_from(at_ms) else {
return at_ms.to_string();
};
let Some(utc) = chrono::DateTime::from_timestamp_millis(millis) else {
return at_ms.to_string();
};
utc.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
}
#[must_use]
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [(u64, &str); 6] = [
(1 << 60, "E"),
(1 << 50, "P"),
(1 << 40, "T"),
(1 << 30, "G"),
(1 << 20, "M"),
(1 << 10, "K"),
];
for (unit, suffix) in UNITS {
if bytes >= unit {
#[allow(clippy::cast_precision_loss)] return format!("{:.1}{suffix}", bytes as f64 / unit as f64);
}
}
format!("{bytes}B")
}
const FLOOR_COLUMNS: usize = 3;
pub(crate) struct BoxedTable {
pub(crate) rendered: String,
pub(crate) dropped: Vec<String>,
}
#[allow(dead_code)]
pub(crate) fn render_boxed(
headers: &[&str],
rows: &[Vec<String>],
priorities: &[u8],
term_width: usize,
) -> String {
render_boxed_ex(headers, rows, priorities, term_width).rendered
}
pub(crate) fn render_boxed_ex(
headers: &[&str],
rows: &[Vec<String>],
priorities: &[u8],
term_width: usize,
) -> BoxedTable {
let rows: Vec<Vec<String>> = rows
.iter()
.map(|row| {
row.iter()
.map(|cell| crate::output::width::sanitize_cell(cell))
.collect()
})
.collect();
let rows = &rows;
let mut keep: Vec<usize> = (0..headers.len()).collect();
let mut dropped: Vec<&str> = Vec::new();
loop {
let widths = column_widths(headers, rows, &keep);
let total: usize = widths.iter().map(|w| w + 3).sum::<usize>() + 1;
if total <= term_width || keep.len() <= FLOOR_COLUMNS {
break;
}
let worst = keep
.iter()
.enumerate()
.max_by_key(|&(_, &col)| priorities.get(col).copied().unwrap_or(0))
.map(|(at, _)| at);
let Some(at) = worst else { break };
if priorities.get(keep[at]).copied().unwrap_or(0) == 0 {
break;
}
dropped.push(headers[keep[at]]);
keep.remove(at);
}
let widths = column_widths(headers, rows, &keep);
let rule = |left: &str, mid: &str, right: &str| {
let mut line = String::from(left);
for (i, w) in widths.iter().enumerate() {
if i > 0 {
line.push_str(mid);
}
line.push_str(&"─".repeat(w + 2));
}
line.push_str(right);
line.push('\n');
line
};
let mut out = rule("┌", "┬", "┐");
out.push_str(&boxed_row(
&keep
.iter()
.map(|&c| headers[c].to_string())
.collect::<Vec<_>>(),
&widths,
));
out.push_str(&rule("├", "┼", "┤"));
for row in rows {
out.push_str(&boxed_row(
&keep
.iter()
.map(|&c| row.get(c).cloned().unwrap_or_default())
.collect::<Vec<_>>(),
&widths,
));
}
out.push_str(&rule("└", "┴", "┘"));
dropped.sort_unstable();
if !dropped.is_empty() {
out.push_str(&format!(
" {} hidden. Widen the window, or use --format json.\n",
dropped.join(", ")
));
}
BoxedTable {
rendered: out,
dropped: dropped.into_iter().map(str::to_string).collect(),
}
}
fn column_widths(headers: &[&str], rows: &[Vec<String>], keep: &[usize]) -> Vec<usize> {
keep.iter()
.map(|&col| {
let mut w = visible_width(headers[col]);
for row in rows {
if let Some(cell) = row.get(col) {
w = w.max(visible_width(cell));
}
}
w
})
.collect()
}
fn boxed_row(cells: &[String], widths: &[usize]) -> String {
let mut line = String::from("│");
for (cell, w) in cells.iter().zip(widths) {
let pad = w.saturating_sub(visible_width(cell));
line.push(' ');
line.push_str(cell);
line.push_str(&" ".repeat(pad));
line.push_str(" │");
}
line.push('\n');
line
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::FlockRows;
use crate::output::rows::tests::info_with_uptime_ms;
#[test]
fn an_empty_payload_renders_headers_rather_than_a_bare_blank() {
let out = render_table(&FlockRows(vec![]));
assert!(
out.contains("NAME"),
"an empty flock still tells the user what it would show"
);
assert_eq!(out.lines().filter(|l| !l.trim().is_empty()).count(), 1);
}
#[test]
fn uptime_is_a_duration_in_the_table_and_a_number_in_json() {
let rows = FlockRows(vec![info_with_uptime_ms(3_723_000)]); let table = render_table(&rows);
assert!(table.contains("1h"), "table uptime is for a human: {table}");
let json = serde_json::to_value(&rows).unwrap();
assert_eq!(json[0]["uptime_ms"], serde_json::json!(3_723_000u64));
assert!(
json[0].get("uptime").is_none(),
"no formatted duplicate on the machine surface"
);
}
#[test]
fn human_duration_takes_the_two_largest_nonzero_units() {
assert_eq!(human_duration(3_723_000), "1h 2m");
assert_eq!(human_duration(184_000), "3m 4s");
assert_eq!(human_duration(5_000), "5s");
assert_eq!(human_duration(0), "0s");
}
#[test]
fn human_duration_day_arm_skips_a_zero_middle_unit() {
assert_eq!(human_duration(86_700_000), "1d 5m"); assert_eq!(human_duration(3_602_000), "1h 2s"); }
#[test]
fn local_timestamp_round_trips_through_the_hosts_own_zone() {
let at_ms: u64 = 1_700_000_000_000; let rendered = local_timestamp(at_ms);
assert_eq!(
rendered.len(),
19,
"shape is `YYYY-MM-DD HH:MM:SS`: {rendered}"
);
let parsed = chrono::NaiveDateTime::parse_from_str(&rendered, "%Y-%m-%d %H:%M:%S")
.unwrap_or_else(|e| {
panic!("local_timestamp produced something unparseable: {rendered}: {e}")
});
let resolved_utc = parsed
.and_local_timezone(chrono::Local)
.single()
.unwrap_or_else(|| panic!("{rendered} does not resolve to one local instant"))
.with_timezone(&chrono::Utc);
assert_eq!(
resolved_utc.timestamp_millis(),
i64::try_from(at_ms).unwrap(),
"the rendered cell must name the same instant at_ms does, in whatever zone \
this machine runs"
);
}
#[test]
fn local_timestamp_falls_back_to_the_raw_number_when_it_will_not_render() {
assert_eq!(
local_timestamp(u64::MAX),
u64::MAX.to_string(),
"too large to fit i64 at all"
);
assert_eq!(
local_timestamp(u64::try_from(i64::MAX).unwrap()),
i64::MAX.to_string(),
"fits i64, but names a calendar date far outside what chrono can represent"
);
}
struct MalformedRow;
impl serde::Serialize for MalformedRow {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_unit()
}
}
impl Render for MalformedRow {
fn headers() -> &'static [&'static str] {
&["A", "B"]
}
fn rows(&self) -> Vec<Vec<String>> {
vec![vec!["1".to_string(), "2".to_string(), "3".to_string()]]
}
fn json_key_for(header: &str) -> &'static str {
match header {
"A" => "a",
"B" => "b",
other => panic!("MalformedRow::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
}
#[test]
#[should_panic(
expected = "MalformedRow::rows() returned a row with 3 cells, but headers() has 2"
)]
fn render_table_panics_on_a_row_whose_arity_does_not_match_headers() {
render_table(&MalformedRow);
}
fn info_with_name(name: &str) -> shep_core::protocol::ProcessInfo {
shep_core::protocol::ProcessInfo::builder(1, name, shep_core::status::ProcStatus::Online)
.build()
}
#[test]
fn bytes_render_with_a_unit_a_reader_can_scan() {
assert_eq!(human_bytes(0), "0B");
assert_eq!(human_bytes(512), "512B");
assert_eq!(human_bytes(50_462_720), "48.1M");
assert_eq!(human_bytes(3 << 30), "3.0G");
assert_eq!(human_bytes(u64::MAX), "16.0E");
}
#[test]
fn column_widths_count_display_columns_not_characters_or_bytes() {
let ascii_name = "wwwwww".to_string(); let cjk_name = "羊".repeat(6); assert_eq!(ascii_name.chars().count(), cjk_name.chars().count());
let lines = |name: &str| -> (usize, usize) {
let table = render_table(&FlockRows(vec![info_with_name(name)]));
let mut lines = table.lines();
let header = visible_width(lines.next().expect("a header line"));
let row = visible_width(lines.next().expect("a row line"));
(header, row)
};
let (ascii_header, ascii_row) = lines(&ascii_name);
let (cjk_header, cjk_row) = lines(&cjk_name);
assert_eq!(
cjk_header - ascii_header,
6,
"six `羊` draw six columns wider than six `w`, so the NAME column \
is padded six wider — a character count makes this 0 and a byte \
count makes it 12"
);
assert_eq!(
cjk_row - ascii_row,
6,
"and the row moves with its own header, or the two disagree about \
where the second column starts"
);
}
fn table_lines(out: &str) -> Vec<&str> {
out.lines()
.filter(|l| l.starts_with(['┌', '├', '│', '└']))
.collect()
}
fn dirty_cell_text() -> impl proptest::strategy::Strategy<Value = String> {
use proptest::prelude::*;
prop_oneof![
3 => "[a-z(). -]{0,12}".prop_map(String::from),
1 => ("[a-z]{0,4}", prop_oneof![Just('\n'), Just('\r'), Just('\t')], "[a-z]{0,4}")
.prop_map(|(a, c, b)| format!("{a}{c}{b}")),
1 => "[a-z]{0,4}".prop_map(|a| format!("{a}\u{1b}[3;1")),
]
}
#[test]
fn every_line_of_a_boxed_table_has_the_same_visible_width() {
use proptest::prelude::*;
proptest!(|(
cells in proptest::collection::vec(
proptest::collection::vec(
(dirty_cell_text(), any::<bool>()).prop_map(|(s, styled)| {
if styled {
format!("\u{1b}[32m{s}\u{1b}[0m")
} else {
s
}
}),
3..6),
0..5),
term in 20usize..200,
)| {
let headers = ["ID", "NAME", "STATUS", "PID", "MEM"];
let n = cells.first().map_or(3, Vec::len);
let headers = &headers[..n];
let priorities: Vec<u8> = (0..n).map(|i| u8::try_from(i).unwrap_or(u8::MAX)).collect();
let out = render_boxed(headers, &cells, &priorities, term);
let lines = table_lines(&out);
let widths: Vec<usize> = lines
.iter()
.map(|l| visible_width(l))
.collect();
if let Some(&first) = widths.first() {
prop_assert!(
widths.iter().all(|&w| w == first),
"ragged table at term={term}: widths {widths:?}\n{out}"
);
let columns_kept = lines
.first()
.map_or(0, |top_rule| top_rule.matches('┬').count() + 1);
prop_assert!(
first <= term || columns_kept == FLOOR_COLUMNS,
"table {first} columns wide exceeds term={term} with {columns_kept} kept \
(floor is {FLOOR_COLUMNS}):\n{out}"
);
}
});
}
#[test]
fn columns_drop_by_priority_and_never_below_three() {
let headers = ["ID", "NAME", "STATUS", "PID", "FOLD"];
let rows = vec![vec![
"0".into(),
"zeus-auth".into(),
"(o.o) online".into(),
"24963".into(),
"backend".into(),
]];
let priorities = [0, 0, 0, 2, 6];
let wide = render_boxed(&headers, &rows, &priorities, 200);
assert!(wide.contains("FOLD"), "everything fits at 200:\n{wide}");
let narrow = render_boxed(&headers, &rows, &priorities, 46);
let narrow_table = table_lines(&narrow).join("\n");
assert!(
!narrow_table.contains("FOLD"),
"FOLD drops first:\n{narrow}"
);
assert!(
narrow.contains("NAME"),
"identity columns survive:\n{narrow}"
);
assert!(
narrow.contains("hidden"),
"and the footer says so:\n{narrow}"
);
let tiny = render_boxed(&headers, &rows, &priorities, 10);
for keep in ["ID", "NAME", "STATUS"] {
assert!(tiny.contains(keep), "{keep} is a floor column:\n{tiny}");
}
}
#[test]
fn the_footer_names_every_column_it_hid() {
let headers = ["ID", "NAME", "STATUS", "CPU", "FOLD"];
let rows = vec![vec![
"0".into(),
"a".into(),
"(o.o)".into(),
"0%".into(),
"b".into(),
]];
let out = render_boxed(&headers, &rows, &[0, 0, 0, 5, 6], 20);
let footer = out.lines().last().unwrap();
assert!(footer.contains("CPU"), "{footer}");
assert!(footer.contains("FOLD"), "{footer}");
assert!(
footer.contains("--format json"),
"and the way to see them: {footer}"
);
}
#[test]
fn the_dropped_column_footer_has_no_em_dashes() {
let headers = ["ID", "NAME", "STATUS", "CPU", "FOLD"];
let rows = vec![vec![
"0".into(),
"a".into(),
"(o.o)".into(),
"0%".into(),
"b".into(),
]];
let out = render_boxed(&headers, &rows, &[0, 0, 0, 5, 6], 20);
let footer = out.lines().last().unwrap();
assert!(!footer.contains('\u{2014}'), "em dash in footer: {footer}");
assert!(!footer.contains('\u{2013}'), "en dash in footer: {footer}");
}
#[test]
fn render_boxed_ex_reports_exactly_what_it_dropped() {
let headers = ["ID", "NAME", "STATUS", "CPU", "FOLD"];
let rows = vec![vec![
"0".into(),
"a".into(),
"(o.o)".into(),
"0%".into(),
"b".into(),
]];
let priorities = [0, 0, 0, 5, 6];
let fits = render_boxed_ex(&headers, &rows, &priorities, 200);
assert!(fits.dropped.is_empty(), "everything fits at 200");
assert_eq!(
fits.rendered,
render_boxed(&headers, &rows, &priorities, 200)
);
let narrow = render_boxed_ex(&headers, &rows, &priorities, 20);
assert_eq!(narrow.dropped, vec!["CPU".to_string(), "FOLD".to_string()]);
assert_eq!(
narrow.rendered,
render_boxed(&headers, &rows, &priorities, 20)
);
}
#[test]
fn a_name_with_an_embedded_newline_does_not_split_its_own_row() {
let headers = ["ID", "NAME", "STATUS"];
let rows = vec![vec!["0".into(), "web\nworker".into(), "online".into()]];
let out = render_boxed(&headers, &rows, &[0, 0, 0], 80);
let box_lines: Vec<&str> = out
.lines()
.filter(|l| l.starts_with(['┌', '├', '│', '└']))
.collect();
assert_eq!(box_lines.len(), 5, "{out}");
assert!(out.contains("web\\nworker"), "escaped, visible: {out}");
assert!(!out.contains("web\nworker"), "no literal newline: {out:?}");
}
use std::ffi::OsStr;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use crate::output::table_of;
use crate::style::{Presentation, StyleLevel};
fn mixed_flock(butter: ProcStatus) -> FlockRows {
FlockRows(vec![
ProcessInfo::builder(0, "web", ProcStatus::Online)
.pid(Some(1234))
.uptime_ms(3_723_000) .build(),
ProcessInfo::builder(1, "worker", butter).build(),
ProcessInfo::builder(2, "api", ProcStatus::Errored)
.restarts(4)
.build(),
ProcessInfo::builder(3, "cron", ProcStatus::Stopped).build(),
])
}
#[test]
fn how_wide_the_real_smits_actually_are() {
assert_eq!(visible_width("\u{25b2} main@a1b2c3"), 13);
assert_eq!(visible_width("\u{23f8} main@f6e5d4"), 13);
}
fn deep_terminal() -> Option<&'static OsStr> {
Some(OsStr::new("xterm-256color"))
}
fn full_at(width: usize) -> Presentation {
Presentation::new(StyleLevel::Full, None, deep_terminal(), None, width)
}
fn mixed_flock_with_smits() -> FlockRows {
let mut flock = mixed_flock(ProcStatus::Starting);
flock.0[0].smit = Some("\u{25b2} main@a1b2c3".to_string());
flock.0[2].smit = Some("\u{23f8} main@f6e5d4".to_string());
flock
}
#[test]
fn full_wide_pins_face_word_and_colour_for_a_mixed_flock() {
let presentation = Presentation::new(StyleLevel::Full, None, deep_terminal(), None, 97);
let rendered = table_of(&mixed_flock(ProcStatus::Starting), presentation);
assert!(
!rendered.contains("hidden"),
"this fixture must fit without dropping a column: {rendered}"
);
assert!(
rendered.contains("starting"),
"the word must survive at a width with room to spare: {rendered}"
);
insta::assert_snapshot!(rendered);
}
#[test]
fn full_narrow_drops_the_status_word_before_a_whole_column() {
let presentation = Presentation::new(StyleLevel::Full, None, deep_terminal(), None, 87);
let rendered = table_of(&mixed_flock(ProcStatus::WaitingRestart), presentation);
assert!(
!rendered.contains("waiting-restart"),
"the word should have dropped: {rendered}"
);
assert!(
!rendered.contains("hidden"),
"and no whole column should have needed to: {rendered}"
);
insta::assert_snapshot!(rendered);
}
const FULL_WIDTH: usize = 93;
#[test]
fn a_smit_is_never_dropped_at_full_width() {
let rendered = table_of(&mixed_flock_with_smits(), full_at(FULL_WIDTH));
assert!(
rendered.contains("\u{25b2} main@a1b2c3"),
"the smit must survive a full-width render: {rendered}"
);
assert!(
!rendered.contains("hidden. Widen the window"),
"and nothing else may be dropped either, or FULL_WIDTH is wrong: {rendered}"
);
insta::assert_snapshot!(rendered);
}
#[test]
fn a_smit_is_the_first_column_dropped_when_the_window_narrows() {
let rendered = table_of(&mixed_flock_with_smits(), full_at(FULL_WIDTH - 1));
assert!(
!rendered.contains("main@a1b2c3"),
"the smit must be gone one column below full width: {rendered}"
);
assert!(
rendered.contains("SMIT hidden.") || rendered.contains("SMIT, "),
"and the footer must name it, so an operator knows to widen: {rendered}"
);
assert!(rendered.contains("FOLD"), "{rendered}");
insta::assert_snapshot!(rendered);
}
#[test]
fn plain_pins_the_boxed_table_with_words_and_colour_but_no_face() {
let presentation = Presentation::new(StyleLevel::Plain, None, deep_terminal(), None, 80);
let rendered = table_of(&mixed_flock(ProcStatus::Starting), presentation);
assert!(!rendered.contains("(o.o)"), "no face at plain: {rendered}");
insta::assert_snapshot!(rendered);
}
#[test]
fn bare_pins_the_byte_identical_plain_table() {
let rendered = table_of(&mixed_flock(ProcStatus::Starting), Presentation::BARE);
assert!(
!rendered.contains('\u{1b}'),
"bare must never emit an escape: {rendered:?}"
);
assert!(
!rendered.contains('┌') && !rendered.contains('│') && !rendered.contains('└'),
"bare must never draw a box: {rendered}"
);
insta::assert_snapshot!(rendered);
}
#[test]
fn full_under_no_color_pins_sheep_and_boxes_without_colour() {
let presentation = Presentation::new(
StyleLevel::Full,
Some(OsStr::new("1")),
deep_terminal(),
None,
80,
);
let rendered = table_of(&mixed_flock(ProcStatus::Starting), presentation);
assert!(
!rendered.contains('\u{1b}'),
"NO_COLOR must leave no escape byte: {rendered:?}"
);
insta::assert_snapshot!(rendered);
}
}