fix(cli): --help prints usage to stdout and exits 0 (#254)

Asking for help is not an error, but `designcraft-cli --help` shared the
catch-all arm with unknown commands, so it wrote the usage block to
stderr and exited 1 — unusable in a pipeline and wrong by convention
(`--version` already exits 0).

Split the arm: `--help`, `-h` and `help` now print the same usage block
to stdout and succeed, while an unknown command keeps it on stderr and
still fails. The usage text and links line are hoisted into a shared
`USAGE` const and `usage_footer()` so the two paths can't drift.

Fixes #243

🤖 Generated with Codebuff

Co-authored-by: Codebuff <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 01:50:34 -04:00
1 parent bbe234aaa6
commit 5369687497
2 files changed
+42 -9

No files matched your search

+22 -9
View File
@@ -34,6 +34,19 @@ mod perf;
use designcraft_engine::Session;
use serde_json::{Value, json};
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
/// The links line under the usage block.
fn usage_footer() -> String {
format!(
"\nCommunity: {} · {} · {}",
designcraft_engine::links::DISCORD,
designcraft_engine::links::APP_PAGE,
designcraft_engine::links::GITHUB
)
}
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
/// ripgrep does; any other write error is reported.
fn stdout_failed(e: std::io::Error) -> ! {
@@ -76,16 +89,16 @@ fn main() -> ExitCode {
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
ExitCode::SUCCESS
}
// Asking for help is not an error: it goes to stdout and succeeds. An unknown command
// still prints the same usage to stderr and fails.
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
outln!("{}", usage_footer());
ExitCode::SUCCESS
}
_ => {
eprintln!(
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"
);
eprintln!(
"\nCommunity: {} · {} · {}",
designcraft_engine::links::DISCORD,
designcraft_engine::links::APP_PAGE,
designcraft_engine::links::GITHUB
);
eprintln!("{USAGE}");
eprintln!("{}", usage_footer());
ExitCode::FAILURE
}
}
+20
View File
@@ -67,6 +67,26 @@ fn run_cmd_references_and_describe() {
assert!(v.as_array().unwrap().iter().all(|c| c.to_string().contains("footnote")));
}
/// `designcraft-cli --help` printed usage to stderr and exited 1; asking for help is not an
/// error (#243). It now goes to stdout and succeeds, while an unknown command keeps the same
/// usage on stderr and fails.
#[test]
fn help_goes_to_stdout_and_unknown_args_to_stderr() {
for flag in ["--help", "-h", "help"] {
let out = cli().arg(flag).output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{flag}: {:?}", out.status);
assert!(stdout.starts_with("usage: designcraft-cli"), "{flag}: {stdout:?}");
assert!(stdout.contains("designcraft-cli --version"), "{flag}: {stdout:?}");
assert!(out.stderr.is_empty(), "{flag} wrote to stderr: {}", String::from_utf8_lossy(&out.stderr));
}
// An unknown option is still an error: usage on stderr, nothing on stdout, non-zero exit.
let out = cli().arg("--no-such-option").output().unwrap();
assert!(!out.status.success(), "{:?}", out.status);
assert!(out.stdout.is_empty(), "unexpected stdout: {}", String::from_utf8_lossy(&out.stdout));
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli"));
}
/// `designcraft-cli commands | head -1` panicked with "failed printing to
/// stdout: Broken pipe (os error 32)" and exit status 101.
#[test]