fj --version prints the ASCII brand mark even when piped, breaking version parsing in scripts #105

Closed
opened 2026-06-11 00:01:10 +00:00 by stephen · 2 comments
Owner

Observation

fj --version prints the 15-line ASCII brand mark to stdout even when output is piped or redirected, so the version string is buried under decoration that scripts have to strip.

$ fj --version | wc -l
16
$ fj --version | head -1
            (blank line, top of the logo)
$ fj --version | tail -1
fj 0.2.0

This is not an oversight; it is a deliberate branch that contradicts the stated goal a few lines above it. The doc-comment on handle_parse_outcome (src/main.rs:66-70) says:

/// with the brand mark first, but only on an interactive stdout so piped
/// `fj --help` / `fj --version` stay clean and parseable.

But the implementation only honors that for help, not version (src/main.rs:75-85):

let show_logo = match err.kind() {
    // `--version` always wears the mark: asking for the version is an
    // explicit "show me fj" and we want it to read the same everywhere.
    ErrorKind::DisplayVersion => true,
    // The help screen ... only gets the mark on an interactive stdout
    ErrorKind::DisplayHelp | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => {
        std::io::stdout().is_terminal()
    }
    _ => false,
};

So fj --help | cat is clean (gated on is_terminal()), but fj --version | cat is not. The two paths disagree, and the version path is the one that loses.

Why it matters

--version is the single most script-consumed flag a CLI has: CI provenance logs, bug-report templates, tool --version || install guards, and agent self-checks all parse it. git --version and gh --version each emit exactly one line. A team wiring fj --version into a pipeline gets 16 lines with the real datum last and a leading blank, so naive parsers (head -1, cut -d' ' -f2) capture whitespace or art instead of 0.2.0. It is a first-contact paper cut precisely because version-probing is often the very first programmatic call a new adopter makes against the binary.

Possible directions (sketches)

  • (sketch) Gate DisplayVersion on std::io::stdout().is_terminal() exactly like DisplayHelp already is (src/main.rs:80-83), so an interactive fj --version still wears the mark but a piped one emits just fj 0.2.0.
  • (sketch) If the mark on interactive --version is worth keeping, print the logo to stderr and the version line to stdout, so redirection of stdout stays clean while the terminal still gets brand.
  • (sketch) Add a test asserting fj --version with a non-tty stdout produces a single line matching ^fj \d+\.\d+\.\d+$, mirroring whatever covers the piped-help case.

Confidence

High. Behavior reproduced (fj --version | wc -l = 16). The contradiction is in one function: the doc-comment promising piped-clean version output (src/main.rs:66-70) versus ErrorKind::DisplayVersion => true (src/main.rs:78), with the adjacent help branch (src/main.rs:80-83) showing the intended is_terminal() gate already applied elsewhere. The remaining choice (suppress vs. route to stderr on a tty) is a product call, not a question of fact.

## Observation `fj --version` prints the 15-line ASCII brand mark to stdout even when output is piped or redirected, so the version string is buried under decoration that scripts have to strip. ``` $ fj --version | wc -l 16 $ fj --version | head -1 (blank line, top of the logo) $ fj --version | tail -1 fj 0.2.0 ``` This is not an oversight; it is a deliberate branch that contradicts the stated goal a few lines above it. The doc-comment on `handle_parse_outcome` (`src/main.rs:66-70`) says: ``` /// with the brand mark first, but only on an interactive stdout so piped /// `fj --help` / `fj --version` stay clean and parseable. ``` But the implementation only honors that for help, not version (`src/main.rs:75-85`): ```rust let show_logo = match err.kind() { // `--version` always wears the mark: asking for the version is an // explicit "show me fj" and we want it to read the same everywhere. ErrorKind::DisplayVersion => true, // The help screen ... only gets the mark on an interactive stdout ErrorKind::DisplayHelp | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => { std::io::stdout().is_terminal() } _ => false, }; ``` So `fj --help | cat` is clean (gated on `is_terminal()`), but `fj --version | cat` is not. The two paths disagree, and the version path is the one that loses. ## Why it matters `--version` is the single most script-consumed flag a CLI has: CI provenance logs, bug-report templates, `tool --version || install` guards, and agent self-checks all parse it. `git --version` and `gh --version` each emit exactly one line. A team wiring `fj --version` into a pipeline gets 16 lines with the real datum last and a leading blank, so naive parsers (`head -1`, `cut -d' ' -f2`) capture whitespace or art instead of `0.2.0`. It is a first-contact paper cut precisely because version-probing is often the very first programmatic call a new adopter makes against the binary. ## Possible directions (sketches) - *(sketch)* Gate `DisplayVersion` on `std::io::stdout().is_terminal()` exactly like `DisplayHelp` already is (`src/main.rs:80-83`), so an interactive `fj --version` still wears the mark but a piped one emits just `fj 0.2.0`. - *(sketch)* If the mark on interactive `--version` is worth keeping, print the logo to stderr and the version line to stdout, so redirection of stdout stays clean while the terminal still gets brand. - *(sketch)* Add a test asserting `fj --version` with a non-tty stdout produces a single line matching `^fj \d+\.\d+\.\d+$`, mirroring whatever covers the piped-help case. ## Confidence High. Behavior reproduced (`fj --version | wc -l` = 16). The contradiction is in one function: the doc-comment promising piped-clean version output (`src/main.rs:66-70`) versus `ErrorKind::DisplayVersion => true` (`src/main.rs:78`), with the adjacent help branch (`src/main.rs:80-83`) showing the intended `is_terminal()` gate already applied elsewhere. The remaining choice (suppress vs. route to stderr on a tty) is a product call, not a question of fact.
Author
Owner

Converted to backlog item rasterstate/fj#115 (p3, size S).

Tracked there with task / priority / reason / acceptance / dependencies. Keeping this open with the converted label as the originating opportunity.

Converted to backlog item `rasterstate/fj#115` (p3, size S). Tracked there with task / priority / reason / acceptance / dependencies. Keeping this open with the `converted` label as the originating opportunity.
Author
Owner

Derived backlog item rasterstate/fj#115 is merged. Closing this opportunity per the issue state machine.

Derived backlog item rasterstate/fj#115 is merged. Closing this opportunity per the issue state machine.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rasterstate/fj#105
No description provided.