Skip to main content

opcda_bridge_client/
output.rs

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