Skip to main content

foundry_cli/
json.rs

1//! Shared JSON output primitives for Foundry CLIs.
2
3use alloy_dyn_abi::DynSolValue;
4use eyre::Result;
5use foundry_common::{
6    fmt::{format_tokens, serialize_value_as_json},
7    sh_println, shell,
8};
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, to_string};
11
12/// The current version of Foundry's top-level JSON output envelope.
13pub const JSON_SCHEMA_VERSION: u32 = 1;
14
15/// Stable top-level envelope for complete machine-readable command output.
16///
17/// This envelope represents a terminal command outcome. Long-running commands
18/// that stream intermediate records should use a separate event type and reserve
19/// this shape for final, complete results.
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21pub struct JsonEnvelope<T> {
22    /// Version of the envelope schema.
23    pub schema_version: u32,
24    /// Whether the command completed successfully.
25    ///
26    /// Only meaningful for a complete/terminal command outcome.
27    pub success: bool,
28    /// Command-specific payload.
29    pub data: Option<T>,
30    /// Structured errors emitted by the command.
31    pub errors: Vec<JsonMessage>,
32    /// Structured warnings emitted by the command.
33    pub warnings: Vec<JsonMessage>,
34}
35
36impl<T> JsonEnvelope<T> {
37    /// Creates a successful envelope with command-specific data.
38    pub const fn success(data: T) -> Self {
39        Self {
40            schema_version: JSON_SCHEMA_VERSION,
41            success: true,
42            data: Some(data),
43            errors: Vec::new(),
44            warnings: Vec::new(),
45        }
46    }
47
48    /// Creates a successful envelope with command-specific data and warnings.
49    pub const fn success_with_warnings(data: T, warnings: Vec<JsonMessage>) -> Self {
50        Self {
51            schema_version: JSON_SCHEMA_VERSION,
52            success: true,
53            data: Some(data),
54            errors: Vec::new(),
55            warnings,
56        }
57    }
58
59    /// Creates a failed envelope with command-specific data and structured errors.
60    pub const fn failure_with_data(data: T, errors: Vec<JsonMessage>) -> Self {
61        Self {
62            schema_version: JSON_SCHEMA_VERSION,
63            success: false,
64            data: Some(data),
65            errors,
66            warnings: Vec::new(),
67        }
68    }
69}
70
71/// A terminal JSON command failure that preserves command-specific output data.
72#[derive(Debug)]
73pub struct JsonError {
74    /// Command-specific payload to include in the failure envelope.
75    pub data: Value,
76    /// Structured errors emitted by the command.
77    pub errors: Vec<JsonMessage>,
78}
79
80impl JsonError {
81    /// Creates a terminal JSON failure with command-specific data and one error.
82    pub fn new(data: impl Serialize, error: JsonMessage) -> serde_json::Result<Self> {
83        Ok(Self { data: serde_json::to_value(data)?, errors: vec![error] })
84    }
85}
86
87impl std::fmt::Display for JsonError {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(self.errors.first().map_or("JSON command failed", |error| &error.message))
90    }
91}
92
93impl std::error::Error for JsonError {}
94
95impl JsonEnvelope<()> {
96    /// Creates a failed envelope with one structured error.
97    pub fn error(error: JsonMessage) -> Self {
98        Self::failure(vec![error])
99    }
100
101    /// Creates a failed envelope with structured errors.
102    pub const fn failure(errors: Vec<JsonMessage>) -> Self {
103        Self {
104            schema_version: JSON_SCHEMA_VERSION,
105            success: false,
106            data: None,
107            errors,
108            warnings: Vec::new(),
109        }
110    }
111}
112
113/// Severity level for a structured JSON diagnostic.
114///
115/// These levels classify diagnostics attached to an envelope. Progress,
116/// informational, and debug records should be modeled as command output data or
117/// stream events instead.
118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum JsonMessageLevel {
121    /// Error message.
122    Error,
123    /// Warning message.
124    Warning,
125}
126
127/// Structured diagnostic entry for JSON output.
128///
129/// Diagnostics describe errors and warnings associated with command output. They
130/// are not intended for progress, informational, or debug events.
131#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
132pub struct JsonMessage {
133    /// Diagnostic severity level.
134    pub level: JsonMessageLevel,
135    /// Stable machine-readable diagnostic code.
136    pub code: String,
137    /// Human-readable diagnostic message.
138    pub message: String,
139    /// Optional structured context for the diagnostic.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub details: Option<Value>,
142}
143
144impl JsonMessage {
145    /// Creates a structured error without details.
146    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
147        Self {
148            level: JsonMessageLevel::Error,
149            code: code.into(),
150            message: message.into(),
151            details: None,
152        }
153    }
154
155    /// Creates a structured warning without details.
156    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
157        Self {
158            level: JsonMessageLevel::Warning,
159            code: code.into(),
160            message: message.into(),
161            details: None,
162        }
163    }
164
165    /// Adds structured details to the diagnostic.
166    pub fn with_details(mut self, details: Value) -> Self {
167        self.details = Some(details);
168        self
169    }
170}
171
172/// Prints a serializable object: envelope-wrapped in `--json` mode, pretty-printed otherwise.
173///
174/// Use this for objects that have no human-readable `Display` format (block data, RPC responses,
175/// etc.).
176pub fn print_json_object<T: Serialize>(value: T) -> Result<()> {
177    if foundry_common::shell::is_json() {
178        print_json_success(value)
179    } else {
180        sh_println!("{}", serde_json::to_string_pretty(&value)?)?;
181        Ok(())
182    }
183}
184
185/// Prints a value as one compact JSON line on stdout and flushes.
186///
187/// Bypasses the shell verbosity layer so `--quiet` cannot suppress structured
188/// output the caller explicitly asked for.
189pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
190    let s = to_string(value)?;
191    let mut shell = foundry_common::shell::Shell::get();
192    let out = shell.out();
193    writeln!(out, "{s}")?;
194    out.flush()?;
195    Ok(())
196}
197
198/// Prints a successful JSON envelope to stdout.
199pub fn print_json_success<T: Serialize>(data: T) -> Result<()> {
200    print_json(&JsonEnvelope::success(data))
201}
202
203/// Prints a successful JSON envelope with warnings to stdout.
204pub fn print_json_success_with_warnings<T: Serialize>(
205    data: T,
206    warnings: Vec<JsonMessage>,
207) -> Result<()> {
208    print_json(&JsonEnvelope::success_with_warnings(data, warnings))
209}
210
211/// Prints command output that may already be JSON: parsed and envelope-wrapped in `--json` mode,
212/// plain text otherwise. If the output is not valid JSON, it is wrapped as a scalar string.
213pub fn print_json_value_or_scalar(value: impl AsRef<str> + std::fmt::Display) -> Result<()> {
214    if shell::is_json() {
215        match serde_json::from_str::<Value>(value.as_ref()) {
216            Ok(value) => print_json_success(value),
217            Err(_) => print_json_success(value.as_ref()),
218        }
219    } else {
220        sh_println!("{value}")?;
221        Ok(())
222    }
223}
224
225/// Prints a scalar value: JSON envelope in `--json` mode, plain text otherwise.
226pub fn print_scalar(value: impl Serialize + std::fmt::Display) -> Result<()> {
227    if shell::is_json() {
228        print_json_success(value)
229    } else {
230        sh_println!("{value}")?;
231        Ok(())
232    }
233}
234
235/// Prints a list of serializable items: JSON envelope wrapping an array in `--json` mode,
236/// one item per line otherwise.
237pub fn print_list<T: Serialize + std::fmt::Display>(items: &[T]) -> Result<()> {
238    if shell::is_json() {
239        print_json_success(items)
240    } else {
241        for item in items {
242            sh_println!("{item}")?;
243        }
244        Ok(())
245    }
246}
247
248/// Prints ABI-decoded tokens: JSON envelope wrapping a value array in `--json` mode,
249/// one formatted token per line otherwise.
250pub fn print_tokens(tokens: &[DynSolValue]) -> Result<()> {
251    if shell::is_json() {
252        let values = tokens
253            .iter()
254            .cloned()
255            .map(|t| serialize_value_as_json(t, None, true))
256            .collect::<Result<Vec<Value>>>()?;
257        print_json_success(values)
258    } else {
259        format_tokens(tokens).try_for_each(|t| sh_println!("{t}"))
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use serde_json::{json, to_value};
267
268    #[derive(Serialize)]
269    struct BuildData {
270        contracts: usize,
271    }
272
273    #[test]
274    fn success_envelope_serializes_all_top_level_fields() {
275        let envelope = JsonEnvelope::success(BuildData { contracts: 2 });
276
277        let json = to_string(&envelope).unwrap();
278
279        assert_eq!(
280            json,
281            r#"{"schema_version":1,"success":true,"data":{"contracts":2},"errors":[],"warnings":[]}"#
282        );
283    }
284
285    #[test]
286    fn warning_details_are_structured() {
287        let warning = JsonMessage::warning("compiler.remappings", "auto-detected remappings")
288            .with_details(json!({ "count": 3 }));
289        let envelope =
290            JsonEnvelope::success_with_warnings(BuildData { contracts: 1 }, vec![warning]);
291
292        let value = to_value(&envelope).unwrap();
293
294        assert_eq!(value["success"], true);
295        assert_eq!(value["warnings"][0]["level"], "warning");
296        assert_eq!(value["warnings"][0]["code"], "compiler.remappings");
297        assert_eq!(value["warnings"][0]["details"]["count"], 3);
298    }
299
300    #[test]
301    fn failure_envelope_serializes_null_data_and_structured_errors() {
302        let error = JsonMessage::error("config.invalid", "invalid foundry.toml")
303            .with_details(json!({ "path": "foundry.toml" }));
304        let envelope = JsonEnvelope::error(error);
305
306        let value = to_value(&envelope).unwrap();
307
308        assert_eq!(value["schema_version"], JSON_SCHEMA_VERSION);
309        assert_eq!(value["success"], false);
310        assert!(value["data"].is_null());
311        assert_eq!(value["errors"][0]["level"], "error");
312        assert_eq!(value["errors"][0]["code"], "config.invalid");
313        assert_eq!(value["errors"][0]["details"]["path"], "foundry.toml");
314        assert_eq!(value["warnings"], json!([]));
315    }
316
317    #[test]
318    fn failure_with_data_envelope_preserves_data() {
319        let envelope = JsonEnvelope::failure_with_data(
320            json!({ "compatible": false }),
321            vec![JsonMessage::error("compatibility.failed", "compatibility check failed")],
322        );
323        let value = to_value(envelope).unwrap();
324
325        assert_eq!(value["success"], false);
326        assert_eq!(value["data"]["compatible"], false);
327        assert_eq!(value["errors"][0]["code"], "compatibility.failed");
328    }
329}