Skip to main content

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}