Skip to main content

soroban_cli/
output.rs

1//! Unified output abstraction for commands that support both human-readable and
2//! JSON output.
3//!
4//! Commands construct an [`Output`] from their `--output` format and the global
5//! `--quiet` flag, then route all output through it:
6//!
7//! * [`Output::readable`] runs its closure only in human-readable mode, handing
8//!   it a [`Print`] for progress/status messages and text rendering.
9//! * [`Output::json`] / [`Output::json_value`] run only in JSON mode and write
10//!   the final machine-readable result to stdout.
11//!
12//! `Output` adds no buffering of its own: each call writes straight to
13//! stdout/stderr as it happens rather than accumulating a buffer to flush at the
14//! end, so long-running operations don't hold their progress back. (The OS may
15//! still buffer a redirected or piped stream, as usual.)
16
17use serde::Serialize;
18
19use crate::print::Print;
20
21/// Build the canonical JSON error envelope for an error:
22/// `{ "error": { "type": …, "message": … } }`.
23///
24/// The inner object always carries a machine-readable `type` discriminator (so
25/// consumers can branch on the kind of failure without parsing the message) and
26/// at least a `message` string. When the error chain contains a JSON-RPC
27/// [`ErrorObject`](jsonrpsee_types::ErrorObjectOwned) (whose own `Display` is
28/// just its debug representation), its structured `{ code, message, data? }`
29/// form is merged in; every other error falls back to its `Display` as the
30/// `message`.
31///
32/// `error_type` is the caller-supplied discriminator (e.g. `"sac_not_deployed"`,
33/// or `"unknown"` when the error has no more specific type).
34#[must_use]
35pub fn error_json(err: &(dyn std::error::Error + 'static), error_type: &str) -> serde_json::Value {
36    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
37    while let Some(source) = current {
38        if let Some(object) = source.downcast_ref::<jsonrpsee_types::ErrorObjectOwned>() {
39            if let Ok(serde_json::Value::Object(mut map)) = serde_json::to_value(object) {
40                map.insert("type".to_string(), serde_json::json!(error_type));
41                return serde_json::json!({ "error": map });
42            }
43        }
44        current = source.source();
45    }
46
47    serde_json::json!({ "error": { "type": error_type, "message": err.to_string() } })
48}
49
50/// The format a command should render its output in.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum Format {
53    /// Human-readable output.
54    Readable,
55    /// Compact, single-line JSON.
56    Json,
57    /// Pretty-printed, multi-line JSON.
58    JsonFormatted,
59}
60
61impl Format {
62    /// Whether this format is one of the JSON variants.
63    #[must_use]
64    pub fn is_json(self) -> bool {
65        matches!(self, Format::Json | Format::JsonFormatted)
66    }
67}
68
69/// Wraps the output [`Format`] and a [`Print`] (carrying `--quiet`) so commands
70/// can emit human-readable and JSON output through a single value.
71#[derive(Clone)]
72pub struct Output {
73    format: Format,
74    print: Print,
75}
76
77impl Output {
78    /// Create an `Output` for the given format, honoring the global `quiet` flag
79    /// for human-readable output.
80    #[must_use]
81    pub fn new(format: Format, quiet: bool) -> Self {
82        Self {
83            format,
84            print: Print::new(quiet),
85        }
86    }
87
88    /// Whether the output format is JSON-based.
89    #[must_use]
90    pub fn is_json(&self) -> bool {
91        self.format.is_json()
92    }
93
94    /// Whether JSON output should be compact (single-line) rather than pretty.
95    #[must_use]
96    pub fn compact_json(&self) -> bool {
97        self.format == Format::Json
98    }
99
100    /// The underlying [`Print`]. Output written through this is gated only by
101    /// `--quiet`, not by the output format, so it is still emitted in JSON mode
102    /// (to stderr). Use it for diagnostics that remain relevant alongside JSON;
103    /// prefer [`Output::readable`] for human-readable output that should be
104    /// suppressed entirely in JSON mode.
105    #[must_use]
106    pub fn print(&self) -> &Print {
107        &self.print
108    }
109
110    /// Run `f` with a [`Print`] only when rendering human-readable output. A
111    /// no-op in JSON mode.
112    pub fn readable<F: FnOnce(&Print)>(&self, f: F) {
113        if !self.format.is_json() {
114            f(&self.print);
115        }
116    }
117
118    /// Run `f` only when rendering JSON output. A no-op in human-readable mode.
119    /// Use this for streaming or custom JSON (e.g. NDJSON); for a single value
120    /// prefer [`Output::json_value`].
121    pub fn json<F: FnOnce(&Output)>(&self, f: F) {
122        if self.format.is_json() {
123            f(self);
124        }
125    }
126
127    /// Serialize `value` to stdout as the final JSON result, compact or
128    /// pretty-printed depending on the format. A no-op in human-readable mode.
129    ///
130    /// # Errors
131    /// If `value` fails to serialize.
132    pub fn json_value<T: Serialize>(&self, value: &T) -> Result<(), serde_json::Error> {
133        match self.format {
134            Format::Json => println!("{}", serde_json::to_string(value)?),
135            Format::JsonFormatted => println!("{}", serde_json::to_string_pretty(value)?),
136            Format::Readable => {}
137        }
138        Ok(())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::cell::Cell;
146
147    #[test]
148    fn format_is_json() {
149        assert!(!Format::Readable.is_json());
150        assert!(Format::Json.is_json());
151        assert!(Format::JsonFormatted.is_json());
152    }
153
154    #[test]
155    fn readable_runs_only_in_readable_mode() {
156        for (format, expected) in [
157            (Format::Readable, true),
158            (Format::Json, false),
159            (Format::JsonFormatted, false),
160        ] {
161            let output = Output::new(format, false);
162            let ran = Cell::new(false);
163            output.readable(|_| ran.set(true));
164            assert_eq!(ran.get(), expected, "format: {format:?}");
165        }
166    }
167
168    #[test]
169    fn json_runs_only_in_json_mode() {
170        for (format, expected) in [
171            (Format::Readable, false),
172            (Format::Json, true),
173            (Format::JsonFormatted, true),
174        ] {
175            let output = Output::new(format, false);
176            let ran = Cell::new(false);
177            output.json(|_| ran.set(true));
178            assert_eq!(ran.get(), expected, "format: {format:?}");
179        }
180    }
181
182    #[test]
183    fn compact_json_only_for_compact_format() {
184        assert!(Output::new(Format::Json, false).compact_json());
185        assert!(!Output::new(Format::JsonFormatted, false).compact_json());
186        assert!(!Output::new(Format::Readable, false).compact_json());
187    }
188
189    #[test]
190    fn error_json_uses_structured_rpc_error_object() {
191        let obj = jsonrpsee_types::ErrorObject::owned(-32603, "DB is empty", None::<()>);
192        assert_eq!(
193            error_json(&obj, "unknown"),
194            serde_json::json!({ "error": { "type": "unknown", "code": -32603, "message": "DB is empty" } }),
195        );
196    }
197
198    #[test]
199    fn error_json_includes_specific_type() {
200        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "no sac");
201        assert_eq!(
202            error_json(&err, "sac_not_deployed"),
203            serde_json::json!({ "error": { "type": "sac_not_deployed", "message": "no sac" } }),
204        );
205    }
206
207    #[test]
208    fn error_json_walks_the_source_chain() {
209        #[derive(Debug)]
210        struct Wrapper(jsonrpsee_types::ErrorObjectOwned);
211        impl std::fmt::Display for Wrapper {
212            fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
213                write!(f, "wrapper")
214            }
215        }
216        impl std::error::Error for Wrapper {
217            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
218                Some(&self.0)
219            }
220        }
221
222        let obj = jsonrpsee_types::ErrorObject::owned(-32000, "boom", None::<()>);
223        assert_eq!(
224            error_json(&Wrapper(obj), "unknown"),
225            serde_json::json!({ "error": { "type": "unknown", "code": -32000, "message": "boom" } }),
226        );
227    }
228
229    #[test]
230    fn error_json_falls_back_to_message_for_other_errors() {
231        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "nope");
232        assert_eq!(
233            error_json(&err, "unknown"),
234            serde_json::json!({ "error": { "type": "unknown", "message": "nope" } }),
235        );
236    }
237}