rtb_cli/render.rs
1//! Output rendering — the seam every v0.4 ops subcommand uses to
2//! honour the global `--output text|json` flag.
3//!
4//! Two pieces:
5//!
6//! - [`OutputMode`] — clap-parseable enum (`Text` default, `Json`),
7//! declared once at the root of the clap tree with
8//! `clap::Arg::global(true)` so it propagates to every
9//! subcommand without per-leaf re-declaration.
10//! - [`output`] — generic helper that picks `tabled`-table or
11//! pretty-printed JSON based on `OutputMode`, prints to stdout
12//! with a trailing newline. Wraps [`rtb_tui::render_table`] and
13//! [`rtb_tui::render_json`] so every rendering site goes through
14//! one path.
15//!
16//! Subcommands that own their own clap subtree (`subcommand_passthrough
17//! = true`) re-parse the global flag from `std::env::args_os()` via
18//! [`OutputMode::from_args_os`].
19
20use clap::ValueEnum;
21use serde::Serialize;
22use tabled::Tabled;
23
24/// Output rendering mode for any subcommand that prints structured
25/// data.
26///
27/// Parsed from the global `--output text|json` flag declared at the
28/// root of the clap tree. Default is [`Self::Text`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
30pub enum OutputMode {
31 /// `tabled`-rendered text table. Default — matches an operator
32 /// running the command at a terminal.
33 #[default]
34 Text,
35 /// Pretty-printed JSON array. One row per element.
36 Json,
37}
38
39impl OutputMode {
40 /// Re-parse the global `--output` flag from
41 /// `std::env::args_os()`. Used by `subcommand_passthrough`
42 /// commands that re-parse their own arg subtree (`update`,
43 /// `docs`, `mcp`, the v0.4 `credentials` / `telemetry` /
44 /// `config` subtrees).
45 ///
46 /// Falls back to [`Self::default`] when the flag is absent or
47 /// unparseable. Recognises both `--output VALUE` and
48 /// `--output=VALUE`.
49 #[must_use]
50 pub fn from_args_os() -> Self {
51 Self::from_args(std::env::args_os())
52 }
53
54 fn from_args<I>(args: I) -> Self
55 where
56 I: IntoIterator<Item = std::ffi::OsString>,
57 {
58 let mut iter = args.into_iter();
59 while let Some(arg) = iter.next() {
60 let Some(s) = arg.to_str() else { continue };
61 if let Some(rest) = s.strip_prefix("--output=") {
62 return Self::parse_value(rest).unwrap_or_default();
63 }
64 if s == "--output" {
65 if let Some(next) = iter.next() {
66 if let Some(value) = next.to_str() {
67 return Self::parse_value(value).unwrap_or_default();
68 }
69 }
70 return Self::default();
71 }
72 }
73 Self::default()
74 }
75
76 fn parse_value(value: &str) -> Option<Self> {
77 match value {
78 "text" => Some(Self::Text),
79 "json" => Some(Self::Json),
80 _ => None,
81 }
82 }
83}
84
85/// Remove the global `--output` flag (and its value) from an
86/// `args_os()` vector before a `subcommand_passthrough` subtree
87/// re-parses with its own clap definition.
88///
89/// clap's outer `global = true` doesn't reach passthrough subtrees
90/// (their post-name tokens are captured as `trailing_var_arg`), so
91/// the inner parser would otherwise reject `--output` as unknown.
92/// This helper is idempotent — safe to call even when the flag is
93/// absent.
94#[must_use]
95pub fn strip_global_output(args: Vec<std::ffi::OsString>) -> Vec<std::ffi::OsString> {
96 let mut out = Vec::with_capacity(args.len());
97 let mut iter = args.into_iter();
98 while let Some(arg) = iter.next() {
99 let Some(s) = arg.to_str() else {
100 out.push(arg);
101 continue;
102 };
103 if s.starts_with("--output=") {
104 // Inline form — drop just this token.
105 continue;
106 }
107 if s == "--output" {
108 // Space-separated form — drop this token and the value.
109 // Defensive: if no value follows, just skip.
110 iter.next();
111 continue;
112 }
113 out.push(arg);
114 }
115 out
116}
117
118/// Render `rows` per `mode` and write to stdout. Wraps
119/// [`rtb_tui::render_table`] for [`OutputMode::Text`] and
120/// [`rtb_tui::render_json`] for [`OutputMode::Json`].
121///
122/// # Errors
123///
124/// Surfaces [`rtb_tui::RenderError`] in JSON mode — typical cause
125/// is a `Serialize` impl returning `Err`. Text mode is infallible.
126pub fn output<R>(mode: OutputMode, rows: &[R]) -> Result<(), rtb_tui::RenderError>
127where
128 R: Tabled + Serialize,
129{
130 let rendered = match mode {
131 OutputMode::Text => rtb_tui::render_table(rows),
132 OutputMode::Json => rtb_tui::render_json(rows)?,
133 };
134 print!("{rendered}");
135 Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140 use super::OutputMode;
141
142 fn parse(args: &[&str]) -> OutputMode {
143 OutputMode::from_args(args.iter().map(|s| std::ffi::OsString::from(*s)))
144 }
145
146 #[test]
147 fn default_when_flag_absent() {
148 assert_eq!(parse(&["mytool", "subcommand"]), OutputMode::Text);
149 }
150
151 #[test]
152 fn parses_space_separated_text() {
153 assert_eq!(parse(&["mytool", "--output", "text", "sub"]), OutputMode::Text);
154 }
155
156 #[test]
157 fn parses_space_separated_json() {
158 assert_eq!(parse(&["mytool", "--output", "json", "sub"]), OutputMode::Json);
159 }
160
161 #[test]
162 fn parses_eq_separated_json() {
163 assert_eq!(parse(&["mytool", "sub", "--output=json"]), OutputMode::Json);
164 }
165
166 #[test]
167 fn unknown_value_falls_back_to_default() {
168 assert_eq!(parse(&["mytool", "--output", "yaml"]), OutputMode::Text);
169 }
170
171 #[test]
172 fn missing_value_falls_back_to_default() {
173 assert_eq!(parse(&["mytool", "--output"]), OutputMode::Text);
174 }
175
176 #[test]
177 fn flag_after_subcommand_works() {
178 // clap's global=true accepts both positions — the helper
179 // mirrors that for re-parsing.
180 assert_eq!(parse(&["mytool", "subcmd", "--output", "json"]), OutputMode::Json);
181 }
182}