Skip to content

(fix) --json keeps stdout to one JSON document (closes #16, #40, #41) - #99

Merged
HMythical merged 4 commits into
rootdevfrom
fix/corrupted-json-output
Oct 9, 2026
Merged

HMythical merged 4 commits into
rootdevfrom
fix/corrupted-json-output

Conversation

@HMythical

@HMythical HMythical commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Description

--json promised a single parseable document on stdout but broke on four paths (#16). The first commit (07c7931) fixes those four, so prompts, help, version, -h/-V and errors each leave exactly one JSON document on stdout. The second commit (ed3d488) closes the remaining leaks: roster <name> (#40) and child processes. It also settles version under --quiet (#41). See the second-commit section below. The third commit (528ea86) is two small cleanups found along the way: a stray quiet=false in draft's output, and a race in Referee's mock OSV server that made two tests flaky on the Windows CI runner.

Confirmation prompts. confirm() (src/utils/fs.rs) printed its prompt to stdout, so baller --json eject on a terminal put Are you sure…? [y/N] into the JSON stream. Prompts now go to stderr. Under --json nothing is prompted at all: confirm(prompt, json) returns the new BallError::ConfirmationRequired, which carries the question. eject, sweep, substitute and inject therefore need --yes under --json, and change nothing without it. The check lives in confirm() itself rather than in each command, so a future caller can't forget it.

help and version. Both ignored --json and printed text. execute_command_help and execute_command_version now take GlobalFlags:

  • The overview lists commands, injected and global_options.
  • A built-in's detail splits its help lines into arguments / subcommands rows (name, description), with wrapped lines folded in, plus notes paragraphs.
  • An injected command's detail carries every .ball field.
  • version prints {"command": "version", "version": …}.
  • Text help is byte-for-byte unchanged: every topic was diffed against the pre-change output.

-h / --help / -V / --version. clap answers these itself, in text, before baller's flags are read. When --json is on the command line, BallerCommand::parse_command now re-parses the request as help [<command>] or version, keeping --config so injected commands still load. baller --json eject -h is baller --json help eject. It follows clap's ordering: a top-level -h is answered before any subcommand after it, and draft --version <V> is still draft's pin flag.

Errors. Every error went to stderr as [Error]: …, leaving a JSON consumer an empty stdout. Under --json, main now prints {"command", "error", "code"} on stdout and still exits 1. code is the new BallError::code() (the variant name), which scripts can branch on. command comes from the parsed command, or from cli::parse::scan_args reading the raw arguments when clap fails first; it is null if no subcommand was named.

referee --fail-on keeps one document. referee audit/check/scan --fail-on print their report and then fail. A JSON error after that would be a second document, so print_json and referee's deliver record that a document went out, and main sends that failure to stderr as text instead.

Also on these paths:

  • inject --json reports {"command": "inject", "name", "binary"} instead of text.
  • substitute's rollback warning moved from a stdout println! to tracing::warn!.
  • Colour is off under --json, so no escape codes reach JSON strings.

Behaviour changes worth a reviewer's eye

  • Prompts are on stderr in text mode too. On a terminal they look the same; baller eject x > out.txt no longer writes the prompt into the file.
  • baller --json sweep without --yes now fails with ConfirmationRequired instead of prompting. Its "status": "aborted" document could no longer be reached and is removed.
  • BallerCommand::parse_command now takes the argument list (&[OsString]).

Second commit (ed3d488): #40, #41 and child processes

roster <name> (#40). When the name was not on the roster, execute_roster printed Searching '<name>' not found locally, searching registries... with println! before search_remote printed its JSON, so the output failed jq.

Child processes. An audit of every non-test println! found no other baller print ahead of a --json document. Child processes inherit stdout too, though: a hook that printed, sudo apt-get/dnf/pacman install for a system package, or cargo install, wrote into the stream ahead of the JSON.

  • Under --json their stdout now goes to stderr via utils::output::child_stdout(), in core/hooks.rs, http/system.rs and http/cargo.rs. The output is still visible, just not on stdout.
  • main sets the mode once with set_json_mode, next to colored's override, so run_hook's 23 callers are untouched.
  • Text mode is unchanged: a hook still prints to stdout.
  • Injected commands still pass their output straight through, by design.

version under --quiet (#41). The --json half of #41 is the first commit. The issue also asked for baller --quiet version to print nothing; that is deliberately not adopted. --quiet hides progress, and every command still prints its result under it (-q roster still prints the table). A silent -q version would be the one command whose result disappears, and would do nothing at all. The --quiet row and the version section of docs/commands.md now say so, and a test pins it.

Third commit (528ea86): quiet=false and the flaky Windows mock

draft printed System installing via apt... quiet=false. The tracing migration (#32) left the quiet argument of the old output::info(quiet, msg) helper on one call in src/commands/draft.rs, and tracing reads it as a field to print. The argument is removed; the tracer already drops info lines under -q/--json, so the line is still hidden there. The leftover test_info_is_quiet_safe, which still exercised tracing::info!(bool, …), is removed with it.

Two Referee integration tests were flaky on Windows CI. test_a_cached_verdict_does_not_cover_another_version and test_refresh_re_queries_a_cached_identity failed this PR's first run of ed3d488 and passed on a re-run. MockServer (src/security/integration.rs) polls a non-blocking listener, and on Windows an accepted socket inherits that mode. That voided read_request's read timeout, so a request still in flight at accept time read as WouldBlock and was dropped unrecorded. read_request now restores blocking mode first. Linux sockets don't inherit the mode, which is why the bug only showed on Windows.

Docs: docs/commands.md (the --json contract, help JSON, confirmations, the roster notice, --quiet, plus a new version section), docs/error-handling.md (the JSON error shape, the new variants), docs/architecture.md (a JSON Output section, including child_stdout), docs/hooks.md (hook output under --json) and docs/inject.md. CHANGELOG.md has five entries under Unreleased → Fixed: one each for #16, #40 and #41, plus one per third-commit fix.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring
  • Test addition

Platform

  • Linux
  • Windows
  • Both

The change is platform-agnostic. It was tested natively on Linux, cross-compiled for x86_64-pc-windows-gnu and run under Wine, and run on a real Windows 11 Pro VM.

Testing

  • All existing tests pass
  • New tests added (if applicable)
  • Tested on Linux and Windows

New tests. tests/json_output.rs (17 tests) runs the real binary in an isolated --config directory per case and requires stdout to parse as one JSON document. It covers:

  • help overview and topic
  • every -h/--help/-V/--version combination, including --config surviving the rewrite
  • text help and version unchanged
  • command errors and clap CLI errors (command: null when no subcommand)
  • text errors still on stderr
  • --json after an injected command belonging to that command
  • ConfirmationRequired for eject, sweep and inject, and nothing changed without --yes
  • inject --yes success JSON
  • the read-only commands
  • referee audit/check --fail-on against a mock OSV server
  • second commit: roster <uninstalled> under --json (one document; every registry source disabled, so no network), the roster notice on stderr in text mode and absent under -q, a pre_eject hook's output on stderr under --json and on stdout without it, and -q version still printing

Every guard was mutation-checked. Removing the document_printed() guard makes the --fail-on test fail with "trailing characters". Restoring the roster println!, or dropping child_stdout() from hooks, fails the three second-commit tests. The third commit adds test_the_mock_server_waits_for_a_request_that_arrives_late, which sends its request 200 ms after connecting. A Windows build without the fix fails it 3 of 3 times on the Windows 11 VM and under Wine; with the fix it passes 3 of 3. It passes on Linux either way. BALLER_TEST_BIN points the test at another binary, so the Windows build can be run on a Windows host. Unit tests were added for scan_args, the display-request rewrite, CommandTypes::name(), help-row splitting and the JSON help shapes, version, error_json, BallError::code() and the ConfirmationRequired display text.

Platform Result
Linux cargo build --all-targets, cargo test (802 + 832 unit, 17 integration; unchanged by the third commit, which removes one stale test and adds one), cargo clippy --all-targets -- -D warnings, cargo fmt --check: all pass. #16's five reproduction steps re-run; on a real tty (script), the prompt is on stderr only, n aborts, y ejects. #40's reproduction now gives one document. --json draft jq --source system, with a fake sudo on PATH that only echoes: the apt-get lines go to stderr and stdout is one document; in text mode they stay on stdout. After the third commit the same run prints System installing via apt... without quiet=false, and -q still hides it
Windows cross-build (Docker, rustc 1.99) cargo clippy --all-targets --target x86_64-pc-windows-gnu -- -D warnings passes; no Windows-only warnings
Windows under Wine 16/17 integration tests pass. The new hook test, like the 3 core::hooks unit tests and test_utils_symlink_integration, fails under Wine only (Wine's powershell is a stub); all of them pass on the VM below
Windows 11 Pro VM (PS 5.1, Restricted policy) 20 --json invocations, including roster not-installed: each stdout one valid document, stderr empty. 17/17 integration tests (the hook test running real PowerShell); 795 + 825 unit tests pass. Text -V, version, -q version and help unchanged; the roster notice is on stderr

Interactive check on the Windows 11 console (transcript of the screenshot):

PS> & $b --config $tty --no-color eject some-pkg 2>$null     # typed n; no prompt shown: it is on stderr
PS> & $b --config $tty --no-color eject some-pkg
Are you sure you want to eject some-pkg? [y/N] n

[Error]: Aborted
PS> & $b --config $tty --json eject some-pkg
{
  "code": "ConfirmationRequired",
  "command": "eject",
  "error": "confirmation required: Are you sure you want to eject some-pkg? — pass --yes/-y to proceed under --json"
}
PS> & $b --config $tty --no-color eject some-pkg
Are you sure you want to eject some-pkg? [y/N] y
Ejected some-pkg ejected

Checklist

  • Code follows project conventions
  • Self-reviewed code
  • Documentation updated
  • No new warnings
  • Changes limited to one platform (if applicable): n/a, platform-agnostic
  • Commit messages follow project style: 07c7931, ed3d488 and 528ea86 do; 3fb77ba trigger CI is an empty commit without a type prefix, and squash-merge drops it

Related Issues

Closes #16
Closes #40
Closes #41 (the --json half is fixed; the --quiet half is deliberately not adopted, see Description)

Screenshots/Videos

None attached: this is a CLI-only change. The Windows console transcript above is taken from a screenshot of the VM run.

The flag promised a singular document, but broke in the following
functions/files

1. confirm()
2. src/utils/fs.rs
3. help <command>
4. version

Now outputs to stderr and the 4 sources of the bug no longer
ask for that document. Instead throwing a new error:

BallError::ConfirmationRequired

Tested on Windows and Linux
`baller --json roster <name>` printed "Searching '<name>' not found
locally, searching registries..." on stdout ahead of the search document
whenever the name was not on the roster, so the output failed jq. The
notice is progress output and now goes through tracing: on stderr, and
hidden by -q and --json. The same match fell back to a registry search
on any lookup error; only PackageNotFound does now, so a roster that
cannot be read is reported rather than searched.

Child processes inherit stdout too: hook scripts, sudo apt-get/dnf/
pacman and cargo install wrote ahead of the JSON document. Under --json
their stdout now goes to stderr (utils::output::child_stdout, with the
mode set once in main); text mode is unchanged.

--quiet keeps printing `version`: it hides progress, never a command's
result. Documented, and pinned by a test.

Tested with three new tests in tests/json_output.rs, each failing when
its fix is reverted, on Linux and on a Windows 11 VM. The hook test
fails under Wine only, whose powershell is a stub.

Closes #40
Closes #41
@HMythical HMythical changed the title (fix) --json keeps stdout to one JSON document for prompts, help, version and errors (closes #16) (fix) --json keeps stdout to one JSON document (closes #16, #40, #41) Oct 9, 2026
Installing a system package printed "System installing via apt...
quiet=false" on stderr. The tracing migration (#32) kept the quiet
argument of the old output::info(quiet, msg) helper on one call in
draft.rs, and tracing reads it as a field to print. The tracer already
drops info lines under -q and --json, so the argument is removed; the
leftover test_info_is_quiet_safe, which still exercised
tracing::info!(bool, ...), goes with it.

Two Referee integration tests failed intermittently on the Windows CI
runner. MockServer polls a non-blocking listener, and on Windows an
accepted socket inherits that mode, voiding read_request's read
timeout: a request still in flight at accept time read as WouldBlock
and was dropped unrecorded. read_request now restores blocking mode.

The new test_the_mock_server_waits_for_a_request_that_arrives_late
sends its request 200 ms after connecting. Built without the fix it
failed 3 of 3 runs on a Windows 11 VM and under Wine; with the fix it
passed 3 of 3. Linux does not inherit the mode, so the test passes
there either way.
@HMythical
HMythical merged commit 4bce939 into rootdev Oct 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant