Skip to main content

bhtune_cli/
output.rs

1//! Output format selection for the handful of commands that support `--output json`
2//! (`history list`/`history show` and `tune`/`simulate`'s final summary line — see
3//! AGENTS.md's `cli-automation` section for why exactly these three).
4//!
5//! Mirrors `opcda-bridge-client`'s `output.rs` in spirit (a small `OutputFormat` enum plus a
6//! `format_error` helper), but deliberately without its generic `render<T: Tabled +
7//! Serialize>` function: bhtune-cli's commands print bespoke, multi-section reports (`history
8//! show`'s run detail, `tune`'s calculated-PID listing), not flat single-row-type tables, so
9//! there is no one shared row shape to hand to a generic renderer. Each command instead
10//! builds its own JSON-serializable summary type and calls `serde_json::to_string_pretty`
11//! directly — see `commands::history`/`commands::tune`.
12
13use clap::ValueEnum;
14
15/// How a command's result is printed. Only commands documented in this module's doc comment
16/// honor this; every other command always prints its existing human-readable text.
17#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, ValueEnum)]
18pub enum OutputFormat {
19    /// Human-readable text (default).
20    #[default]
21    Table,
22    /// Pretty-printed JSON. This is the external contract for scripted/scheduled consumers,
23    /// so its shape must not change silently once shipped.
24    Json,
25}
26
27/// Format an error for display, matching the requested output format.
28///
29/// The `Table` branch reproduces this crate's existing plain-text error format
30/// (`"error: {err:#}"`, anyhow's flattened `Display` chain), so plain-text users see the same
31/// text as before `--output` existed. The `Json` branch emits `{"error": "<message>"}` so
32/// scripted consumers never have to parse free-text stderr.
33pub fn format_error(err: &anyhow::Error, format: OutputFormat) -> String {
34    match format {
35        OutputFormat::Table => format!("error: {err:#}"),
36        OutputFormat::Json => format_json_error(err, serde_json::to_string_pretty),
37    }
38}
39
40fn format_json_error<E>(
41    err: &anyhow::Error,
42    serialize: impl FnOnce(&serde_json::Value) -> Result<String, E>,
43) -> String
44where
45    E: std::fmt::Display,
46{
47    let payload = serde_json::json!({ "error": err.to_string() });
48    serialize(&payload).unwrap_or_else(|_| format!("{{\"error\": \"{err}\"}}"))
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn output_format_defaults_to_table() {
57        assert_eq!(OutputFormat::default(), OutputFormat::Table);
58    }
59
60    #[test]
61    fn format_error_table_matches_existing_plain_text_format() {
62        let err = anyhow::anyhow!("boom");
63        assert_eq!(
64            format_error(&err, OutputFormat::Table),
65            format!("error: {err:#}")
66        );
67    }
68
69    #[test]
70    fn format_error_json_is_valid_json_with_message() {
71        let err = anyhow::anyhow!("boom");
72        let out = format_error(&err, OutputFormat::Json);
73        let value: serde_json::Value = serde_json::from_str(&out).unwrap();
74        assert_eq!(value["error"], "boom");
75    }
76
77    #[test]
78    fn format_error_json_is_pretty_printed() {
79        let err = anyhow::anyhow!("boom");
80        let out = format_error(&err, OutputFormat::Json);
81        assert!(out.contains('\n'), "expected multi-line pretty JSON");
82    }
83
84    #[test]
85    fn format_error_preserves_anyhows_context_chain() {
86        let err = anyhow::anyhow!("root cause").context("higher-level context");
87        let table = format_error(&err, OutputFormat::Table);
88        assert!(table.contains("root cause"));
89        assert!(table.contains("higher-level context"));
90    }
91
92    #[test]
93    fn format_error_keeps_a_displayable_fallback_when_json_encoding_fails() {
94        let err = anyhow::anyhow!("boom");
95        let output = format_json_error(&err, |_| Err::<String, _>("injected failure"));
96        assert_eq!(output, r#"{"error": "boom"}"#);
97    }
98}