Introduction
cols is a Rust library for building and printing terminal tables with
adaptive column widths, tree rendering, group brackets, and multiple output
formats.
Features
cols is batteries-included: everything you need to build rich CLI table
output is in the box. With default features, cols has zero dependencies.
The two optional features (regex for filter regex operators, color for
terminal styling) each add one small dependency.
- Adaptive column widths: fixed, fractional, or content-sized, with a multi-phase layout algorithm
- Tree rendering: depth-first traversal with Unicode or ASCII connectors
- Built-in output modes: Normal, Raw, Export, JSON, ShellVar, CSV, Markdown
- Streaming: output rows incrementally without buffering the whole table
- Group brackets: M:N member/child relationships with left-margin connectors
- Filter expressions: boolean logic, comparisons, regex, integer suffixes (K/M/G/T)
- Color support: foreground, background, bold, italic, underline (via
coloredcrate, optional) - Builder API: configure columns fluently:
Column::new("NAME").width_fixed(10).right(true)
When to use cols
cols is designed for CLI tools that need adaptive, terminal-aware table
output. It is particularly good for information-dense tools like lsblk,
lsmem, or pstree, where the output needs to be clean, scannable, and
available in multiple formats.
Use cols when you need:
- Tabular CLI output that adapts to the terminal width
- Tree-structured data (like
lsblkorpstree) - Multiple output formats from the same data (human-readable, JSON, CSV, shell-parseable)
- Filtering or sorting built into the table layer
- Streaming output for large or unbounded datasets
If you just need a static table with fixed formatting, or if your main goal
is pretty-printing Rust structs with a derive macro, a crate like tabled
or comfy-table may be a better fit. cols shines when the output needs
to be adaptive, the data is dynamic, and you want the same table to render
as Normal, JSON, CSV, or Markdown without rebuilding it.
Comparison with other crates
There are several other table-formatting crates in the Rust ecosystem.
Here’s how cols compares:
| Feature | cols | tabled | comfy-table | cli-table | prettytable-rs |
|---|---|---|---|---|---|
| Adaptive column widths | Yes | Yes | Yes | Limited | No |
| Tree rendering | Yes | No | No | No | No |
| JSON output | Yes | No | No | No | No |
| CSV / Markdown output | Yes | No | No | No | No |
| Raw/Export/ShellVar output | Yes | No | No | No | No |
| Streaming mode | Yes | No | No | No | No |
| Group brackets | Yes | No | No | No | No |
| Filter expressions | Yes | No | No | No | No |
| Color styling | Yes (optional) | Yes | Yes | Yes | Yes |
| Derive macro | Yes | Yes | No | Yes | No |
| Unicode box-drawing | Yes | Yes | Yes | Yes | Yes |
| Wrapping | Yes | Yes | Yes | No | No |
| Maintained | Yes | Yes | Yes | Yes | No |
tabled is the most popular choice. It focuses on pretty-printing Rust
structs via #[derive(Tabled)] and has extensive styling options. If you’re
printing struct collections and want derive-macro convenience, tabled is a
great fit. It doesn’t support trees, multiple output formats, filtering, or
streaming.
comfy-table focuses on being a rock-solid, minimalistic library with
well-tested code. It has good dynamic content arrangement and ANSI styling.
Like tabled, it’s pure tabular output — no trees, structured output
formats, or streaming.
cli-table provides derive macros and CSV integration. It’s lighter than
tabled but has fewer formatting options.
prettytable-rs was popular historically but has been abandoned since 2021 and has a pending security advisory.
cols is the right choice when you need advanced output: trees, streaming, multiple output formats (especially JSON and shell-parseable), adaptive widths that respect terminal size, and built-in filtering. It’s designed for the kind of CLI tools found in util-linux, where information density and flexibility matter more than decoration.
History
cols started as a clean-room Rust reimplementation of libsmartcols, the
table formatting library from the util-linux project (written by Karel Zak,
Ondrej Oprala, and Igor Gnatenko). libsmartcols powers the output of Linux
tools like lsblk, lsmem, lslogins, and findmnt.
The reimplementation was based solely on the public API (header files and documentation); no source code of the original library was studied. It was originally built for the linuxutils project, where it provided a mostly-compatible FFI layer that could be used as a drop-in replacement. This FFI layer was used to test the code against the actual utilities.
Over time, the library proved useful on its own, so it was extracted into a
standalone crate with a native Rust API and has since grown beyond the
original libsmartcols feature set.
Getting Started
To use cols, add it to your Cargo.toml:
[dependencies]
cols = "0.1"
Core concepts
A cols table has three layers:
- A Table holds configuration (output mode, terminal width, symbols) and owns all columns and lines.
- Columns define the vertical structure — each column has a name, a width hint, and formatting flags (right-align, truncate, wrap, etc.). Columns are built with a fluent builder API.
- Lines are rows. Each line holds one cell per column. Lines can optionally have a parent, forming a tree hierarchy.
You build a table by adding columns, then adding lines and setting their cell data. When you’re ready, call print_table to render the output.
Your first table
use cols::{Table, Column, print_table};
fn main() {
let mut t = Table::new();
t.add_column(Column::new("NAME").width_fixed(10));
t.add_column(Column::new("SIZE").width_fixed(6).right(true));
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "sda").data_set(1, "100G");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "sdb").data_set(1, "50G");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
Output:
NAME SIZE
sda 100G
sdb 50G
The NAME column gets a fixed width of 10 characters. The SIZE column is
right-aligned and 6 characters wide. The print engine pads and aligns the
data automatically.
Printing
print_table writes to any std::io::Write. For stdout:
use cols::print_table;
print_table(&table, &mut std::io::stdout().lock()).unwrap();
To capture the output as a String instead (useful for tests or further
processing):
use cols::print_table_to_string;
let output = print_table_to_string(&table).unwrap();
What’s next
The following chapters walk through each feature in detail:
- Tables and Columns explains width hints, terminal settings, and modifying columns after creation.
- Adding Data covers lines, cells, handles, and iteration.
- Output Modes walks through Normal, Raw, Export, JSON, and ShellVar output.
- Tree Rendering shows how to build parent/child hierarchies.
- Column Formatting covers right-align, truncation, wrapping, and hidden columns.
- Sorting demonstrates alphabetic, numeric, and custom comparators.
- Groups explains M:N relationships with bracket connectors.
- Filtering introduces the expression language for filtering rows.
Tables and Columns
Tables and columns are the foundation of cols. A Table is the top-level
container that owns all configuration, columns, and lines. Columns define the
vertical structure of the table: each column has a name (shown in the header
row), an optional width hint, and formatting flags. You typically set up your
columns first, then add lines with data.
Creating a table
#![allow(unused)]
fn main() {
use cols::Table;
let mut table = Table::new();
table.name_set("devices"); // used as the key in JSON output
}
Adding columns
The simplest way to add a column is new_column, which creates an auto-sized column:
#![allow(unused)]
fn main() {
table.new_column("NAME");
}
For more control, build a Column and add it with add_column:
#![allow(unused)]
fn main() {
use cols::Column;
table.add_column(Column::new("SIZE").width_fixed(6).right(true));
}
Width hints
Width hints guide the layout algorithm. There are three modes:
| Method | Meaning |
|---|---|
| (default) | Auto — sized to fit content |
.width_fixed(n) | At least n characters wide |
.width_fraction(f) | Fraction of terminal width (e.g. 0.25 = 25%) |
Fractional widths only apply when output goes to a terminal. When piped, columns auto-size to their content.
#![allow(unused)]
fn main() {
use cols::{Column, WidthHint};
// These are equivalent:
Column::new("PATH").width_fraction(0.25);
Column::new("PATH").width(WidthHint::Fraction(0.25));
}
Modifying columns after creation
Use table.column_mut(idx) to modify a column that’s already in the table:
#![allow(unused)]
fn main() {
table.column_mut(0).unwrap().right_set(true);
table.column_mut(0).unwrap().hidden_set(true);
}
Terminal settings
Control how the table interacts with the terminal:
#![allow(unused)]
fn main() {
use cols::TermForce;
// explicit width
table.termwidth_set(120);
// force terminal-aware layout
table.termforce_set(TermForce::Always);
// subtract margin from width
table.termwidth_reduce(4);
}
TermForce::Auto (the default) detects whether stdout is a TTY. Use Always in tests or when you want fixed-width output regardless of the output target.
Adding Data
Once your columns are set up, you populate the table by adding lines and
setting cell data. Each line is a row that holds one cell per column. Lines
are referenced by opaque LineId handles, which you get back when creating
a line and use to read or modify it later.
Lines
Lines are rows in the table. Each line holds one cell per column.
#![allow(unused)]
fn main() {
let row = table.new_line(None); // None = no parent (top-level row)
table.line_mut(row).data_set(0, "sda").data_set(1, "100G");
}
The data_set calls are chainable. Column indices are zero-based and match the order columns were added.
Reading data back
#![allow(unused)]
fn main() {
let name = table.line(row).data_get(0); // Some("sda")
let missing = table.line(row).data_get(99); // None
}
Cells
Each cell can also carry metadata:
#![allow(unused)]
fn main() {
let line = table.line_mut(row);
line.cell_mut(0).unwrap().style_set(CellStyle::new().fg(Color::Red));
line.cell_mut(0).unwrap().uri_set("https://example.com");
}
Line handles
new_line returns a LineId — an opaque handle. You can’t construct one by
accident, which prevents indexing bugs.
Iterating
You can walk over all lines in the table, or just the root lines (those without a parent). This is useful for filtering, exporting, or building a second table from an existing one.
#![allow(unused)]
fn main() {
// All lines
for id in table.line_ids() {
let data = table.line(id).data_get(0);
}
// Root lines only (no parent)
for id in table.root_line_ids() {
// ...
}
}
Output Modes
One of the key strengths of cols is that the same table can be rendered in
multiple formats without rebuilding it. This is how tools like lsblk offer
--json, --raw, and --export flags from a single data model. Set the
output mode before printing:
use cols::OutputMode;
table.output_mode_set(OutputMode::Json);
Normal
The default. Human-readable columnar output with padding and alignment.
NAME SIZE
sda 100G
sdb 50G
Raw
Compact, separator-delimited, no padding. Good for piping to awk or cut.
use cols::{Table, OutputMode, print_table};
fn main() {
let mut t = Table::new();
t.output_mode_set(OutputMode::Raw);
t.new_column("NAME");
t.new_column("SIZE");
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "sda").data_set(1, "100G");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "sdb").data_set(1, "50G");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
NAME SIZE
sda 100G
sdb 50G
Export
NAME="value" pairs, one line per row. Useful for shell eval:
use cols::{Table, OutputMode, print_table};
fn main() {
let mut t = Table::new();
t.output_mode_set(OutputMode::Export);
t.new_column("NAME");
t.new_column("SIZE");
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "sda").data_set(1, "100G");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "sdb").data_set(1, "50G");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
NAME="sda" SIZE="100G"
NAME="sdb" SIZE="50G"
ShellVar
Like Export, but column names are sanitized for shell variables (non-alphanumeric characters become _):
use cols::{Table, Column, OutputMode, print_table};
fn main() {
let mut t = Table::new();
t.output_mode_set(OutputMode::ShellVar);
t.add_column(Column::new("MAJ:MIN"));
t.new_column("NAME");
t.new_column("SIZE");
let r1 = t.new_line(None);
t.line_mut(r1)
.data_set(0, "8:0")
.data_set(1, "sda")
.data_set(2, "100G");
let r2 = t.new_line(None);
t.line_mut(r2)
.data_set(0, "8:1")
.data_set(1, "sda1")
.data_set(2, "50G");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
MAJ_MIN="8:0" NAME="sda" SIZE="100G"
MAJ_MIN="8:1" NAME="sda1" SIZE="50G"
JSON
Structured JSON output. The table name becomes the top-level key:
use cols::{Table, OutputMode, print_table};
fn main() {
let mut t = Table::new();
t.name_set("devices");
t.output_mode_set(OutputMode::Json);
t.new_column("NAME");
t.new_column("SIZE");
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "sda").data_set(1, "100G");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "sdb").data_set(1, "50G");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
{
"devices": [
{
"name": "sda",
"size": "100G"
},
{
"name": "sdb",
"size": "50G"
}
]
}
JSON keys
Column names are lowercased for JSON object keys (e.g. "NAME" becomes
"name"). To use a custom key, set it on the column:
Column::new("MAJ_MIN").json_key("majmin")
JSON types
By default all values are strings. Set a JSON type on the column to control serialization:
use cols::{Column, JsonType};
Column::new("COUNT").json_type(JsonType::Number); // 42
Column::new("LOAD").json_type(JsonType::Float); // 3.14
Column::new("ACTIVE").json_type(JsonType::Boolean); // true
Column::new("TAGS").json_type(JsonType::ArrayString); // ["a", "b"]
BooleanOptional renders as null when the cell is empty, instead of false.
CSV
RFC 4180-compliant CSV output. Fields containing commas, quotes, or newlines are automatically quoted.
use cols::{Table, OutputMode, print_table};
fn main() {
let mut t = Table::new();
t.output_mode_set(OutputMode::Csv);
t.new_column("NAME");
t.new_column("SIZE");
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "sda").data_set(1, "100G");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "sdb").data_set(1, "50G");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
NAME,SIZE
sda,100G
sdb,50G
Markdown
Pipe-delimited markdown table with aligned columns. Pipes and backslashes in
data are escaped; newlines become <br>.
use cols::{Table, Column, OutputMode, print_table};
fn main() {
let mut t = Table::new();
t.output_mode_set(OutputMode::Markdown);
t.new_column("NAME");
t.add_column(Column::new("SIZE").right(true));
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "sda").data_set(1, "100G");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "sdb").data_set(1, "50G");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
| NAME | SIZE |
| ---- | ---: |
| sda | 100G |
| sdb | 50G |
Configuration
Additional settings that affect output:
table.headings_set(false); // suppress the header row
table.column_separator_set("|"); // between columns (default: " ")
table.line_separator_set("\n"); // between rows (default: "\n")
table.line_separator_enabled_set(false); // suppress line separators entirely
table.encoding_set(false); // pass control characters through raw
table.ascii_set(true); // ASCII symbols instead of Unicode
Tree Rendering
Many system tools need to display hierarchical data. cols supports this
natively: mark one column as the tree column, create lines with a parent, and
the print engine draws the connectors automatically. Tree depth is factored
into the width calculation, so columns stay aligned regardless of nesting
level.
To draw the trees, cols uses Unicode characters (this is the default), but
you can enable an ASCII-only mode if you need.
Unicode (default)
use cols::{Table, Column, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(40);
t.add_column(Column::new("NAME").tree(true));
let root = t.new_line(None);
t.line_mut(root).data_set(0, "root");
let c1 = t.new_line(Some(root));
t.line_mut(c1).data_set(0, "child1");
let c2 = t.new_line(Some(root));
t.line_mut(c2).data_set(0, "child2");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
NAME
root
├─child1
└─child2
ASCII mode
use cols::{Table, Column, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(40);
t.ascii_set(true);
t.add_column(Column::new("NAME").tree(true));
t.add_column(Column::new("SIZE").right(true));
let root = t.new_line(None);
t.line_mut(root).data_set(0, "root").data_set(1, "100");
let c1 = t.new_line(Some(root));
t.line_mut(c1).data_set(0, "child1").data_set(1, "50");
let c2 = t.new_line(Some(root));
t.line_mut(c2).data_set(0, "child2").data_set(1, "30");
let gc = t.new_line(Some(c1));
t.line_mut(gc).data_set(0, "grandchild").data_set(1, "20");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
NAME SIZE
root 100
|-child1 50
| `-grandchild 20
`-child2 30
Deep trees
The print engine handles arbitrary depth. Each level adds indentation:
root
├─child
│ └─grandchild
│ └─great-grandchild
└─sibling
Reparenting
Move a line to a different parent:
table.add_child(new_parent, child); // removes from old parent automatically
table.remove_child(parent, child); // detach without reattaching
How it works
The print engine does a depth-first walk from root lines at print time. Lines are stored in insertion order internally — the tree structure is defined by the parent/child relationships, not the storage order.
Column Formatting
Each column has flags that control how its data is rendered.
Alignment
Columns default to left-aligned. Set right or center alignment on the column,
and all cells in that column will follow. Individual cells can override the
column’s alignment via cell.alignment_set().
use cols::{Column, Alignment};
Column::new("SIZE").right(true); // right-align
Column::new("STATUS").center(true); // center-align
Column::new("PRICE").align(Alignment::Right); // equivalent to .right(true)
Right alignment
use cols::{Table, Column, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(40);
t.add_column(Column::new("LEFT").width_fixed(10));
t.add_column(Column::new("RIGHT").width_fixed(10).right(true));
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "a").data_set(1, "1");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "bb").data_set(1, "22");
let l3 = t.new_line(None);
t.line_mut(l3).data_set(0, "ccc").data_set(1, "333");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
LEFT RIGHT
a 1
bb 22
ccc 333
Truncation
Truncate data that exceeds the column width:
use cols::{Table, Column, TermForce, print_table};
fn main() {
let mut t = Table::new();
t.termforce_set(TermForce::Always);
t.termwidth_set(20);
t.headings_set(false);
t.add_column(Column::new("A").width_fixed(8).truncate(true));
t.add_column(Column::new("B").width_fixed(8));
let row = t.new_line(None);
t.line_mut(row)
.data_set(0, "this-is-very-long")
.data_set(1, "short");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
this-is-ver short
Wrapping
Wrap long data across multiple output lines:
use cols::{Table, Column, TermForce, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(30);
t.termforce_set(TermForce::Always);
t.add_column(Column::new("ID").width_fixed(4));
t.add_column(Column::new("TEXT").wrap(true));
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "1").data_set(1, "Short text");
let l2 = t.new_line(None);
t.line_mut(l2)
.data_set(0, "2")
.data_set(1, "A longer description that wraps");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
ID TEXT
1 Short text
2 A longer description that
wraps
The table’s wrap_set(false) overrides this flag globally.
Word wrap
By default, wrapping splits at character boundaries. Use word_wrap(true)
to break at word boundaries instead. Words wider than the column fall back
to character-level wrapping.
use cols::{Table, Column, TermForce, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(30);
t.termforce_set(TermForce::Always);
t.add_column(Column::new("ID").width_fixed(4));
t.add_column(Column::new("TEXT").word_wrap(true));
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "1").data_set(1, "Short text");
let l2 = t.new_line(None);
t.line_mut(l2)
.data_set(0, "2")
.data_set(1, "A longer description that wraps at word boundaries");
let l3 = t.new_line(None);
t.line_mut(l3)
.data_set(0, "3")
.data_set(1, "Superlongwordthatexceedsthecolumnwidth here");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
ID TEXT
1 Short text
2 A longer description
that wraps at word
boundaries
3 Superlongwordthatexceedst
hecolumnwidth here
Hidden columns
Store data without displaying it. Useful for sorting or filtering by a column that shouldn’t appear in the output:
use cols::{Table, Column, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(40);
t.add_column(Column::new("A").width_fixed(5));
t.add_column(Column::new("B").width_fixed(5).hidden(true));
t.add_column(Column::new("C").width_fixed(5));
let l = t.new_line(None);
t.line_mut(l)
.data_set(0, "visible")
.data_set(1, "hidden")
.data_set(2, "also visible");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
A C
visible also visible
Strict width
Prevent the layout engine from shrinking a column below its hint:
Column::new("ID").width_fixed(6).strict_width(true)
Normally the layout engine may shrink columns to fit the terminal. strict_width exempts a column from this.
No extremes
Exclude the single largest value when calculating column width:
Column::new("PATH").no_extremes(true)
If one row has a very long value, it won’t force the entire column wide. The outlier value is truncated or overflows instead of dominating the layout.
Encoding
By default, non-printable control characters are encoded as \xHH:
DATA
hello\x01world\x1b[31m
To pass through specific characters, set safechars on the column:
Column::new("DATA").safechars("\t") // tabs pass through, other control chars encoded
Or disable encoding entirely on the table:
table.encoding_set(false);
Header repeat
For long tables, you can repeat the header row at regular intervals so
it stays visible when scrolling. The interval is based on termheight —
headers repeat every termheight - 1 data lines.
use cols::{Table, Column, TermForce, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(30);
t.termforce_set(TermForce::Always);
t.termheight_set(5);
t.header_repeat_set(true);
t.add_column(Column::new("NAME").width_fixed(10));
t.add_column(Column::new("VALUE").width_fixed(10).right(true));
for i in 1..=12 {
let row = t.new_line(None);
t.line_mut(row)
.data_set(0, &format!("item-{i}"))
.data_set(1, &format!("{}", i * 10));
}
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
NAME VALUE
item-1 10
item-2 20
item-3 30
item-4 40
NAME VALUE
item-5 50
item-6 60
item-7 70
item-8 80
NAME VALUE
item-9 90
item-10 100
item-11 110
item-12 120
This works in both flat and tree modes.
Sorting
cols can sort lines by any column. For tree tables, sorting is recursive —
children are reordered within each parent node, preserving the tree structure.
By default, sorting is alphabetical by the cell’s string data. You can supply
a custom comparator for numeric or application-specific ordering.
Alphabetic sorting (default)
use cols::{Table, Column, print_table};
fn main() {
let mut t = Table::new();
t.headings_set(false);
t.add_column(Column::new("NAME").tree(true));
let root = t.new_line(None);
t.line_mut(root).data_set(0, "items");
for val in ["cherry", "apple", "banana"] {
let row = t.new_line(Some(root));
t.line_mut(row).data_set(0, val);
}
t.sort(0);
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
items
├─apple
├─banana
└─cherry
Custom comparators
Set a comparator on the column for non-alphabetical ordering:
use cols::{Table, Column, print_table};
fn main() {
let mut t = Table::new();
t.headings_set(false);
t.add_column(Column::new("VALUE").tree(true).order_function(|a, b| {
let av: i64 = a.data().unwrap_or("0").parse().unwrap_or(0);
let bv: i64 = b.data().unwrap_or("0").parse().unwrap_or(0);
av.cmp(&bv)
}));
let root = t.new_line(None);
t.line_mut(root).data_set(0, "items");
for val in ["10", "2", "100", "20", "3"] {
let row = t.new_line(Some(root));
t.line_mut(row).data_set(0, val);
}
t.sort(0);
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
items
├─2
├─3
├─10
├─20
└─100
You can also set a comparator after the column is in the table:
table.column_mut(0).unwrap().order_function_set(|a, b| {
// ...
a.data().cmp(&b.data())
});
Re-sorting
After adding new lines, call resort() to re-apply the last sort:
table.sort(0);
// ... add more lines ...
table.resort(); // re-sorts using column 0
How it works
For flat tables, sorting reorders root lines. For trees, sorting reorders children within each parent node — the tree structure is preserved. The sort is stable and uses the column’s comparator (defaulting to string comparison).
Groups
Groups express M:N relationships between lines — multiple lines can be “members” of a group, and other lines can be “children” of that group. The print engine draws bracket connectors on the left margin.
Members and children
use cols::{Table, Column, TermForce, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(40);
t.termforce_set(TermForce::Always);
t.headings_set(false);
t.new_column("PROCESS");
t.add_column(Column::new("PID").right(true));
let g = t.new_group();
let m1 = t.new_line(None);
t.line_mut(m1).data_set(0, "server").data_set(1, "100");
let m2 = t.new_line(None);
t.line_mut(m2).data_set(0, "worker").data_set(1, "101");
let c1 = t.new_line(None);
t.line_mut(c1).data_set(0, "logger").data_set(1, "200");
let c2 = t.new_line(None);
t.line_mut(c2).data_set(0, "monitor").data_set(1, "201");
t.group_add_member(g, m1);
t.group_add_member(g, m2);
t.group_add_child(g, c1);
t.group_add_child(g, c2);
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
┌─server 100
├─worker 101
├─logger 200
└─monitor 201
Members only
Groups can also have only members (no children):
use cols::{Table, Column, TermForce, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(40);
t.termforce_set(TermForce::Always);
t.headings_set(false);
t.new_column("NAME");
t.add_column(Column::new("SIZE").right(true));
let g = t.new_group();
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "alpha").data_set(1, "10");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "beta").data_set(1, "20");
let l3 = t.new_line(None);
t.line_mut(l3).data_set(0, "gamma").data_set(1, "30");
t.group_add_member(g, l1);
t.group_add_member(g, l2);
t.group_add_member(g, l3);
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
┌─alpha 10
├─beta 20
└─gamma 30
Symbols
┌─— first member├─— middle member or child└─— last member or last child│— vertical connector between members/children
Lines between members that aren’t part of the group get vertical connectors.
Terminal requirement
Group rendering requires a terminal width to compute the margin. Set TermForce::Always and an explicit termwidth for deterministic output in tests.
Filtering
cols includes a built-in expression language for filtering lines. Parse an expression, then evaluate it against each line’s data.
Basic usage
#![allow(unused)]
fn main() {
use cols::Filter;
let filter = Filter::parse(r#"SIZE > 1G && TYPE == "disk""#).unwrap();
let matches = filter.evaluate(|column_name| match column_name {
"SIZE" => Some("4294967296".into()), // 4G in bytes
"TYPE" => Some("disk".into()),
_ => None,
}).unwrap();
assert!(matches);
}
The evaluate closure maps column names to their string values for the current row. Return None for unknown columns (treated as empty string).
Operators
Comparison
| Operator | Word form | Meaning |
|---|---|---|
== | EQ | Equal |
!= | NE | Not equal |
< | LT | Less than |
<= | LE | Less or equal |
> | GT | Greater than |
>= | GE | Greater or equal |
Regex
Regex operators require the regex feature:
cols = { version = "0.2", features = ["regex"] }
| Operator | Meaning |
|---|---|
=~ | Matches regex |
!~ | Does not match regex |
Filter::parse(r#"NAME =~ "sd[a-z]""#).unwrap();
Boolean
| Operator | Word form | Meaning |
|---|---|---|
&& | AND | Logical and |
|| | OR | Logical or |
! | NOT | Logical not |
Parentheses control precedence: (A || B) && C.
Literals
- Integers:
42,0 - Integer suffixes:
1K(1024),2M,1G,4T— optionally withiBsuffix (1KiB) - Floats:
3.14,0.5 - Strings:
"hello",'world' - Booleans:
true,false
Column references
Bare identifiers are column name references (called “holders”):
SIZE > 1G
Here SIZE is resolved via the closure at evaluation time. A bare holder is truthy if the column has a non-empty value.
Extracting column names
#![allow(unused)]
fn main() {
let filter = Filter::parse(r#"SIZE > 1G && TYPE == "disk""#).unwrap();
let columns = filter.holders(); // ["SIZE", "TYPE"]
}
Useful for knowing which columns a filter depends on.
Filtering at print time
The simplest way to filter is to set a filter expression on the table. Non-matching lines are skipped during printing. Column names in the expression are resolved automatically by matching against column headers.
let mut t = fruit_table();
t.filter_set(r#"COLOR == "red""#).unwrap();
FRUIT PRICE COLOR
apple 1.20 red
cherry 3.00 red
The filter works with all output modes. Here’s the same filter as CSV:
FRUIT,PRICE,COLOR
apple,1.20,red
cherry,3.00,red
Call table.filter_clear() to remove the filter and show all lines again.
Custom resolver
By default, column names in the filter expression are matched against column headers. If you need custom resolution (e.g. computed values or renamed columns), set a resolver:
table.filter_resolver_set(|line, col_name| match col_name {
"BYTES" => line.data_get(1).map(String::from),
_ => None,
});
Manual filtering
You can also filter manually by iterating lines and building a new table. This gives full control over the filtering logic.
/// Helper: build a device table for filter demos.
fn device_table() -> Table {
let mut t = Table::new();
t.new_column("NAME");
t.new_column("TYPE");
t.new_column("SIZE");
let devices = [
("sda", "disk", "500107862016"), // 500G
("sda1", "part", "268435456"), // 256M
("sda2", "part", "499839426560"), // ~465G
("sdb", "disk", "2000398934016"), // 2T
("sdb1", "part", "1073741824"), // 1G
("sdb2", "part", "1999325192192"), // ~1.8T
("nvme0n1", "disk", "512110190592"), // 512G
("sr0", "rom", "0"),
("loop0", "loop", "109051904"), // 104M
];
for (name, typ, size) in devices {
let row = t.new_line(None);
t.line_mut(row)
.data_set(0, name)
.data_set(1, typ)
.data_set(2, size);
}
t
}
Filter by type
fn main() {
let source = device_table();
let filtered = filter_table(&source, r#"TYPE == "disk""#);
print_table(&filtered, &mut std::io::stdout().lock()).unwrap();
}
NAME TYPE SIZE
sda disk 500107862016
sdb disk 2000398934016
nvme0n1 disk 512110190592
Filter by size (with integer suffix)
The expression SIZE > 1G uses the suffix G (1024^3). The filter
automatically parses the cell’s string value as an integer for comparison.
fn main() {
let source = device_table();
let filtered = filter_table(&source, "SIZE > 1G");
print_table(&filtered, &mut std::io::stdout().lock()).unwrap();
}
NAME TYPE SIZE
sda disk 500107862016
sda2 part 499839426560
sdb disk 2000398934016
sdb2 part 1999325192192
nvme0n1 disk 512110190592
Combined filter
fn main() {
let source = device_table();
let filtered = filter_table(&source, r#"TYPE == "disk" && SIZE > 1G"#);
print_table(&filtered, &mut std::io::stdout().lock()).unwrap();
}
NAME TYPE SIZE
sda disk 500107862016
sdb disk 2000398934016
nvme0n1 disk 512110190592
Styling
cols supports terminal colors and text attributes via the optional color
feature, which uses the colored crate.
cols = { version = "0.2", features = ["color"] }
CellStyle
The CellStyle struct describes the visual appearance of a cell: foreground
color, background color, and text attributes (bold, italic, underline, etc.).
use cols::CellStyle;
use cols::Color;
let style = CellStyle::new()
.fg(Color::Green)
.bg(Color::Black)
.bold();
Available builder methods:
| Method | Effect |
|---|---|
.fg(Color) | Set the foreground (text) color |
.bg(Color) | Set the background color |
.bold() | Bold text |
.italic() | Italic text |
.underline() | Underlined text |
.dimmed() | Dimmed/faint text |
.strikethrough() | Strikethrough text |
cols re-exports Color from the colored crate, so you don’t need to
add colored as a separate dependency. The Color enum supports the 8
standard colors (Red, Green, Blue, etc.), their bright variants
(BrightRed, etc.), ANSI 256 codes (AnsiColor(n)), and 24-bit true color
(TrueColor { r, g, b }).
Applying styles
Styles can be set at three levels:
Column level — the default style for all data cells in the column, and a separate style for the header:
use cols::{Column, CellStyle};
use cols::Color;
Column::new("STATUS")
.header_style(CellStyle::new().bold())
.style(CellStyle::new().fg(Color::Green));
Line level — overrides the column style for every cell in that line:
table.line_mut(row).style_set(CellStyle::new().fg(Color::Red).bold());
Cell level — overrides both column and line styles for a single cell:
table.line_mut(row)
.cell_mut(0).unwrap()
.style_set(CellStyle::new().fg(Color::Yellow));
Cascading
Styles cascade from column to line to cell. At each level, only the fields that are explicitly set override the parent. Unset fields are inherited.
For example, if a column sets bold + green:
- A line that sets
fg: redproduces bold red (bold inherited from column) - A cell on that line that sets
underlineproduces bold red underline (bold from column, red from line, underline from cell)
This means you can set a base style on the column and only override what changes at the line or cell level.
use cols::{Table, Column, CellStyle};
use cols::Color;
let mut table = Table::new();
table.colors_set(true);
// All cells in this column are bold green by default.
table.add_column(
Column::new("STATUS")
.header_style(CellStyle::new().bold().underline())
.style(CellStyle::new().fg(Color::Green).bold()),
);
let ok = table.new_line(None);
table.line_mut(ok).data_set(0, "OK");
// This line renders as bold green (inherited from column).
let err = table.new_line(None);
table.line_mut(err).data_set(0, "ERROR");
// Override just the color for this line — bold is inherited.
table.line_mut(err).style_set(CellStyle::new().fg(Color::Red));
// This line renders as bold red.
Enabling color output
Styles are only applied when color output is enabled on the table:
table.colors_set(true);
When disabled (the default), styles are stored but ignored during printing. This lets callers decide at runtime whether to use colors — for example, based on whether stdout is a terminal.
Streaming
The standard print_table API requires building the entire table in memory
before printing. For large or unbounded datasets, the streaming writer lets
you output rows immediately as data arrives.
Basic usage
Configure columns on a Table as usual, then create a streaming writer.
The writer prints the header (if enabled) and any opening syntax
automatically. Build rows with begin_row, populate them with set, then call finish
to flush each row. Call close when done.
use cols::{Table, Column};
fn main() {
let mut table = Table::new();
table.add_column(Column::new("NAME").width_fixed(10));
table.add_column(Column::new("SIZE").width_fixed(6).right(true));
let stdout = std::io::stdout();
let mut out = stdout.lock();
let mut w = table.streaming_writer(&mut out).unwrap();
w.begin_row(None).set("NAME", "sda").set("SIZE", "100G").finish().unwrap();
w.begin_row(None).set("NAME", "sdb").set("SIZE", "50G").last(true).finish().unwrap();
w.close().unwrap();
}
Setting cell data
Use set() to populate cells. It accepts both column names and indices
via the ColumnKey trait:
row.set("NAME", "sda"); // by column name
row.set(0, "sda"); // by column index
Both can be mixed freely on the same row.
Output modes
The streaming writer supports all output modes. Set the mode on the table before creating the writer:
table.output_mode_set(OutputMode::Json);
table.name_set("devices");
let mut w = table.streaming_writer(&mut out).unwrap();
For Normal and Markdown modes, columns should have fixed width hints
(width_fixed) since the writer can’t scan all data upfront to calculate
widths.
Tree streaming
Pass a parent RowId to begin_row to build tree hierarchies
incrementally. Call .last(true) on the row builder to mark a row as the
last child of its parent – this controls whether a └─ (last) or ├─
(more siblings) connector is drawn.
use cols::{Table, Column};
fn main() {
let mut table = Table::new();
table.headings_set(false);
table.add_column(Column::new("NAME").width_fixed(20).tree(true));
table.add_column(Column::new("SIZE").width_fixed(8).right(true));
let stdout = std::io::stdout();
let mut out = stdout.lock();
let mut w = table.streaming_writer(&mut out).unwrap();
let root = w.begin_row(None)
.set("NAME", "sda").set("SIZE", "500G")
.last(true)
.finish().unwrap();
w.begin_row(Some(root))
.set("NAME", "sda1").set("SIZE", "256M")
.finish().unwrap();
w.begin_row(Some(root))
.set("NAME", "sda2").set("SIZE", "250G")
.last(true)
.finish().unwrap();
w.close().unwrap();
}
Output:
sda 500G
├─sda1 256M
└─sda2 250G
This is particularly useful for recursive directory walkers, process trees, or any case where the data is discovered incrementally.
Limitations
- Sorting is not supported in streaming mode (it requires all data).
- Group rendering is not supported.
- Auto-sized columns (no width hint) work for Raw, CSV, Export, ShellVar, and JSON modes, but Normal and Markdown modes need fixed widths since the writer can’t pre-scan the data.
Derive Macro
The derive feature provides a #[derive(Cols)] macro that generates table
column definitions, row conversion, and a typed header enum from a struct.
This replaces the manual column setup for the common case of displaying a
collection of structs.
Setup
Enable the derive feature in your Cargo.toml:
[dependencies]
cols = { version = "0.2", features = ["derive"] }
Basic usage
Annotate a struct with #[derive(Cols)]. Each field becomes a column, with
the header defaulting to the field name in uppercase.
use cols::{Cols, print_table};
#[derive(Cols)]
struct Server {
name: String,
ip: String,
port: u16,
status: String,
}
let servers = vec![
Server { name: "web-1".into(), ip: "10.0.0.1".into(), port: 443, status: "up".into() },
Server { name: "web-2".into(), ip: "10.0.0.2".into(), port: 443, status: "down".into() },
];
let table = Server::to_table(&servers);
print_table(&table, &mut std::io::stdout().lock()).unwrap();
Output:
NAME IP PORT STATUS
web-1 10.0.0.1 443 up
web-2 10.0.0.2 443 down
Field attributes
Use #[column(...)] on fields to control column behavior. All attributes are
optional.
Layout and alignment
| Attribute | Effect |
|---|---|
right | Right-align the column |
center | Center-align the column |
width_fixed = N | Set a fixed character width |
width_fraction = F | Set width as a fraction of terminal width |
truncate | Truncate data exceeding the column width |
wrap | Wrap long data across multiple output lines |
strict_width | Never shrink below the width hint |
no_extremes | Ignore outlier values in width calculation |
Display control
| Attribute | Effect |
|---|---|
header = "NAME" | Override the column header (default: field name uppercased) |
hidden | Store in memory but don’t display |
skip | Don’t create a column at all (field is ignored entirely) |
Tree rendering
| Attribute | Effect |
|---|---|
tree | Mark as the tree hierarchy column |
children | Use this Vec<Self> field as the tree children source |
JSON control
| Attribute | Effect |
|---|---|
json_type = "Number" | Set the JSON serialization type (String, Number, Boolean) |
json_key = "key" | Override the JSON object key |
Type conversion
The to_row() method converts each field value to a string automatically:
String/&str– used as-isu8..u128,i8..i128,f32,f64–.to_string()bool–"true"/"false"Option<T>– inner value’s string, or""ifNone
Header enum
The derive macro generates a header enum named {Struct}Header with one
variant per visible column (excluding skip fields). This enum lets you
select columns programmatically.
#[derive(Cols)]
struct Device {
name: String,
size: u64,
#[column(skip)]
internal: String,
}
// Generated: DeviceHeader with variants Name and Size (not Internal)
// Show only selected columns
let table = Device::to_table_with(
&devices,
&[DeviceHeader::Name, DeviceHeader::Size],
);
// Look up a column index
let idx = DeviceHeader::Size.index(); // 1
let name = DeviceHeader::Size.name(); // "SIZE"
The header enum implements Debug, Clone, Copy, PartialEq, and Eq.
It also implements the ColsHeader trait, which provides all(), index(),
and name().
Tree rendering
Mark one field with #[column(tree)] and another with #[column(children)] to
get automatic tree rendering. The children field must be a Vec<Self>.
#[derive(Cols)]
struct FsEntry {
#[column(tree)]
name: String,
size: u64,
#[column(children)]
entries: Vec<FsEntry>,
}
let root = FsEntry {
name: "/".into(),
size: 4096,
entries: vec![
FsEntry { name: "bin".into(), size: 2048, entries: vec![] },
FsEntry { name: "etc".into(), size: 1024, entries: vec![] },
],
};
let table = FsEntry::to_table(&[root]);
Output:
NAME SIZE
/ 4096
├─bin 2048
└─etc 1024
Both to_table and stream_to recurse into children automatically.
Convenience methods
The Cols trait provides several methods beyond columns() and to_row():
to_table(items)– build a complete table from a slice, recursing into childrento_table_with(items, headers)– build a table showing only the selected columnsprint_table(items, writer)– build and print in one steptable()– create an empty table with columns configured (useful for streaming)stream_to(writer, parent, last)– stream this item and its children to a streaming writer (lastcontrols└─vs├─tree connectors)
Streaming
Combine the derive macro with the streaming writer for incremental output:
use cols::{Cols, TermForce};
let mut table = Device::table();
table.termforce_set(TermForce::Always);
table.termwidth_set(80);
let mut out = std::io::stdout().lock();
let mut writer = table.streaming_writer(&mut out).unwrap();
let count = devices.len();
for (i, device) in devices.iter().enumerate() {
device.stream_to(&mut writer, None, i == count - 1).unwrap();
}
writer.close().unwrap();
Output modes
Tables built with the derive macro support all output modes. Set the mode on the table after building it:
let mut table = Device::to_table(&devices);
table.output_mode_set(OutputMode::Json);
table.name_set("devices");
print_table(&table, &mut std::io::stdout().lock()).unwrap();
Cookbook
Complete examples showing cols in real-world scenarios. See also the examples/ directory in the repository.
lsblk-like output
A realistic example with trees, fractional widths, and multiple output modes:
use cols::{Table, Column, TermForce, print_table};
fn main() {
let mut t = Table::new();
t.name_set("blockdevices");
t.termwidth_set(80);
t.termforce_set(TermForce::Always);
t.add_column(
Column::new("NAME")
.width_fraction(0.25)
.tree(true)
.no_extremes(true),
);
t.add_column(Column::new("MAJ:MIN").width_fixed(6));
t.add_column(Column::new("RM").width_fixed(1).right(true));
t.add_column(Column::new("SIZE").width_fixed(5).right(true));
t.add_column(Column::new("RO").width_fixed(1).right(true));
t.add_column(Column::new("TYPE").width_fixed(4));
t.add_column(Column::new("MOUNTPOINTS").width_fraction(0.1).wrap(true));
let sda = t.new_line(None);
t.line_mut(sda)
.data_set(0, "sda")
.data_set(1, "8:0")
.data_set(2, "1")
.data_set(3, "0B")
.data_set(4, "0")
.data_set(5, "disk");
let nvme = t.new_line(None);
t.line_mut(nvme)
.data_set(0, "nvme0n1")
.data_set(1, "259:0")
.data_set(2, "0")
.data_set(3, "931.5G")
.data_set(4, "0")
.data_set(5, "disk");
let p1 = t.new_line(Some(nvme));
t.line_mut(p1)
.data_set(0, "nvme0n1p1")
.data_set(1, "259:1")
.data_set(2, "0")
.data_set(3, "600M")
.data_set(4, "0")
.data_set(5, "part")
.data_set(6, "/boot/efi");
let p2 = t.new_line(Some(nvme));
t.line_mut(p2)
.data_set(0, "nvme0n1p2")
.data_set(1, "259:2")
.data_set(2, "0")
.data_set(3, "2G")
.data_set(4, "0")
.data_set(5, "part")
.data_set(6, "/boot");
let p3 = t.new_line(Some(nvme));
t.line_mut(p3)
.data_set(0, "nvme0n1p3")
.data_set(1, "259:3")
.data_set(2, "0")
.data_set(3, "928.9G")
.data_set(4, "0")
.data_set(5, "part");
let luks = t.new_line(Some(p3));
t.line_mut(luks)
.data_set(0, "luks-62d09206-d2b0-48d8-9164-a8487c42e31b")
.data_set(1, "252:0")
.data_set(2, "0")
.data_set(3, "928.9G")
.data_set(4, "0")
.data_set(5, "crypt")
.data_set(6, "/home");
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
Normal output:
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
sda 8:0 1 0B 0 disk
nvme0n1 259:0 0 931.5G 0 disk
├─nvme0n1p1 259:1 0 600M 0 part /boot/efi
├─nvme0n1p2 259:2 0 2G 0 part /boot
└─nvme0n1p3 259:3 0 928.9G 0 part
└─luks-62d09206-d2b0-48d8-9164-a8487c42e31b 252:0 0 928.9G 0 crypt /home
Group members only
use cols::{Table, Column, TermForce, print_table};
fn main() {
let mut t = Table::new();
t.termwidth_set(40);
t.termforce_set(TermForce::Always);
t.headings_set(false);
t.new_column("NAME");
t.add_column(Column::new("SIZE").right(true));
let g = t.new_group();
let l1 = t.new_line(None);
t.line_mut(l1).data_set(0, "alpha").data_set(1, "10");
let l2 = t.new_line(None);
t.line_mut(l2).data_set(0, "beta").data_set(1, "20");
let l3 = t.new_line(None);
t.line_mut(l3).data_set(0, "gamma").data_set(1, "30");
t.group_add_member(g, l1);
t.group_add_member(g, l2);
t.group_add_member(g, l3);
print_table(&t, &mut std::io::stdout().lock()).unwrap();
}
Output:
┌─alpha 10
├─beta 20
└─gamma 30
Filtering lines before display
use cols::{Table, Filter, print_table_to_string};
fn main() {
let mut table = Table::new();
table.new_column("NAME");
table.new_column("SIZE");
let data = vec![("sda", "1073741824"), ("sdb", "536870912"), ("sr0", "0")];
for (name, size) in &data {
let row = table.new_line(None);
table.line_mut(row).data_set(0, name).data_set(1, size);
}
let filter = Filter::parse("SIZE > 512M").unwrap();
let mut filtered = Table::new();
filtered.new_column("NAME");
filtered.new_column("SIZE");
for id in table.line_ids() {
let line = table.line(id);
let matches = filter.evaluate(|col| match col {
"NAME" => line.data_get(0).map(String::from),
"SIZE" => line.data_get(1).map(String::from),
_ => None,
}).unwrap();
if matches {
let row = filtered.new_line(None);
filtered.line_mut(row)
.data_set(0, line.data_get(0).unwrap_or(""))
.data_set(1, line.data_get(1).unwrap_or(""));
}
}
let output = print_table_to_string(&filtered).unwrap();
assert!(output.contains("sda"));
assert!(!output.contains("sr0"));
}
Changelog
All notable changes to this project will be documented in this file.
0.3.2
Added
- Auto-detect terminal width from stdout via ioctl when no explicit width
is set. Falls back to
COLUMNSenv var, then 80. NO_COLORenvironment variable support: colors are suppressed whenNO_COLORis set (per https://no-color.org). Default behavior is now auto-detect; usecolors_force(true)to override.Table::colors_force— enable or disable colors ignoringNO_COLOR.- Crate-level and derive macro documentation for
cols-derive.
Changed
- Layout engine now prefers shrinking truncatable/wrappable columns when terminal width is constrained. Non-flexible columns get their full content width first, so columns like PID no longer get squeezed when a wide truncatable column (e.g. COMMAND) is present.
Table::colors()default changed fromfalseto auto-detect (enabled unlessNO_COLORis set). Existingcolors_set(true)calls still work but now also respectNO_COLOR.colsandcols-deriveversions are now synced viaworkspace.package.
0.3.1
Added
Column::word_wrap— wrap at word boundaries instead of mid-character; falls back to character wrap for words wider than the column. Supported via builder, setter, and#[column(word_wrap)]in the derive macro.Table::column,column_mut,remove_column, andsortnow acceptimpl ColumnKey— pass a column name (&str) or index (usize)- docs.rs metadata: builds with all features, feature-gated items labeled
automatically via
doc_auto_cfg - 6 new tests for
NO_EXTREMEScolumn layout behavior (4 api, 2 snapshot)
Changed
- Rewrote
filter,json, andwrapexamples to use#[derive(Cols)]
Fixed
- Tree connectors now render on any column with the
treeflag, not only when the tree column is the first visible column - Release builds failed due to
sealed_parentsfield andHashSetimport not being gated behind#[cfg(debug_assertions)] - Stale method names in mdbook documentation (
set_foo()→foo_set())
0.3.0
Added
#[derive(Cols)]proc macro (behind thederivefeature flag) for building tables from structs. Generates column definitions, row conversion, tree children, a typed header enum, and convenience methods (to_table,to_table_with,print_table,stream_to). Field attributes use#[column(...)].ColsandColsHeadertraits inderive_supportmodulecols-deriveproc macro crate- Streaming writer (
Table::streaming_writer) for outputting rows incrementally without buffering the entire table. Supports all output modes and tree rendering. RowBuilder::last()for marking the last child at each tree level, producing correct└─/├─connectors. Debug-mode assertion catches children added after a sibling was marked as last.ColumnKeytrait for indexing columns byusizeor&strnameRowBuilderfor building streaming rows with.set(key, value)- Color and styling support (behind the
colorfeature flag): foreground/ background colors, bold/italic/underline on cells, columns, and lines with cascading precedence header_repeat— repeat the header row everytermheight - 1lines in long output (works in both flat and tree modes)Table::filter_set— set a filter expression on the table; non-matching lines are skipped during printing across all output modesTable::filter_resolver_set— optional custom resolver for filter evaluationColumn::wrap_function— custom wrap function for splitting cell data into logical lines (e.g. splitting mountpoints on\n)Column::json_key/json_key_set— override the JSON object key for a column (default: lowercased column name)column_count(),line_count(),cell_count()replacingncols(),nlines(),ncells()deriveexample- 285 tests (unit + integration + snapshot + doc)
Changed
regexdependency is now optional behind theregexfeature flag (off by default). Filter=~/!~operators require this feature; all other filter operators work without it.- JSON output now lowercases column names for object keys (matches libsmartcols)
- Raw output mode encodes spaces as
\x20and all control chars (matches libsmartcols) - Export output mode encodes control chars in values (matches libsmartcols)
Fixed
- Sort not reordering root lines
- Wrap columns encoding newlines before splitting
0.2.0
Added
- 7 examples:
ls,tree,json,filter,groups,sort,wrap - mdbook documentation with 11 chapters, all code examples sourced from snapshot tests via
{{#include}} - Comparison table with
tabled,comfy-table,cli-table,prettytable-rsin introduction - CSV output mode (
OutputMode::Csv) with RFC 4180 quoting - Markdown table output mode (
OutputMode::Markdown) with pipe/backslash escaping and<br>for newlines - Rustdoc comments on all public methods
- 160 tests (92% line coverage): 22 unit, 80 integration, 47 snapshot, 11 doc
Changed
- Method naming standardized to
noun_verbform:- Setters:
set_foo()→foo_set() - Getters:
get_foo()→foo_get() - Boolean flags on Table:
enable_no_headings(true)→headings_set(false)(polarity flipped) - Same for
encoding,wrap,line_separator_enabled
- Setters:
- Abbreviations removed:
trunc→truncate,cmp_func→order_function,cmp_cells→compare_cells
0.1.0
Initial release, extracted from rustutils/util-linux.
Added
WidthHintenum (Auto,Fixed,Fraction) replacing magicf64width hints- Column builder API:
Column::new("NAME").width_fixed(10).tree(true).right(true) - Convenience methods:
width_fixed(),width_fraction()onColumn &mut selfsetters for all column flags and metadata (for post-construction mutation)LineId::from_raw()/LineId::as_raw()andGroupId::from_raw()/GroupId::as_raw()for FFI and serializationColumnFlagsbitfield flattened into directboolfields onColumn
Removed
bitflagsdependency- FFI remnants:
set_flags_from_bits,with_name_and_flags,new_column_with_flags Column::with_name(name, whint)constructor (useColumn::new(name).width_fixed(n))Column::set_flags()/Column::flags()accessors