1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
//! Consistent command output.
//!
//! Every `boatramp` subcommand returns a *typed result value* and this module renders it once, at
//! the dispatch boundary, as either human-readable text (the default) or machine-readable JSON
//! (`--output-format json`, env `BOATRAMP_OUTPUT_FORMAT=json`). The point is that a command's output
//! no longer depends on how it was invoked: the same typed value renders the same way for a terminal,
//! a captured pipe, or a programmatic caller that wants JSON — instead of each command hand-printing
//! to stdout with `println!`, which only ever produces one ad-hoc text shape.
//!
//! Division of labor:
//! - A command's **result** (what it computed / changed) is the `Rendered` value it returns here.
//! - Transient **progress / status** during a long operation stays on the `tracing` sink (stderr,
//! and JSON-structured under `BOATRAMP_LOG_FORMAT=json`) — it is not a result and does not pass
//! through `emit`.
//! - **Errors** propagate as the command's `Result::Err`; `emit` only renders the `Ok` value.
use std::io::{self, Write};
use clap::ValueEnum;
/// How a command's result value is rendered to stdout.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum OutputMode {
/// Human-readable text (the default) — what the command historically printed.
#[default]
Human,
/// Pretty JSON: the `serde` serialization of the result value, for scripts and other callers.
Json,
}
/// A typed command result. JSON rendering is free via the `serde::Serialize` bound, so an
/// implementor only supplies the human view (the text the command used to `println!`).
///
/// Keep the two faithful to each other: the JSON object should carry the same facts the human text
/// shows, so switching `--output-format` never changes *what* a command reports, only its shape.
pub trait Rendered: serde::Serialize {
/// Write the human-readable rendering to `w`. Called only in [`OutputMode::Human`]; include any
/// trailing newline the output needs.
fn render_human(&self, w: &mut dyn Write) -> io::Result<()>;
}
/// Render `value` to stdout in `mode`: the human text, or pretty JSON followed by a newline.
pub fn emit<T: Rendered + ?Sized>(mode: OutputMode, value: &T) -> io::Result<()> {
let stdout = io::stdout();
let mut w = stdout.lock();
match mode {
OutputMode::Human => value.render_human(&mut w)?,
OutputMode::Json => {
serde_json::to_writer_pretty(&mut w, value).map_err(io::Error::other)?;
w.write_all(b"\n")?;
}
}
w.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Serialize)]
struct Sample {
name: String,
count: u32,
}
impl Rendered for Sample {
fn render_human(&self, w: &mut dyn Write) -> io::Result<()> {
writeln!(w, "{} x{}", self.name, self.count)
}
}
fn render<T: Rendered>(mode: OutputMode, v: &T) -> String {
// Exercise the same rendering `emit` performs, into a buffer (so the test does not depend on
// the process stdout). Mirrors `emit`'s two arms.
let mut buf: Vec<u8> = Vec::new();
match mode {
OutputMode::Human => v.render_human(&mut buf).unwrap(),
OutputMode::Json => {
serde_json::to_writer_pretty(&mut buf, v).unwrap();
buf.push(b'\n');
}
}
String::from_utf8(buf).unwrap()
}
#[test]
fn human_and_json_carry_the_same_facts() {
let s = Sample {
name: "api".into(),
count: 3,
};
assert_eq!(render(OutputMode::Human, &s), "api x3\n");
let json = render(OutputMode::Json, &s);
assert!(json.contains("\"name\": \"api\""));
assert!(json.contains("\"count\": 3"));
assert!(json.ends_with('\n'));
}
}