//! 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, pub bytes: Option, } impl Cell { pub fn str(s: impl Into) -> 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, pub rows: Vec>, } impl DataTable { pub fn column_index(&self, name: &str) -> Option { 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 = headers.iter().map(|h| visible_len(h)).collect(); let mut cells: Vec> = 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 = 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::() { return WhereValue::Bytes((v * mult as f64) as u64); } } } if let Ok(v) = raw.trim().parse::() { 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 . pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result { 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 . pub fn get_column(table: &DataTable, field: &str) -> Result { 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 = text.lines().map(|l| l.chars().count()).collect(); assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}"); } }