# ctl-term
Terminal output for CLIs: colour policy per stream, colour depth, width, and the escape encoder.
The repository's root [`AGENTS.md`](../AGENTS.md) holds the workspace-wide rules.
This file holds what applies to this crate alone.
## What it owns
- `Terminal`: what is known about one stream, decided once from a probe, a colour mode, and an environment lookup.
- `ColorMode`, `ColorDepth`, and the nearest-colour conversion between depths.
- `DetectOptions`: the width rules and every default they apply, and the `DEFAULT_*` constants other crates use instead of copying the numbers.
- `Encoder`: the only place an escape sequence is written.
It needs no terminal, so ctl-core's string rendering calls it with a depth it chose.
- `Console`: renders a `ctl_text::Renderable` at its stream's width and writes it.
It reports a closed pipe as `Error::BrokenPipe`.
`From<Error> for io::Error` keeps the `BrokenPipe` error kind without boxing, so a caller that returns `io::Result` carries no `Debug` code for `Error`.
## What it must not own
- Layout, tables, records, or widgets.
Those belong to `ctl-layout`; text measurement and wrapping belong to `ctl-text`.
- The exit code for a closed pipe.
This crate reports the error, and the caller chooses what to do.
- A dependency on a terminal crate such as `anstream` or `crossterm`.
The crate depends on `anstyle` and `ctl-text`, and on `terminal_size` behind the `detect` feature.
## Platforms and features
- The crate supports and tests Unix only.
It has no Windows path, no virtual-terminal mode, and no `anstyle-query`.
Windows is to come with a Windows CI job; until then do not add `cfg(windows)` code.
[Design decision 19][unix] records the reason.
- `detect` is a default feature.
It holds everything that asks the operating system: `terminal_size`, `Probe::of`, `Terminal::detect`, the `from_process` constructors, and `Console::print`.
The crate must compile with `--no-default-features`, because ctl-core's `render` tier takes only the `Encoder` from it.
Gate a new item that touches the process or the operating system on `detect`.
## Public API
- A public item is a promise.
Every public item has a doc comment that adds something beyond its name.
The doc gives what the item is for, a unit, an invariant, an edge case, or what it returns.
- The enums are `#[non_exhaustive]`, so a new variant is not a break.
- A change that breaks the public API needs a minor changeset in the repository's `.changeset/` folder.
CI runs `mise run semver` against the last release.
- Environment reads go through the `env` lookup that the caller passes.
`Terminal::from_probe` touches no process state, so keep it that way.
## Tests
- `tests/it/detection.rs`: tables over every variable that decides colour, depth, and width, each alone and in conflicting pairs.
- `tests/it/scenarios.rs`: the `terminal-output` spec scenarios this crate owns, each through a `Console`.
- `tests/it/encoding.rs`, `palette.rs`, and `properties.rs`: the encoder, the colour conversion, and proptest properties of escape output.
- `tests/closed_pipe.rs`: a real child process writing to a pipe that the test reads one line from and then closes.
The test reads the line itself instead of starting `head`, because how much a system `head` reads before it quits differs between implementations.
It needs the `detect` feature, and on a target that is not Unix it prints a skip line.
It runs without libtest, because libtest prints its own banner to stdout, and the test closes the pipe after the first line.
The next libtest write would then fail before the code under test ran.
Keep that target at `harness = false` in `Cargo.toml`.
- `tests/non_utf8_no_color.rs`: a child process whose `NO_COLOR` is not valid UTF-8.
A test cannot set that in its own environment without `unsafe`.
- The child asserts that `Terminal::from_process` reports no colour.
A control child without `NO_COLOR` asserts that colour is on.
- It needs the `detect` feature, and prints a skip line on a target that is not Unix.
- It keeps `harness = false`, so the child is a plain `main` that reports through its exit code.
- The `it` tests pass an explicit environment lookup and probe (`tests/it/common.rs`), so they read no process variable.
## Running them
`mise run verify` runs them with the rest of the workspace.
To run this crate's tests alone:
```sh
cargo test --locked -p ctl-term --all-features
```
`mise run lint:prose ctl-term` checks the prose of the crate's docs and README without compiling.
## Design decisions
These bind the crate.
They live in [`design.md`](../openspec/changes/archive/2026-11-10-terminal-render-engine/design.md).
- [Escape sequences are written once, by the sink][escape]: layout produces lines, and the encoder adds every escape.
- [Terminal facts are a pure function of an environment snapshot][facts]: the colour table and the width order.
- [Colour depth is detected and every colour is downgraded to it][depth].
- [A closed pipe ends the program quietly][pipe]: the crate reports `BrokenPipe`.
[escape]: ../openspec/changes/archive/2026-11-10-terminal-render-engine/design.md#5-escape-sequences-are-written-once-by-the-sink
[facts]: ../openspec/changes/archive/2026-11-10-terminal-render-engine/design.md#10-terminal-facts-are-a-pure-function-of-an-environment-snapshot
[depth]: ../openspec/changes/archive/2026-11-10-terminal-render-engine/design.md#11-colour-depth-is-detected-and-every-colour-is-downgraded-to-it
[unix]: ../openspec/changes/archive/2026-11-10-terminal-render-engine/design.md#19-ctl-term-supports-and-tests-unix-only-for-now
[pipe]: ../openspec/changes/archive/2026-11-10-terminal-render-engine/design.md#13-a-closed-pipe-ends-the-program-quietly