Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/scripts/check-cli-contract.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# The published CLI's documented contract, as exit-code assertions.
#
# `termlens --help` states three exit codes -- 0 ran, 1 diff found a
# difference, 2 termlens itself could not run -- and STABILITY.md promises
# them. Nothing checked them against an *installed* binary until #326: the
# `install` workflow verified `cargo add termlens` and never `cargo install
# termlens-cli`, so #310 (`termlens inspect --version` exiting 2) shipped in
# 0.10.0 and again in 0.10.1 and was found by a user of the published binary.
#
# Takes the binary to exercise, so the same assertions run against a release
# from crates.io in CI and against `target/debug/termlens` locally -- which
# is how you check that the assertions can fail at all:
#
# cargo build -p termlens-cli
# .github/scripts/check-cli-contract.sh target/debug/termlens
#
# Portability: macOS ships bash 3.2 and BSD sed. No `declare -A`, no GNU-only
# sed flags, no process substitution in the assertions.
set -euo pipefail

BIN=${1:?usage: check-cli-contract.sh <path-to-termlens>}
command -v "$BIN" >/dev/null 2>&1 || [ -x "$BIN" ] || {
echo "check-cli-contract: $BIN is not executable" >&2
exit 2
}

WORK=$(mktemp -d 2>/dev/null || mktemp -d -t termlens-cli-contract)
trap 'rm -rf "$WORK"' EXIT
status=0

# `<label> <expected-exit> <command…>`; runs it, compares, records.
expect() {
label=$1
want=$2
shift 2
got=0
"$@" > "$WORK/out" 2> "$WORK/err" || got=$?
if [ "$got" = "$want" ]; then
printf ' ok %-46s exit %s\n' "$label" "$got"
else
printf ' FAIL %-46s exit %s, expected %s\n' "$label" "$got" "$want" >&2
sed 's/^/ /' "$WORK/err" >&2 || true
status=1
fi
}

contains() {
label=$1
needle=$2
file=$3
if grep -qF -- "$needle" "$file"; then
printf ' ok %-46s contains %s\n' "$label" "$needle"
else
printf ' FAIL %-46s does not contain %s\n' "$label" "$needle" >&2
status=1
fi
}

echo "cli contract: $BIN"

# --- the version, in every position. #310 was exactly this.
expect "--version" 0 "$BIN" --version
top=$("$BIN" --version 2>/dev/null || echo "<failed>")
for sub in inspect diff render; do
expect "$sub --version" 0 "$BIN" "$sub" --version
sub_version=$("$BIN" "$sub" --version 2>/dev/null || echo "<failed>")
if [ "$sub_version" = "$top" ]; then
printf ' ok %-46s same string as top level\n' "$sub --version"
else
printf ' FAIL %-46s said %s, top level said %s\n' \
"$sub --version" "$sub_version" "$top" >&2
status=1
fi
done

# --- inspect drives a real PTY and prints a screen with its trailer.
expect "inspect a program" 0 "$BIN" inspect sh -c 'printf hi'
"$BIN" inspect sh -c 'printf hi' > "$WORK/raw.txt" 2>/dev/null || true
contains "inspect prints the screen" "hi" "$WORK/raw.txt"
contains "inspect prints the header" "size: " "$WORK/raw.txt"
contains "inspect prints the trailer" "--- exited: " "$WORK/raw.txt"

# A saved screen is that output without the human trailer (see the note in
# `install.yml`: the trailer is not part of the snapshot format).
sed '/^--- exited:/d' "$WORK/raw.txt" > "$WORK/a.snap"
"$BIN" inspect sh -c 'printf bye' 2>/dev/null | sed '/^--- exited:/d' > "$WORK/b.snap"
printf 'not a saved screen at all\n' > "$WORK/junk.txt"

# --- render, every format, from a saved screen.
expect "render --text" 0 "$BIN" render --text "$WORK/a.snap"
expect "render --svg" 0 "$BIN" render --svg "$WORK/a.snap"
expect "render --html" 0 "$BIN" render --html "$WORK/a.snap"
expect "render --ansi" 0 "$BIN" render --ansi "$WORK/a.snap"
"$BIN" render --svg "$WORK/a.snap" > "$WORK/out.svg" 2>/dev/null || true
if head -c 4 "$WORK/out.svg" | grep -q '<svg'; then
printf ' ok %-46s starts with <svg\n' "render --svg"
else
printf ' FAIL %-46s does not start with <svg\n' "render --svg" >&2
status=1
fi

# --- diff, which is the one command with three meaningful exit codes.
expect "diff, same picture" 0 "$BIN" diff "$WORK/a.snap" "$WORK/a.snap"
expect "diff, different pictures" 1 "$BIN" diff "$WORK/a.snap" "$WORK/b.snap"
expect "diff, unreadable input" 2 "$BIN" diff "$WORK/junk.txt" "$WORK/a.snap"
expect "render, unreadable input" 2 "$BIN" render --text "$WORK/junk.txt"
expect "diff, missing file" 2 "$BIN" diff "$WORK/nope.snap" "$WORK/a.snap"
expect "an unknown subcommand" 2 "$BIN" nonesuch
expect "--help" 0 "$BIN" --help

if [ "$status" -eq 0 ]; then
echo "cli contract: PASS — every documented exit code holds"
fi
exit "$status"
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ jobs:
- run: cargo test --workspace --all-features
env:
TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens
# The CLI's documented exit codes, against this tree. `install.yml`
# runs the same script against the *published* binary, which is the
# net; this is the fast feedback. #310 was a `--version` that exited 2
# in two published releases and was found by a user, so the point is
# to fail on the pull request rather than after the publish.
- run: cargo build -p termlens-cli
- run: .github/scripts/check-cli-contract.sh target/debug/termlens
# The screens of whatever failed, in the step summary (#251). The
# suite dogfoods the action with the CLI from this tree.
- uses: ./.github/actions/report
Expand Down
77 changes: 75 additions & 2 deletions .github/workflows/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,18 @@
# shapes are verified (#238).
#
# The consumer is a real crate in an empty directory, and the test it runs is
# a real PTY — which is the whole product, so nothing else would do. Nothing
# here checks this repository out: the version and the feature list come from
# a real PTY — which is the whole product, so nothing else would do. The
# `consume` job checks nothing out: the version and the feature list come from
# the registry, because the registry is what a consumer gets.
#
# The `cli` job answers the same question for the *binary*, which had no
# answer at all until #326: this workflow verified `cargo add termlens` and
# never `cargo install termlens-cli`, so #310 — `termlens inspect --version`
# exiting 2 — shipped in 0.10.0, shipped again in 0.10.1, and was found by a
# user of the published binary rather than by anything here. It checks the
# repository out for one file, `.github/scripts/check-cli-contract.sh`: the
# subject still comes from the registry, only the harness comes from here,
# which is also what lets the same assertions run against a path build.
name: install

on:
Expand Down Expand Up @@ -244,3 +253,67 @@ jobs:
fi
fi
cargo test --tests -- --nocapture

# The published CLI, installed the way a user installs it, held to the
# three exit codes `termlens --help` documents and STABILITY.md promises.
cli:
name: cli (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- name: Ask the registry what was published
id: crate
env:
TAG: ${{ github.event.release.tag_name }}
WANTED: ${{ inputs.version }}
run: |
api="https://crates.io/api/v1/crates/termlens-cli"
agent="termlens-install-check (github actions)"
version="${WANTED#v}"
version="${version:-${TAG#v}}"
if [ -z "$version" ]; then
version=$(curl -sSf -H "User-Agent: $agent" "$api" | jq -r .crate.max_stable_version)
fi

# `termlens-cli` is published after `termlens` in the same release,
# so the index can trail the release event by longer here than for
# the library. Wait rather than fail.
for attempt in $(seq 30); do
body=$(curl -sS -H "User-Agent: $agent" "$api/$version") || body=""
if [ "$(printf '%s' "$body" | jq -r '.version.num // empty')" = "$version" ]; then
break
fi
echo "waiting for termlens-cli $version to appear on crates.io (${attempt}/30)"
sleep 10
done

num=$(printf '%s' "$body" | jq -r '.version.num // empty')
if [ "$num" != "$version" ]; then
echo "::error::termlens-cli $version never appeared on crates.io"
exit 1
fi
if [ "$(printf '%s' "$body" | jq -r .version.yanked)" != "false" ]; then
echo "::error::termlens-cli $version is yanked"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "verifying termlens-cli $version"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
with:
toolchain: stable
# From the registry, every run, never from a cache: a cached binary is
# evidence about whatever crates.io served the day the cache was
# filled, and this job exists to be evidence about what it serves now.
- name: Install the published CLI
env:
VERSION: ${{ steps.crate.outputs.version }}
run: cargo install termlens-cli --version "=$VERSION" --locked --force
- name: Hold it to its documented contract
run: .github/scripts/check-cli-contract.sh "$(command -v termlens)"
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,61 @@ listed under a **Changed** or **Removed** heading.

## [Unreleased]

## [0.10.2] - 2026-09-10

### Added

- **The published CLI is installed and held to its documented contract**
(#326). `install.yml` verified `cargo add termlens` and never
`cargo install termlens-cli`, so #310 — `termlens inspect --version`
exiting 2 — shipped in 0.10.0, shipped again in 0.10.1, and was found by a
user of the published binary rather than by anything here.

A `cli` job installs `termlens-cli` from crates.io on Linux and macOS —
every run, never from a cache — and asserts every exit code
`termlens --help` documents: `--version` in all four positions returning
the same string, `inspect` printing a screen with its header and trailer,
`render` in all four formats, and `diff` returning **0** for the same
picture, **1** for a different one and **2** for input it cannot read.

The assertions live in `.github/scripts/check-cli-contract.sh` and take
the binary to exercise, so the same script runs against a path build. `ci.yml`
does exactly that on every pull request: the published check is the net,
the tree check is the fast feedback. Re-introducing #310 locally was
observed to fail it.

### Fixed

- **`unsupported()` no longer names the four SGR parameters the attribute
shadow implements** (#320). `5`/`25` (blink), `9`/`29` (strikethrough),
and `6`/`8`/`28` with them are exactly what `emu/shadow.rs` exists to
recover: vt100 drops them, the shadow parser puts the attribute on the
cell, and the tracker then reported the *backend's* gap under an accessor
documented as naming the emulator's.

The consequence was the one the accessor was added to prevent, inverted: a
screen said `Style::blink` was true and `unsupported()` said `^[[5m` had
been dropped, at the same instant — so a reader checking the list before
trusting a blink or masked-password assertion concluded a correct
assertion was unreliable, and
`assert!(screen.unsupported().is_empty())` could never pass for an
application that uses either attribute.

Found by `termlens-demo` driving a real ratatui application against the
published 0.10.1, which is what the testing tier is for.

**What still gets named.** An SGR with one parameter nobody models keeps
the whole sequence: `^[[59m` (underline colour) alone, and `^[[5;59m`,
where blink is recovered and `59` is not. Dropping a mixed sequence
because part of it is implemented would hide a real gap.

**The residue, measured rather than assumed.** `^[[1;5;31m` is still
named although bold, red *and* blink all reach the cell — `1` and `31`
are vt100's to implement, and this tracker knows what the shadow
recovers, not what the backend does. Narrowing that needs vt100's own SGR
surface enumerated, which is more than a patch should claim; a test
records the behaviour so it is a documented edge rather than a surprise.

- **`termlens <subcommand> --version` is no longer an unknown option.**
The flag was handled only in the top-level dispatch, so `termlens inspect
--version` exited 2 while `-h`/`--help` worked in that position. All three
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ cargo test --workspace --no-default-features --features decode
cargo test --workspace --no-default-features --features regex
cargo test --workspace --no-default-features --features serde
cargo test --workspace # default features
cargo build -p termlens-cli # then the CLI's documented exit codes:
.github/scripts/check-cli-contract.sh target/debug/termlens
RUSTDOCFLAGS='-D warnings' cargo doc --no-deps
RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --all-features
cargo deny check # cargo install cargo-deny
Expand Down
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ resolver = "2"
members = ["crates/termlens", "crates/termlens-cli", "fixtures/*"]

[workspace.package]
version = "0.10.1"
version = "0.10.2"
edition = "2021"
# Minimum supported Rust version. Checked by the `msrv` CI job, which reads
# this field; bumping it is a minor (not patch) change per our SemVer policy.
Expand Down
2 changes: 1 addition & 1 deletion crates/termlens-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ doc = false
# The version is the one release bumps alongside `workspace.package.version`
# (docs/RELEASING.md); a path dependency needs it to publish. `serde` so a
# saved screen can be JSON as well as the text format.
termlens = { version = "0.10.1", path = "../termlens", default-features = false, features = ["serde"] }
termlens = { version = "0.10.2", path = "../termlens", default-features = false, features = ["serde"] }
serde_json.workspace = true

[lints]
Expand Down
Loading