Files
agent-manager/src/tables.rs
T

329 lines
9.8 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.
pub fn render(&self, color: bool) -> String {
let headers = self.columns.clone();
// Column 0 is the row index; the data columns follow.
let index_width = self.rows.len().to_string().len().max(1);
let mut widths: 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) {
widths[c - 1] = widths[c - 1].max(visible_len(cell));
}
cells.push(line);
}
let all_headers: Vec<String> = std::iter::once("#".to_string())
.chain(headers.iter().cloned())
.collect();
let mut all_widths = vec![index_width];
all_widths.extend(widths.iter().cloned());
for (c, w) in all_widths.iter_mut().enumerate() {
if c < all_headers.len() {
*w = (*w).max(visible_len(&all_headers[c]));
}
}
let pad = |s: &str, w: usize| -> String {
let n = visible_len(s);
format!("{s}{}", " ".repeat(w.saturating_sub(n)))
};
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 row_line = |row: &[String], bold: bool| -> String {
let mut line = String::from("│");
for (i, c) in row.iter().enumerate() {
line.push(' ');
let cell = pad(c, all_widths[i]);
let rendered = if bold {
cell.bold().to_string()
} else {
cell
};
line.push_str(&rendered);
line.push(' ');
line.push('│');
}
line
};
let dim = |s: &str| -> String {
if color {
s.dimmed().to_string()
} else {
s.to_string()
}
};
let mut out = String::new();
out.push_str(&dim(&border('╭', '┬', '╮')));
out.push('\n');
out.push_str(&row_line(&all_headers, true));
out.push('\n');
out.push_str(&dim(&border('├', '┼', '┤')));
out.push('\n');
for line in &cells {
out.push_str(&row_line(line, false));
out.push('\n');
}
out.push_str(&dim(&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(),
})
}
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);
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}");
}
}