From 085af838a37900ef54088d60696dbf015a2d4336 Mon Sep 17 00:00:00 2001 From: JamBalaya56562 Date: Sun, 2 Aug 2026 10:11:20 +0900 Subject: [PATCH] fix(cli): let generate markdown write to stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generate markdown` was the only generator with no way to reach stdout: `--out-file` was required and nothing else emitted the document. The workaround people reach for is `--out-file /dev/stdout`, which is a device file on unix but just a relative-looking path on Windows — `xx::file::write` creates the parent directory first, so it leaves a real `C:\dev\stdout` behind. This repo's own test suite did exactly that, and the resulting snapshot had a stray `writing to /dev/stdout` line baked into its first line. `--out-file` is now optional on markdown and defaults to stdout, matching manpage, fig, json and completion. `-` also means stdout on markdown, manpage and fig, mirroring the `-f -` convention already used for reading a spec from stdin; a file literally named `-` is spelled `./-`. Both spellings go through one `write_or_stdout` helper sitting next to `parse_file_or_stdin`. That helper writes with `write_all` rather than `print!`, which panics when the write fails. These documents outlast a pipe buffer — 100 KB for mise's spec — so `usage g markdown -f mise.usage.kdl --out-file - | head -1` ended in `failed printing to stdout … (os error 109)` and exit 101. A reader that closed early is not reported: Rust ignores SIGPIPE, so what ends the process silently in C arrives as an ordinary write error. The `writing to ...` progress line moved from stdout to stderr on markdown, manpage, fig and sdk. On stdout it lands inside the document as soon as the document itself goes to stdout, which is what the snapshot was recording. Dropping the `--out-file` requirement would have made `--out-dir` without `--multi` silently print to stdout and ignore the directory, so `--out-dir` now requires `--multi`. `/dev/stdout` is deliberately not special-cased on Windows: it is a unix fd path, and emulating it honestly pulls in `/dev/stderr`, `/dev/null` and `/dev/fd/N` too. `-` is the portable spelling and is now in the help text. --- cli/assets/fig.ts | 6 +- cli/assets/usage.1 | 6 +- cli/src/cli/generate/fig.rs | 9 ++- cli/src/cli/generate/manpage.rs | 11 +--- cli/src/cli/generate/markdown.rs | 31 ++++++----- cli/src/cli/generate/mod.rs | 41 +++++++++++++- cli/src/cli/generate/sdk.rs | 2 +- cli/tests/manpage.rs | 15 +++++ cli/tests/markdown.rs | 55 ++++++++++++++++--- ...down__markdown_snapshot_with_examples.snap | 3 +- cli/usage.usage.kdl | 6 +- docs/cli/reference/commands.json | 12 ++-- docs/cli/reference/generate/fig.md | 2 +- docs/cli/reference/generate/manpage.md | 2 +- docs/cli/reference/generate/markdown.md | 2 +- 15 files changed, 149 insertions(+), 54 deletions(-) diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index 883e5355..a7de9bb4 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -324,7 +324,7 @@ const completionSpec: Fig.Spec = { { name: "--out-file", description: - "File path where the generated Fig spec will be saved", + 'File path where the generated Fig spec will be saved, or "-" for stdout', isRepeatable: false, args: { name: "out_file", @@ -380,7 +380,7 @@ const completionSpec: Fig.Spec = { }, { name: ["-o", "--out-file"], - description: "Output file path (defaults to stdout)", + description: 'Output file path, or "-" for stdout (default)', isRepeatable: false, args: { name: "out_file", @@ -434,7 +434,7 @@ const completionSpec: Fig.Spec = { { name: "--out-file", description: - "Output file path for single-file markdown generation", + 'Output file path for single-file markdown generation, or "-" for stdout (default)', isRepeatable: false, args: { name: "out_file", diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index fe006c63..52215452 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -258,7 +258,7 @@ Generate Fig completion spec for Amazon Q / Fig A usage spec taken in as a file, use "\-" to read from stdin .TP \fB\-\-out\-file\fR \fI\fR -File path where the generated Fig spec will be saved +File path where the generated Fig spec will be saved, or "\-" for stdout .TP \fB\-\-spec\fR \fI\fR Raw string spec input @@ -285,7 +285,7 @@ raw string spec input A usage spec taken in as a file, use "\-" to read from stdin .TP \fB\-o, \-\-out\-file\fR \fI\fR -Output file path (defaults to stdout) +Output file path, or "\-" for stdout (default) .TP \fB\-s, \-\-section\fR \fI
\fR Manual section number (default: 1) @@ -315,7 +315,7 @@ Escape HTML in markdown Output markdown files to this directory (required when using \-\-multi) .TP \fB\-\-out\-file\fR \fI\fR -Output file path for single\-file markdown generation +Output file path for single\-file markdown generation, or "\-" for stdout (default) .TP \fB\-\-replace\-pre\-with\-code\-fences\fR Replace `
` tags with markdown code fences
diff --git a/cli/src/cli/generate/fig.rs b/cli/src/cli/generate/fig.rs
index 7d0896a9..0f80bfe8 100644
--- a/cli/src/cli/generate/fig.rs
+++ b/cli/src/cli/generate/fig.rs
@@ -52,7 +52,7 @@ pub struct Fig {
     #[clap(short, long)]
     file: Option,
 
-    /// File path where the generated Fig spec will be saved
+    /// File path where the generated Fig spec will be saved, or "-" for stdout
     #[clap(long, value_hint = clap::ValueHint::FilePath)]
     out_file: Option,
 
@@ -355,8 +355,7 @@ impl FigCommand {
 impl Fig {
     pub fn run(&self) -> miette::Result<()> {
         let write = |path: &PathBuf, md: &str| -> miette::Result<()> {
-            println!("writing to {}", path.display());
-            xx::file::write(path, format!("{}\n", md.trim()))?;
+            generate::write_or_stdout(Some(path), &format!("{}\n", md.trim()))?;
             Ok(())
         };
         let spec = generate::file_or_spec(&self.file, &self.spec)?;
@@ -394,6 +393,10 @@ impl Fig {
                 )
             });
 
+        // Only the file path wraps the spec in the prescript/postscript that make it usable
+        // on its own; bare `usage g fig` prints the spec object alone. `--out-file -` follows
+        // the file path, since it means "the bytes a file would have received". Whether the
+        // two should agree is a separate question, left alone here.
         if let Some(path) = &self.out_file {
             let prescript = if let Some(source_file) = &self.file {
                 let source_label = if source_file.as_os_str() == "-" {
diff --git a/cli/src/cli/generate/manpage.rs b/cli/src/cli/generate/manpage.rs
index 797e36be..218c5db5 100644
--- a/cli/src/cli/generate/manpage.rs
+++ b/cli/src/cli/generate/manpage.rs
@@ -1,6 +1,6 @@
 use std::path::PathBuf;
 
-use super::parse_file_or_stdin;
+use super::{parse_file_or_stdin, write_or_stdout};
 use clap::Args;
 use usage::docs::manpage::ManpageRenderer;
 
@@ -11,7 +11,7 @@ pub struct Manpage {
     #[clap(short, long)]
     file: PathBuf,
 
-    /// Output file path (defaults to stdout)
+    /// Output file path, or "-" for stdout (default)
     #[clap(short, long, value_hint = clap::ValueHint::FilePath)]
     out_file: Option,
 
@@ -32,12 +32,7 @@ impl Manpage {
         let renderer = ManpageRenderer::new(spec).with_section(self.section);
         let manpage = renderer.render()?;
 
-        if let Some(out_file) = &self.out_file {
-            println!("writing to {}", out_file.display());
-            xx::file::write(out_file, &manpage)?;
-        } else {
-            print!("{}", manpage);
-        }
+        write_or_stdout(self.out_file.as_deref(), &manpage)?;
 
         Ok(())
     }
diff --git a/cli/src/cli/generate/markdown.rs b/cli/src/cli/generate/markdown.rs
index 6938386f..66455f6b 100644
--- a/cli/src/cli/generate/markdown.rs
+++ b/cli/src/cli/generate/markdown.rs
@@ -1,6 +1,6 @@
 use std::path::PathBuf;
 
-use super::parse_file_or_stdin;
+use super::{parse_file_or_stdin, write_or_stdout};
 use clap::Args;
 use usage::docs::markdown::MarkdownRenderer;
 
@@ -23,11 +23,11 @@ pub struct Markdown {
     html_encode: bool,
 
     /// Output markdown files to this directory (required when using --multi)
-    #[clap(long, value_hint = clap::ValueHint::DirPath)]
+    #[clap(long, value_hint = clap::ValueHint::DirPath, requires = "multi")]
     out_dir: Option,
 
-    /// Output file path for single-file markdown generation
-    #[clap(long, value_hint = clap::ValueHint::FilePath, required_unless_present = "multi")]
+    /// Output file path for single-file markdown generation, or "-" for stdout (default)
+    #[clap(long, value_hint = clap::ValueHint::FilePath)]
     out_file: Option,
 
     /// Replace `
` tags with markdown code fences
@@ -41,15 +41,19 @@ pub struct Markdown {
 
 impl Markdown {
     pub fn run(&self) -> miette::Result<()> {
+        // The banner belongs to every generated document, so build it in one place rather
+        // than once per output path.
+        let render = |md: &str| {
+            format!(
+                "\n{}\n",
+                md.trim()
+            )
+        };
+        // File-only, deliberately: every path this receives is a join onto `--out-dir`, so a
+        // `-` meaning stdout cannot turn up here.
         let write = |path: &PathBuf, md: &str| -> miette::Result<()> {
-            println!("writing to {}", path.display());
-            xx::file::write(
-                path,
-                format!(
-                    "\n{}\n",
-                    md.trim()
-                ),
-            )?;
+            eprintln!("writing to {}", path.display());
+            xx::file::write(path, render(md))?;
             Ok(())
         };
         let spec = parse_file_or_stdin(&self.file)?;
@@ -84,8 +88,7 @@ impl Markdown {
             write(&path_idx, &md_idx)?;
         } else {
             let md = ctx.render_spec()?;
-            let path = self.out_file.as_ref().unwrap();
-            write(path, &md)?;
+            write_or_stdout(self.out_file.as_deref(), &render(&md))?;
         }
         Ok(())
     }
diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs
index 38d6b5e5..131750d2 100644
--- a/cli/src/cli/generate/mod.rs
+++ b/cli/src/cli/generate/mod.rs
@@ -1,4 +1,4 @@
-use std::io::Read;
+use std::io::{Read, Write};
 use std::path::{Path, PathBuf};
 use usage::error::UsageErr;
 
@@ -65,6 +65,45 @@ pub fn parse_file_or_stdin(file: &Path) -> Result {
     }
 }
 
+/// The mirror of [`parse_file_or_stdin`]: `-` means stdout, and so does no path at all.
+///
+/// Both spellings are collapsed here so a caller never has to ask which of the two it is
+/// looking at. To write to a file actually named `-`, spell it `./-`.
+///
+/// The progress line goes to stderr. It used to go to stdout, where it ended up inside the
+/// document whenever the document itself was going to stdout.
+pub fn write_or_stdout(out_file: Option<&Path>, contents: &str) -> Result<(), UsageErr> {
+    match out_file {
+        Some(path) if path.as_os_str() != "-" => {
+            eprintln!("writing to {}", path.display());
+            xx::file::write(path, contents)?;
+        }
+        _ => write_stdout(contents)?,
+    }
+    Ok(())
+}
+
+/// Write a generated document to stdout, reporting a failed write rather than panicking.
+///
+/// `print!` panics if the write fails, and these documents are big enough to outlast a pipe
+/// buffer: `usage g markdown -f mise.usage.kdl --out-file - | head -1` produces 100 KB and
+/// used to end in `failed printing to stdout … (os error 109)` and exit 101.
+///
+/// A reader that closed early is not a failure to report, though. Rust ignores `SIGPIPE`, so
+/// what would end the process silently in C arrives here as an ordinary write error; treating
+/// it as success is what makes `| head` behave the way it does everywhere else.
+fn write_stdout(contents: &str) -> Result<(), UsageErr> {
+    let mut stdout = std::io::stdout().lock();
+    let wrote = stdout.write_all(contents.as_bytes()).and_then(|_| {
+        // Explicitly, because the flush at process exit discards whatever it hits.
+        stdout.flush()
+    });
+    match wrote {
+        Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
+        other => Ok(other?),
+    }
+}
+
 fn read_spec_from_stdin() -> Result {
     let mut input = String::new();
     std::io::stdin().read_to_string(&mut input)?;
diff --git a/cli/src/cli/generate/sdk.rs b/cli/src/cli/generate/sdk.rs
index 76949eb6..cbbcee32 100644
--- a/cli/src/cli/generate/sdk.rs
+++ b/cli/src/cli/generate/sdk.rs
@@ -57,7 +57,7 @@ impl Sdk {
 
         for file in &output.files {
             let path = self.output.join(&file.path);
-            println!("writing to {}", path.display());
+            eprintln!("writing to {}", path.display());
             xx::file::write(&path, &file.content)?;
         }
 
diff --git a/cli/tests/manpage.rs b/cli/tests/manpage.rs
index cce4b436..1d305ec1 100644
--- a/cli/tests/manpage.rs
+++ b/cli/tests/manpage.rs
@@ -97,6 +97,21 @@ fn test_generate_manpage_output_to_file() {
     std::fs::remove_file(&out_file).unwrap();
 }
 
+#[test]
+fn test_generate_manpage_out_file_dash_is_stdout() {
+    let run = |args: &[&str]| {
+        let mut cmd = usage_cmd();
+        cmd.args(["generate", "manpage", "-f"]);
+        cmd.arg(example_path("basic.usage.kdl"));
+        cmd.args(args);
+        let output = cmd.output().unwrap();
+        assert!(output.status.success());
+        String::from_utf8(output.stdout).unwrap()
+    };
+
+    assert_eq!(run(&[]), run(&["-o", "-"]));
+}
+
 #[test]
 fn test_manpage_output_first_50_lines() {
     // Test first 50 lines of mise manpage to avoid huge snapshot
diff --git a/cli/tests/markdown.rs b/cli/tests/markdown.rs
index b8621a85..ec47e622 100644
--- a/cli/tests/markdown.rs
+++ b/cli/tests/markdown.rs
@@ -177,7 +177,8 @@ fn test_generate_markdown_basic() {
         out_file.to_str().unwrap(),
     ]);
 
-    cmd.assert().success();
+    let output = cmd.output().unwrap();
+    assert!(output.status.success());
 
     // Verify file was created
     assert!(out_file.exists());
@@ -186,25 +187,65 @@ fn test_generate_markdown_basic() {
     let content = fs::read_to_string(&out_file).unwrap();
     assert!(content.contains("# `basic.usage.kdl`"));
 
+    // The progress line belongs on stderr; on stdout it lands inside the document whenever
+    // the document is going to stdout.
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    let stderr = String::from_utf8(output.stderr).unwrap();
+    assert!(!stdout.contains("writing to"), "stdout was: {stdout:?}");
+    assert!(stderr.contains("writing to"), "stderr was: {stderr:?}");
+
     // Clean up
     std::fs::remove_file(&out_file).unwrap();
 }
 
+fn markdown_stdout(args: &[&str]) -> String {
+    let mut cmd = usage_cmd();
+    cmd.args(["generate", "markdown", "-f"]);
+    cmd.arg(example_path("with-examples.usage.kdl"));
+    cmd.args(args);
+
+    let output = cmd.output().unwrap();
+    assert!(output.status.success());
+    String::from_utf8(output.stdout).unwrap()
+}
+
 #[test]
 fn test_markdown_snapshot_with_examples() {
+    insta::assert_snapshot!(markdown_stdout(&["--out-file", "-"]));
+}
+
+#[test]
+fn test_markdown_stdout_when_out_file_omitted() {
+    // The two spellings of "stdout" have to agree; `--out-file -` exists for callers that
+    // build the path in a variable.
+    assert_eq!(markdown_stdout(&[]), markdown_stdout(&["--out-file", "-"]));
+}
+
+#[test]
+fn test_markdown_out_file_dash_writes_no_file() {
+    // The bug this guards: `--out-file` was resolved as a path unconditionally, and
+    // `xx::file::write` creates the parent directory before writing. `/dev/stdout` therefore
+    // produced a real `C:\dev\stdout` on Windows; `-` would likewise leave a file named `-`.
+    let dir = std::env::temp_dir().join(format!("usage_md_dash_{}", std::process::id()));
+    let _ = fs::remove_dir_all(&dir);
+    fs::create_dir_all(&dir).unwrap();
+
     let mut cmd = usage_cmd();
-    cmd.args([
+    cmd.current_dir(&dir).args([
         "generate",
         "markdown",
         "-f",
         &example_path("with-examples.usage.kdl"),
         "--out-file",
-        "/dev/stdout",
+        "-",
     ]);
+    cmd.assert().success();
 
-    let output = cmd.output().unwrap();
-    assert!(output.status.success());
+    let leftovers: Vec<_> = fs::read_dir(&dir)
+        .unwrap()
+        .map(|e| e.unwrap().path())
+        .collect();
+    assert!(leftovers.is_empty(), "wrote files: {leftovers:?}");
 
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    insta::assert_snapshot!(stdout);
+    fs::remove_dir_all(&dir).unwrap();
 }
diff --git a/cli/tests/snapshots/markdown__markdown_snapshot_with_examples.snap b/cli/tests/snapshots/markdown__markdown_snapshot_with_examples.snap
index dab6e235..65cbdc0c 100644
--- a/cli/tests/snapshots/markdown__markdown_snapshot_with_examples.snap
+++ b/cli/tests/snapshots/markdown__markdown_snapshot_with_examples.snap
@@ -1,8 +1,7 @@
 ---
 source: cli/tests/markdown.rs
-expression: stdout
+expression: "markdown_stdout(&[\"--out-file\", \"-\"])"
 ---
-writing to /dev/stdout
 
 # `demo`
 
diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl
index 7346ee54..0dbe7329 100644
--- a/cli/usage.usage.kdl
+++ b/cli/usage.usage.kdl
@@ -124,7 +124,7 @@ You may need to set this if you have a different bin named "usage"
         flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" {
             arg 
         }
-        flag --out-file help="File path where the generated Fig spec will be saved" effect=write {
+        flag --out-file help="File path where the generated Fig spec will be saved, or \"-\" for stdout" effect=write {
             arg 
         }
         flag --spec help="Raw string spec input" {
@@ -144,7 +144,7 @@ You may need to set this if you have a different bin named "usage"
         flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" required=#true {
             arg 
         }
-        flag "-o --out-file" help="Output file path (defaults to stdout)" effect=write {
+        flag "-o --out-file" help="Output file path, or \"-\" for stdout (default)" effect=write {
             arg 
         }
         flag "-s --section" help="Manual section number (default: 1)" default="1" {
@@ -166,7 +166,7 @@ Common sections: - 1: User commands - 5: File formats - 7: Miscellaneous - 8: Sy
         flag --out-dir help="Output markdown files to this directory (required when using --multi)" effect=write {
             arg 
         }
-        flag --out-file help="Output file path for single-file markdown generation" effect=write {
+        flag --out-file help="Output file path for single-file markdown generation, or \"-\" for stdout (default)" effect=write {
             arg 
         }
         flag --replace-pre-with-code-fences help="Replace `
` tags with markdown code fences"
diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json
index 41f3b5c0..c31e4a7d 100644
--- a/docs/cli/reference/commands.json
+++ b/docs/cli/reference/commands.json
@@ -473,8 +473,8 @@
               {
                 "name": "out-file",
                 "usage": "--out-file ",
-                "help": "File path where the generated Fig spec will be saved",
-                "help_first_line": "File path where the generated Fig spec will be saved",
+                "help": "File path where the generated Fig spec will be saved, or \"-\" for stdout",
+                "help_first_line": "File path where the generated Fig spec will be saved, or \"-\" for stdout",
                 "short": [],
                 "long": ["out-file"],
                 "hide": false,
@@ -592,8 +592,8 @@
               {
                 "name": "out-file",
                 "usage": "-o --out-file ",
-                "help": "Output file path (defaults to stdout)",
-                "help_first_line": "Output file path (defaults to stdout)",
+                "help": "Output file path, or \"-\" for stdout (default)",
+                "help_first_line": "Output file path, or \"-\" for stdout (default)",
                 "short": ["o"],
                 "long": ["out-file"],
                 "hide": false,
@@ -700,8 +700,8 @@
               {
                 "name": "out-file",
                 "usage": "--out-file ",
-                "help": "Output file path for single-file markdown generation",
-                "help_first_line": "Output file path for single-file markdown generation",
+                "help": "Output file path for single-file markdown generation, or \"-\" for stdout (default)",
+                "help_first_line": "Output file path for single-file markdown generation, or \"-\" for stdout (default)",
                 "short": [],
                 "long": ["out-file"],
                 "hide": false,
diff --git a/docs/cli/reference/generate/fig.md b/docs/cli/reference/generate/fig.md
index 18d90c6b..34d306fd 100644
--- a/docs/cli/reference/generate/fig.md
+++ b/docs/cli/reference/generate/fig.md
@@ -18,7 +18,7 @@ A usage spec taken in as a file, use "-" to read from stdin
 
 **Effect**: modifies state
 
-File path where the generated Fig spec will be saved
+File path where the generated Fig spec will be saved, or "-" for stdout
 
 ### `--spec `
 
diff --git a/docs/cli/reference/generate/manpage.md b/docs/cli/reference/generate/manpage.md
index fc3821dc..7f0d4e27 100644
--- a/docs/cli/reference/generate/manpage.md
+++ b/docs/cli/reference/generate/manpage.md
@@ -17,7 +17,7 @@ A usage spec taken in as a file, use "-" to read from stdin
 
 **Effect**: modifies state
 
-Output file path (defaults to stdout)
+Output file path, or "-" for stdout (default)
 
 ### `-s --section 
` diff --git a/docs/cli/reference/generate/markdown.md b/docs/cli/reference/generate/markdown.md index aa06f74a..508a7f78 100644 --- a/docs/cli/reference/generate/markdown.md +++ b/docs/cli/reference/generate/markdown.md @@ -33,7 +33,7 @@ Output markdown files to this directory (required when using --multi) **Effect**: modifies state -Output file path for single-file markdown generation +Output file path for single-file markdown generation, or "-" for stdout (default) ### `--replace-pre-with-code-fences`