Files
agent-manager/src/tables.rs
T

460 lines
14 KiB
Rust

//! Generic Nushell-style tables: render a boxed table, filter rows with
//! where-like predicates and select a column with get.
use anyhow::{bail, Result};
use colored::Colorize;
/// One cell: the text to display plus a typed value for filtering
/// (number, byte size, or plain string).
#[derive(Debug, Clone)]
pub struct Cell {
pub text: String,
pub num: Option<f64>,
pub bytes: Option<u64>,
}
impl Cell {
pub fn str(s: impl Into<String>) -> Self {
Cell {
text: s.into(),
num: None,
bytes: None,
}
}
pub fn num(n: f64, text: String) -> Self {
Cell {
text,
num: Some(n),
bytes: None,
}
}
pub fn bytes(b: u64, text: String) -> Self {
Cell {
text,
num: None,
bytes: Some(b),
}
}
}
/// A tabular result produced by ls, ps, where or get.
#[derive(Debug, Clone, Default)]
pub struct DataTable {
pub columns: Vec<String>,
pub rows: Vec<Vec<Cell>>,
}
impl DataTable {
pub fn column_index(&self, name: &str) -> Option<usize> {
self.columns
.iter()
.position(|c| c.eq_ignore_ascii_case(name))
}
/// Render the boxed Nushell-style table, fitting max_width columns.
/// When the natural layout is too wide, the first (name) column is
/// truncated with an ellipsis and the trailing columns are dropped if
/// that is still not enough — every row stays on a single line.
pub fn render(&self, color: bool, max_width: usize) -> String {
self.render_with(None, color, max_width)
}
/// Render with the colors of a theme (borders dimmed, header row in the
/// theme's header style instead of plain bold).
pub fn render_themed(
&self,
theme: &crate::theme::Theme,
color: bool,
max_width: usize,
) -> String {
self.render_with(Some(theme), color, max_width)
}
fn render_with(
&self,
theme: Option<&crate::theme::Theme>,
color: bool,
max_width: usize,
) -> String {
let max_width = max_width.max(20);
let headers = self.columns.clone();
if headers.is_empty() {
return String::new();
}
// Column 0 is the row index; the data columns follow.
let index_width = self.rows.len().to_string().len().max(1);
let mut natural: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
let mut cells: Vec<Vec<String>> = Vec::new();
for (i, row) in self.rows.iter().enumerate() {
let mut line = vec![i.to_string()];
for cell in row {
line.push(cell.text.clone());
}
for (c, cell) in line.iter().enumerate().skip(1) {
natural[c - 1] = natural[c - 1].max(visible_len(cell));
}
cells.push(line);
}
let line_width = |ncols: usize, widths: &[usize]| -> usize {
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
};
// Choose the final column set and widths.
let mut data_cols = headers.len();
let mut widths: Vec<usize> = natural.clone();
loop {
if data_cols == 0 {
break;
}
widths.truncate(data_cols);
if line_width(data_cols, &widths) <= max_width {
// Expand the first column into the remaining slack.
widths[0] += max_width - line_width(data_cols, &widths);
break;
}
// Cap the non-first columns, then give the first what remains.
let mut capped = widths.clone();
for w in capped.iter_mut().skip(1) {
*w = (*w).min(16);
}
if line_width(data_cols, &capped) <= max_width {
capped[0] += max_width - line_width(data_cols, &capped);
widths = capped;
break;
}
if data_cols == 1 {
// Last resort: shrink the single column to whatever remains
// after the borders and the index column.
let fixed = 1 + 3 * (data_cols + 1) + index_width;
capped[0] = max_width.saturating_sub(fixed).max(1);
widths = capped;
break;
}
data_cols -= 1;
}
let all_headers: Vec<String> = std::iter::once("#".to_string())
.chain(headers.iter().take(data_cols).cloned())
.collect();
let mut all_widths = vec![index_width];
all_widths.extend(widths.iter().cloned());
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
for (i, w) in all_widths.iter().enumerate() {
if i > 0 {
line.push(mid);
}
line.push_str(&"─".repeat(w + 2));
}
line.push(right);
line
};
let bar = |s: &str| -> String {
if !color {
return s.to_string();
}
match theme {
Some(t) => t.frame(s),
None => s.to_string(),
}
};
let row_line = |row: &[String], header: bool| -> String {
let mut line = bar("│");
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
line.push(' ');
let cell = fit_cell(c, all_widths[i]);
let rendered = if header {
match theme {
Some(t) if color => t.hdr(&cell),
_ => cell.bold().to_string(),
}
} else {
cell
};
line.push_str(&rendered);
line.push(' ');
line.push_str(&bar("│"));
}
line
};
let edge = |s: &str| -> String {
if !color {
return s.to_string();
}
match theme {
Some(t) => t.frame(s),
None => s.dimmed().to_string(),
}
};
let mut out = String::new();
out.push_str(&edge(&border('╭', '┬', '╮')));
out.push('\n');
out.push_str(&row_line(&all_headers, true));
out.push('\n');
out.push_str(&edge(&border('├', '┼', '┤')));
out.push('\n');
for line in &cells {
out.push_str(&row_line(line, false));
out.push('\n');
}
out.push_str(&edge(&border('╰', '┴', '╯')));
out.push('\n');
out
}
}
/// Parse a where value: "1mb"/"512kb"/"2gb" become byte sizes, numbers
/// become numbers, everything else is a string.
#[derive(Debug, Clone, PartialEq)]
enum WhereValue {
Num(f64),
Bytes(u64),
Str(String),
}
fn parse_value(raw: &str) -> WhereValue {
let lower = raw.trim().to_lowercase();
for (suffix, mult) in [("gb", 1024u64.pow(3)), ("mb", 1024u64.pow(2)), ("kb", 1024)] {
if let Some(n) = lower.strip_suffix(suffix) {
if let Ok(v) = n.trim().parse::<f64>() {
return WhereValue::Bytes((v * mult as f64) as u64);
}
}
}
if let Ok(v) = raw.trim().parse::<f64>() {
return WhereValue::Num(v);
}
WhereValue::Str(raw.trim().to_string())
}
fn num_cmp(a: f64, b: f64, op: &str) -> bool {
match op {
">" => a > b,
"<" => a < b,
">=" => a >= b,
"<=" => a <= b,
"==" => (a - b).abs() < f64::EPSILON,
"!=" => (a - b).abs() >= f64::EPSILON,
_ => false,
}
}
/// Filter the rows of a table: where <field> <op> <value>.
pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result<DataTable> {
let Some(idx) = table.column_index(field) else {
bail!(
"unknown column '{field}' — available: {}",
table.columns.join(", ")
);
};
let val = parse_value(value);
let supported = matches!(op, ">" | "<" | ">=" | "<=" | "==" | "!=" | "=~");
if !supported {
bail!("unknown operator '{op}' — use > < >= <= == != =~");
}
let mut out = table.clone();
out.rows.retain(|row| match row.get(idx) {
None => false,
Some(cell) => match &val {
WhereValue::Num(n) => match cell.num {
Some(v) => num_cmp(v, *n, op),
None => false,
},
WhereValue::Bytes(b) => match cell.bytes {
Some(v) => num_cmp(v as f64, *b as f64, op),
None => false,
},
WhereValue::Str(s) => {
let text = strip_ansi(&cell.text);
match op {
"==" => text.eq_ignore_ascii_case(s),
"!=" => !text.eq_ignore_ascii_case(s),
"=~" => text.to_lowercase().contains(&s.to_lowercase()),
_ => false,
}
}
},
});
Ok(out)
}
/// Select one column: get <column>.
pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
let Some(idx) = table.column_index(field) else {
bail!(
"unknown column '{field}' — available: {}",
table.columns.join(", ")
);
};
Ok(DataTable {
columns: vec![field.to_string()],
rows: table
.rows
.iter()
.map(|row| vec![row[idx].clone()])
.collect(),
})
}
/// Pad a cell to its column width, or truncate it (with an ellipsis,
/// preserving any leading color codes) when it is too wide.
fn fit_cell(s: &str, w: usize) -> String {
let n = visible_len(s);
if n <= w {
return format!("{s}{}", " ".repeat(w - n));
}
let plain = strip_ansi(s);
let mut cut: String = plain.chars().take(w.saturating_sub(1).max(1)).collect();
cut.push('…');
if s.contains('') {
let mut prefix = String::new();
let mut chars = s.chars().peekable();
while chars.peek() == Some(&'') {
for ch in chars.by_ref() {
prefix.push(ch);
if ch == 'm' {
break;
}
}
}
format!("{prefix}{cut}")
} else {
cut
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn visible_len(s: &str) -> usize {
strip_ansi(s).chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> DataTable {
DataTable {
columns: vec!["name".into(), "type".into(), "size".into()],
rows: vec![
vec![
Cell::str("src"),
Cell::str("dir"),
Cell::bytes(2048, "2.0 KB".into()),
],
vec![
Cell::str("am.exe"),
Cell::str("file"),
Cell::bytes(5_000_000, "4.8 MB".into()),
],
vec![
Cell::str("readme.md"),
Cell::str("file"),
Cell::bytes(512, "512 B".into()),
],
],
}
}
#[test]
fn filters_numeric_and_size() {
let t = sample();
let big = filter_where(&t, "size", ">", "1mb").unwrap();
assert_eq!(big.rows.len(), 1);
assert_eq!(big.rows[0][0].text, "am.exe");
let eq = filter_where(&t, "type", "==", "dir").unwrap();
assert_eq!(eq.rows.len(), 1);
let contains = filter_where(&t, "name", "=~", "read").unwrap();
assert_eq!(contains.rows.len(), 1);
}
#[test]
fn get_selects_column() {
let t = sample();
let names = get_column(&t, "name").unwrap();
assert_eq!(names.columns, vec!["name".to_string()]);
assert_eq!(names.rows.len(), 3);
assert_eq!(names.rows[1][0].text, "am.exe");
}
#[test]
fn unknown_column_errors() {
let t = sample();
assert!(filter_where(&t, "nope", "==", "x").is_err());
assert!(get_column(&t, "nope").is_err());
}
#[test]
fn parses_where_values() {
assert_eq!(parse_value("1mb"), WhereValue::Bytes(1024 * 1024));
assert_eq!(parse_value("512kb"), WhereValue::Bytes(512 * 1024));
assert_eq!(parse_value("3.5"), WhereValue::Num(3.5));
assert_eq!(parse_value("hello"), WhereValue::Str("hello".into()));
}
#[test]
fn render_is_aligned() {
let t = sample();
let text = t.render(false, 200);
assert!(text.contains("╭"));
assert!(text.contains("name"));
assert!(text.contains("am.exe"));
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}");
}
#[test]
fn render_fits_narrow_width() {
// A table whose first column is too long to fit naturally.
let t = DataTable {
columns: vec!["name".into(), "type".into(), "size".into()],
rows: vec![vec![
Cell::str("a-very-long-file-name-that-overflows.txt"),
Cell::str("file"),
Cell::bytes(512, "512 B".into()),
]],
};
// Trailing columns are dropped when too narrow.
let text = t.render(false, 45);
for line in text.lines() {
assert!(line.chars().count() <= 45, "too wide: {line}");
}
assert!(!text.contains("size"));
// The name column is truncated with an ellipsis at the very end.
let text = t.render(false, 30);
for line in text.lines() {
assert!(line.chars().count() <= 30, "too wide: {line}");
}
assert!(text.contains("…"), "name should be truncated:
{text}");
}
#[test]
fn fit_cell_truncates_and_keeps_color() {
let colored = "\u{1b}[34mverylongname\u{1b}[0m".to_string();
let out = fit_cell(&colored, 8);
assert_eq!(strip_ansi(&out).chars().count(), 8);
assert!(out.starts_with("\u{1b}["));
}
}