//! 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, 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 = 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) { 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::() }; // Choose the final column set and widths. let mut data_cols = headers.len(); let mut widths: Vec = 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 = 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::() { 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(), }) } /// 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 = 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}[")); } }