xa-cli 1.0.0

A modern, safe replacement for xargs
# xa

A modern, safe replacement for `xargs`.

`xa` reads items from stdin and runs a command for each one. The key difference from `xargs`: it uses **null-delimited input by default**, which means filenames with spaces, newlines, or special characters work correctly without extra flags.

## Installation

```sh
cargo install xa-cli
```

The binary installs as `xa`. Or download a pre-built binary from the [releases page](https://github.com/welcomevideogame/xa/releases).

Shell completions (bash, zsh, fish) and a man page are included in each release.

## Quick start

```sh
# Find files and process them (pairs naturally with fd's -0 flag)
fd -0 '\.png$' | xa -- cwebp {} -o {.}.webp

# Parallel processing with 4 workers
fd -0 '\.log$' | xa -j4 -- gzip {}

# Preview what would run without executing
fd -0 '\.tmp$' | xa --dry-run -- rm {}
```

## Why xa instead of xargs?

| | `xargs` | `xa` |
|---|---|---|
| Default delimiter | whitespace | null (`\0`) |
| Safe with spaces in filenames | only with `-0` | always |
| Placeholders | `{}` only | `{}` `{/}` `{.}` `{/.}` `{ext}` `{#}` `{slot}` |
| Parallel workers | `-P n` | `-j n` |
| Ordered output in parallel | no | `-k` |
| Retry on failure | no | `--retry n` |
| Rate limiting | no | `--rate n` |
| JSON structured logging | no | `--json-log` |
| Dry run | no | `--dry-run` |
| Progress bar | no | `-p` |

The null-delimiter default is the most important difference. With `xargs`, a file named `my photo.jpg` silently becomes two arguments — `my` and `photo.jpg`. With `xa`, it works correctly out of the box as long as your input source also uses null delimiters (which `find -print0`, `fd -0`, and `git ls-files -z` all support).

## Placeholders

When a placeholder appears in the command, `xa` substitutes it for each input item. If no placeholder is present, the item is appended as a trailing argument.

| Placeholder | Expands to | Example input | Result |
|---|---|---|---|
| `{}` | the item as-is | `/path/to/photo.png` | `/path/to/photo.png` |
| `{/}` | basename | `/path/to/photo.png` | `photo.png` |
| `{.}` | path without extension | `/path/to/photo.png` | `/path/to/photo` |
| `{/.}` | basename without extension | `/path/to/photo.png` | `photo` |
| `{ext}` | extension only | `/path/to/photo.png` | `png` |
| `{#}` | 1-based item index || `1`, `2`, `3`, … |
| `{slot}` | 0-based worker slot || `0`, `1`, … (with `-j`) |

```sh
# Convert images, preserving directory structure
fd -0 '\.png$' | xa -- cwebp {} -o {.}.webp

# Show only filenames
fd -0 | xa -- echo {/}

# Rename by extension
fd -0 '\.jpeg$' | xa -- mv {} {.}.jpg
```

## Parallelism

```sh
# Run 8 workers
fd -0 '\.mp4$' | xa -j8 -- ffmpeg -i {} {.}.mkv

# Keep output in input order even when running in parallel
fd -0 | xa -j4 -k -- process {}

# Stop immediately if any command fails
fd -0 | xa -j4 --halt-on-error -- risky-command {}
```

## Batching

`-n <N>` passes N items per command invocation instead of one at a time.

```sh
# Pass 10 files to each invocation of a command
fd -0 '\.txt$' | xa -n10 -- wc -l

# All items in a single invocation (-n0)
fd -0 | xa -n0 -- tar czf archive.tar.gz
```

With a placeholder, each item's args are repeated within the single invocation:

```sh
# xa -n2 -- echo {} expands to: echo item1 item2, then echo item3 item4, ...
printf 'a\0b\0c\0d\0' | xa -n2 -- echo {}
# a b
# c d
```

## Input modes

```sh
# Default: null-delimited (safest)
printf 'foo\0bar\0' | xa -- echo {}

# Newline-delimited
printf 'foo\nbar\n' | xa -l -- echo {}

# Custom delimiter
printf 'foo:bar:baz' | xa -d: -- echo {}

# Whitespace-split with quoting (legacy xargs behaviour)
printf "'hello world' bye" | xa -s -- echo {}
# hello world
# bye
```

## Resilience

```sh
# Retry failed commands up to 3 times with exponential backoff
xa --retry 3 -- flaky-api-call {}

# Stop after the first failure
xa --halt-on-error -- critical-step {}

# Limit to 10 commands per second
xa --rate 10 -- api-call {}

# Confirm before each command
xa --confirm -- rm {}
```

## Output options

```sh
# Print each command before running it
xa --verbose -- make -C {}

# Prefix each output line with the source item
xa --tag -- grep pattern {}

# Emit structured JSON to stderr for each completed command
xa --json-log -- process {} 2>log.ndjson

# Show a progress bar
xa -p -j4 -- encode {}
```

## Shell mode

`--shell` (`-S`) wraps the command in `sh -c`, enabling pipes and other shell features:

```sh
fd -0 '\.log$' | xa --shell -- 'grep ERROR {} | wc -l'
```

## Comparison with similar tools

- **xargs** — the classic. Whitespace-splitting default makes it unsafe with most filenames. xa is a drop-in improvement for the common `find | xargs` pattern.
- **GNU parallel** — extremely feature-rich but a large Perl dependency. xa covers the most common 90% of use cases in a single ~2 MB static binary.
- **fd's built-in exec** (`fd --exec`) — convenient for single commands but no parallelism control, no retry, no rate limiting, no JSON logging.

## Benchmarks

Benchmarks run on a Linux x86-64 machine with `cargo bench` (criterion). These measure wall-clock time for process spawning + execution, so they reflect real-world overhead.

| Workload | Throughput |
|---|---|
| Sequential `echo` — 100 items, 1 worker | ~3,350 items/s |
| Parallel `echo` — 100 items, 4 workers | ~12,380 items/s |
| Batch `echo` — 100 items, `-n10` (10 invocations) | ~28,800 items/s |
| Dry-run — 1,000 items, placeholder expansion only | ~550,000 items/s |

Parallelism (`-j4`) gives roughly a **3.7× throughput improvement** for CPU-bound or I/O-bound workloads. Batch mode (`-n10`) reduces process-spawn overhead by ~8.6× for commands that accept multiple arguments. The dry-run numbers show that placeholder expansion itself adds negligible overhead (~1.8 µs per 1,000 items).

Run benchmarks yourself:

```sh
cargo bench
# HTML report: target/criterion/report/index.html
```

## Exit codes

| Code | Meaning |
|---|---|
| `0` | all commands succeeded |
| `1` | one or more commands failed |
| `2` | xa itself failed (bad arguments, I/O error) |
| `130` | interrupted by Ctrl-C |