Skip to main content

Command Palette

Search for a command to run...

From println! to Structured Output: A UX Lesson

Updated
•3 min read•View as Markdown
B
I build CLI tools and developer productivity scripts in Python and Rust.

In the last post, I covered graceful shutdown. This post is about a bug that wasn't really a bug - just bad output that made watchr hard to actually use.

What it looked like

Initially, watchr ran your command and printed whatever it got back - straight from Rust's debug formatting:

match output {
    Ok(out) => println!("{:?}", out),
    Err(e) => println!("{:?}", e),
}

Here's the output looked like after running a test suite:

Output { status: ExitStatus(unix_wait_status(0)), stdout: "test\n", stderr: "" }

Technically correct. Yet, completely unreadable. You're staring at Rust's internal struct representation instead of your test results.

Why this mattered

watchr's whole job is running your command and showing you what happened. If the output is unreadable, the tool fails its purpose.

This wasn't a "nice to have" fix. It was a release blocker. Shipping v0.1.0 with debug structs as the primary output would've made the tool unusable.

Designing the fix

Before writing the code, I finalized what the user actually needs to see:

  • The command that was run.

  • Did the command succeed or fail?

  • If it failed, why - an exit code, or did it not even run?

  • What did the command actually print?

That's it. No Rust internals, no exposed structs.

The fix

fn print_output(
    cmd: &str,
    output: Result<process::Output, std::io::Error>,
) {
    println!("$ {}", cmd);

    match output {
        Ok(out) if out.status.success() => {
            println!("✓ success");
            match String::from_utf8_lossy(&out.stdout).trim() {
                "" => println!("(no output)"),
                out => println!("{}", out),
            }
        }
        Ok(out) => {
            match out.status.code() {
                Some(code) => {
                    println!("✗ failed (exit code {})", code)
                }
                None => println!("✗ failed (terminated)"),
            }

            match String::from_utf8_lossy(&out.stderr).trim() {
                "" => eprintln!("(no output)"),
                err => eprintln!("{}", err),
            }
        }
        Err(e) => {
            println!("✗ failed to spawn: {}", e);
        }
    }
}

Walking through the three cases:

  • Ok(out) if out.status.success() - the command ran and succeeded. Print a checkmark, then the command's actual output.

  • Ok(out) (the fallthrough) - the command ran but failed. Show the exit code if there is one, or "terminated" if the process was killed by a signal instead of exiting normally. Then print stderr.

  • Err(e) - the command never ran at all. This happens if, say, the shell itself couldn't be found.

It is worth noting that Command::output() returns a Result<Output, io::Error>, not just Output. Okmeans the process spawned successfully (whether it succeeded or failed is a separate question, answered by status).Err means it couldn't even start.

String::from_utf8_lossy turns the raw bytes from stdout and stderr into a displayable string. .trim() matters because command output often ends in a trailing newline, or is nothing but whitespace. Without trimming, that would fail to match the "" case and print a blank line instead of "(no output)". A blank line looks like the tool might be stuck. A clear label prevents this confusion.

What you get now

$ cargo test
✓ success
running 20 tests
test cli::tests::test_is_init_true ... ok
...
test result: ok. 20 passed; 0 failed

No debug structs. Success or failure is the first thing you see, not something you have to dig for.

Next

A retrospective on what's changing in v0.2.0

Building watchr v0.1.0

Part 5 of 7

A behind-the-scenes look at building `watchr`, a Rust CLI tool that watches files and runs commands on change - the design decisions, the mistakes, and the reasoning behind each one.

Up next

watchr v0.1.0: A Retrospective - Lessons and What's Next

This series has covered why watchr looks the way it does: TOML for config, debouncing for file events, graceful shutdown, and readable output. v0.1.0 is shipped. This post is about what I'd change, an