Skip to main content

opcda_bridge_client/
output.rs

1//! Output format selection and rendering for client command results.
2//!
3//! Every command builds a `Vec` of row structs that derive both `Tabled`
4//! (for the default human-readable table) and `Serialize` (for `--output
5//! json`), then routes them through [`render`] — the single place that
6//! decides how a result becomes text. This mirrors `commands::render_tree`,
7//! which the codebase deliberately made return a `String` rather than print
8//! directly, so both paths are unit-testable without capturing stdout.
9
10use clap::ValueEnum;
11use serde::{Deserialize, Serialize};
12use tabled::{Table, Tabled};
13
14/// How a command's result is printed.
15#[derive(Copy, Clone, PartialEq, Eq, Debug, ValueEnum, Deserialize, Serialize)]
16#[serde(rename_all = "lowercase")]
17pub enum OutputFormat {
18    /// Human-readable table (default).
19    Table,
20    /// Pretty-printed JSON: a bare array of objects, one per row, using the
21    /// Rust field names as keys. This is the external contract for
22    /// programmatic consumers, so it must not change silently.
23    Json,
24}
25
26/// Render a command's rows in the requested format.
27pub fn render<T: Tabled + Serialize>(rows: Vec<T>, format: OutputFormat) -> anyhow::Result<String> {
28    match format {
29        OutputFormat::Table => Ok(Table::new(rows).to_string()),
30        OutputFormat::Json => Ok(serde_json::to_string_pretty(&rows)?),
31    }
32}
33
34/// Format an error for display, matching the requested output format.
35///
36/// The `Table` branch reproduces Rust's default `Termination` behavior for
37/// `Err` (`"Error: {:?}"`, the `Debug` chain anyhow builds), so plain-table
38/// users see the same error text as before this flag existed. The `Json`
39/// branch emits `{"error": "<message>"}` so scripted consumers never have
40/// to parse free-text stderr.
41pub fn format_error(err: &anyhow::Error, format: OutputFormat) -> String {
42    match format {
43        OutputFormat::Table => format!("Error: {err:?}"),
44        OutputFormat::Json => {
45            let payload = serde_json::json!({ "error": err.to_string() });
46            serde_json::to_string_pretty(&payload)
47                .unwrap_or_else(|_| format!("{{\"error\": \"{err}\"}}"))
48        }
49    }
50}
51
52/// Extract the output format from CLI-only sources: `--json` (which wins if
53/// both are set) or `--output` (which already folds in `OPC_BRIDGE_OUTPUT`
54/// via clap's `env` attribute). Returns `None` if neither was given, so the
55/// caller can still fall back to a config file.
56///
57/// Kept CLI-only (no config file access) because config loading can itself
58/// fail, and an error at that stage must still be reportable in *some*
59/// format before the config file's `output` key could ever be known.
60pub fn resolve_from_cli(cli: &crate::cli::Cli) -> Option<OutputFormat> {
61    if cli.json {
62        Some(OutputFormat::Json)
63    } else {
64        cli.output
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use proptest::prelude::*;
72
73    #[derive(Tabled, Serialize)]
74    struct Row {
75        name: String,
76        count: u32,
77    }
78
79    #[test]
80    fn test_render_table() {
81        let rows = vec![Row {
82            name: "a".into(),
83            count: 1,
84        }];
85        let out = render(rows, OutputFormat::Table).unwrap();
86        assert!(out.contains("name"));
87        assert!(out.contains("a"));
88    }
89
90    #[test]
91    fn test_render_json_shape_and_keys() {
92        let rows = vec![Row {
93            name: "a".into(),
94            count: 1,
95        }];
96        let out = render(rows, OutputFormat::Json).unwrap();
97        let value: serde_json::Value = serde_json::from_str(&out).unwrap();
98        let arr = value.as_array().unwrap();
99        assert_eq!(arr.len(), 1);
100        assert_eq!(arr[0]["name"], "a");
101        assert_eq!(arr[0]["count"], 1);
102    }
103
104    #[test]
105    fn test_render_json_empty_is_bare_array_not_null() {
106        let rows: Vec<Row> = vec![];
107        let out = render(rows, OutputFormat::Json).unwrap();
108        assert_eq!(out, "[]");
109    }
110
111    #[test]
112    fn test_render_json_is_pretty_printed() {
113        let rows = vec![Row {
114            name: "a".into(),
115            count: 1,
116        }];
117        let out = render(rows, OutputFormat::Json).unwrap();
118        assert!(out.contains('\n'), "expected multi-line pretty JSON");
119    }
120
121    #[test]
122    fn test_format_error_table_matches_debug_chain() {
123        let err = anyhow::anyhow!("boom");
124        let out = format_error(&err, OutputFormat::Table);
125        assert_eq!(out, format!("Error: {err:?}"));
126    }
127
128    #[test]
129    fn test_format_error_json_is_valid_json_with_message() {
130        let err = anyhow::anyhow!("boom");
131        let out = format_error(&err, OutputFormat::Json);
132        let value: serde_json::Value = serde_json::from_str(&out).unwrap();
133        assert_eq!(value["error"], "boom");
134    }
135
136    #[test]
137    fn test_output_format_deserialize_lowercase() {
138        #[derive(Deserialize)]
139        struct Wrapper {
140            output: OutputFormat,
141        }
142        let w: Wrapper = toml::from_str("output = \"json\"").unwrap();
143        assert_eq!(w.output, OutputFormat::Json);
144        let w: Wrapper = toml::from_str("output = \"table\"").unwrap();
145        assert_eq!(w.output, OutputFormat::Table);
146    }
147
148    proptest::proptest! {
149        #[test]
150        fn prop_json_errors_are_parseable(message in any::<String>()) {
151            let err = anyhow::anyhow!(message);
152            let rendered = format_error(&err, OutputFormat::Json);
153            prop_assert!(serde_json::from_str::<serde_json::Value>(&rendered).is_ok());
154        }
155    }
156}