I keep small behavior specs for GNU coreutils commands in the behave repo. Each spec is a folder of YAML files: the arguments, stdin, input files, and the exact stdout, stderr and exit code GNU produced. Then I replay the same folder against another implementation and read the diff.

This week I added one for expand (tabs to spaces). 21 cases: default and custom tab stops, the obsolete -4 form, tab lists, the /N and +N extensions, -i, backspace, several files, and the usual error paths. Recording took a minute:

behave add examples/expand --name 'files: dash means stdin' \ --stdin $'\ts\n' --file f=$'a\tb\n' -- f - f 

The diff against uutils

Replaying it against uutils coreutils built from main:

✗ files: dash means stdin 0017-files-dash-means-stdin.yaml $ expand f - f stdout: a b - s a b 

expand f - f should print the file, then stdin, then the file again. The rewrite printed the file twice and never read stdin. No error, exit code 0.

The cause is a small preprocessing step that turns obsolete options like -4 or -2,6 into --tabs=.... It checks "starts with - and the rest is digits or commas". For a bare - the rest is empty, and "all characters are digits" is true for an empty string, so the hyphen was swallowed. expand - alone still worked because the file list defaults to -, which is exactly why this kind of bug survives: the common case is fine.

Someone had already found it (uutils/coreutils#15716 is open with a fix), so I linked it from the example instead of filing a duplicate. The other two differences were error wording only.

The bug in my own tool

Writing the spec also broke behave itself. An input file starting with a tab ($'\tc\n') was saved as a YAML literal block whose first line began with a tab, which YAML does not allow. The case saved fine, then every later command on the spec failed to load it. Input file contents now get the same quoting rules as stdin and stdout, with a round-trip test for tabs, leading spaces, CR and empty files.

A version trap

My first version had 22 cases. One checked that é before a tab counts as one column. It passed locally on GNU 9.12 and failed in CI, where Ubuntu 24.04 still ships GNU 9.4, which counts the two bytes. Same tool, two answers depending on the release. I dropped the case and wrote down why, because a spec that fails for reasons unrelated to the tool under test teaches people to ignore it.

The spec is in examples/expand if you want to run it against your own build:

behave run examples/expand # against expand on your PATH behave diff examples/expand --impl ./target/release/expand