329 lines
9.8 KiB
Rust
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}");
|
|
}
|
|
}
|