Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 colored crate, 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 lsblk or pstree)
  • 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:

Featurecolstabledcomfy-tablecli-tableprettytable-rs
Adaptive column widthsYesYesYesLimitedNo
Tree renderingYesNoNoNoNo
JSON outputYesNoNoNoNo
CSV / Markdown outputYesNoNoNoNo
Raw/Export/ShellVar outputYesNoNoNoNo
Streaming modeYesNoNoNoNo
Group bracketsYesNoNoNoNo
Filter expressionsYesNoNoNoNo
Color stylingYes (optional)YesYesYesYes
Derive macroYesYesNoYesNo
Unicode box-drawingYesYesYesYesYes
WrappingYesYesYesNoNo
MaintainedYesYesYesYesNo

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:

MethodMeaning
(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

OperatorWord formMeaning
==EQEqual
!=NENot equal
<LTLess than
<=LELess or equal
>GTGreater than
>=GEGreater or equal

Regex

Regex operators require the regex feature:

cols = { version = "0.2", features = ["regex"] }
OperatorMeaning
=~Matches regex
!~Does not match regex
Filter::parse(r#"NAME =~ "sd[a-z]""#).unwrap();

Boolean

OperatorWord formMeaning
&&ANDLogical and
||ORLogical or
!NOTLogical not

Parentheses control precedence: (A || B) && C.

Literals

  • Integers: 42, 0
  • Integer suffixes: 1K (1024), 2M, 1G, 4T — optionally with iB suffix (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:

MethodEffect
.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: red produces bold red (bold inherited from column)
  • A cell on that line that sets underline produces 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

AttributeEffect
rightRight-align the column
centerCenter-align the column
width_fixed = NSet a fixed character width
width_fraction = FSet width as a fraction of terminal width
truncateTruncate data exceeding the column width
wrapWrap long data across multiple output lines
strict_widthNever shrink below the width hint
no_extremesIgnore outlier values in width calculation

Display control

AttributeEffect
header = "NAME"Override the column header (default: field name uppercased)
hiddenStore in memory but don’t display
skipDon’t create a column at all (field is ignored entirely)

Tree rendering

AttributeEffect
treeMark as the tree hierarchy column
childrenUse this Vec<Self> field as the tree children source

JSON control

AttributeEffect
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-is
  • u8..u128, i8..i128, f32, f64.to_string()
  • bool"true" / "false"
  • Option<T> – inner value’s string, or "" if None

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 children
  • to_table_with(items, headers) – build a table showing only the selected columns
  • print_table(items, writer) – build and print in one step
  • table() – 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 (last controls └─ 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 COLUMNS env var, then 80.
  • NO_COLOR environment variable support: colors are suppressed when NO_COLOR is set (per https://no-color.org). Default behavior is now auto-detect; use colors_force(true) to override.
  • Table::colors_force — enable or disable colors ignoring NO_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 from false to auto-detect (enabled unless NO_COLOR is set). Existing colors_set(true) calls still work but now also respect NO_COLOR.
  • cols and cols-derive versions are now synced via workspace.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, and sort now accept impl 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_EXTREMES column layout behavior (4 api, 2 snapshot)

Changed

  • Rewrote filter, json, and wrap examples to use #[derive(Cols)]

Fixed

  • Tree connectors now render on any column with the tree flag, not only when the tree column is the first visible column
  • Release builds failed due to sealed_parents field and HashSet import 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 the derive feature 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(...)].
  • Cols and ColsHeader traits in derive_support module
  • cols-derive proc 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.
  • ColumnKey trait for indexing columns by usize or &str name
  • RowBuilder for building streaming rows with .set(key, value)
  • Color and styling support (behind the color feature flag): foreground/ background colors, bold/italic/underline on cells, columns, and lines with cascading precedence
  • header_repeat — repeat the header row every termheight - 1 lines 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 modes
  • Table::filter_resolver_set — optional custom resolver for filter evaluation
  • Column::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() replacing ncols(), nlines(), ncells()
  • derive example
  • 285 tests (unit + integration + snapshot + doc)

Changed

  • regex dependency is now optional behind the regex feature 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 \x20 and 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-rs in 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_verb form:
    • 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
  • Abbreviations removed: trunctruncate, cmp_funcorder_function, cmp_cellscompare_cells

0.1.0

Initial release, extracted from rustutils/util-linux.

Added

  • WidthHint enum (Auto, Fixed, Fraction) replacing magic f64 width hints
  • Column builder API: Column::new("NAME").width_fixed(10).tree(true).right(true)
  • Convenience methods: width_fixed(), width_fraction() on Column
  • &mut self setters for all column flags and metadata (for post-construction mutation)
  • LineId::from_raw() / LineId::as_raw() and GroupId::from_raw() / GroupId::as_raw() for FFI and serialization
  • ColumnFlags bitfield flattened into direct bool fields on Column

Removed

  • bitflags dependency
  • FFI remnants: set_flags_from_bits, with_name_and_flags, new_column_with_flags
  • Column::with_name(name, whint) constructor (use Column::new(name).width_fixed(n))
  • Column::set_flags() / Column::flags() accessors