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
60impl JsonEnvelope<()> {
61    /// Creates a failed envelope with one structured error.
62    pub fn error(error: JsonMessage) -> Self {
63        Self::failure(vec![error])
64    }
65
66    /// Creates a failed envelope with structured errors.
67    pub const fn failure(errors: Vec<JsonMessage>) -> Self {
68        Self {
69            schema_version: JSON_SCHEMA_VERSION,
70            success: false,
71            data: None,
72            errors,
73            warnings: Vec::new(),
74        }
75    }
76}
77
78/// Severity level for a structured JSON diagnostic.
79///
80/// These levels classify diagnostics attached to an envelope. Progress,
81/// informational, and debug records should be modeled as command output data or
82/// stream events instead.
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum JsonMessageLevel {
86    /// Error message.
87    Error,
88    /// Warning message.
89    Warning,
90}
91
92/// Structured diagnostic entry for JSON output.
93///
94/// Diagnostics describe errors and warnings associated with command output. They
95/// are not intended for progress, informational, or debug events.
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct JsonMessage {
98    /// Diagnostic severity level.
99    pub level: JsonMessageLevel,
100    /// Stable machine-readable diagnostic code.
101    pub code: String,
102    /// Human-readable diagnostic message.
103    pub message: String,
104    /// Optional structured context for the diagnostic.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub details: Option<Value>,
107}
108
109impl JsonMessage {
110    /// Creates a structured error without details.
111    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
112        Self {
113            level: JsonMessageLevel::Error,
114            code: code.into(),
115            message: message.into(),
116            details: None,
117        }
118    }
119
120    /// Creates a structured warning without details.
121    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
122        Self {
123            level: JsonMessageLevel::Warning,
124            code: code.into(),
125            message: message.into(),
126            details: None,
127        }
128    }
129
130    /// Adds structured details to the diagnostic.
131    pub fn with_details(mut self, details: Value) -> Self {
132        self.details = Some(details);
133        self
134    }
135}
136
137/// Prints a serializable object: envelope-wrapped in `--json` mode, pretty-printed otherwise.
138///
139/// Use this for objects that have no human-readable `Display` format (block data, RPC responses,
140/// etc.).
141pub fn print_json_object<T: Serialize>(value: T) -> Result<()> {
142    if foundry_common::shell::is_json() {
143        print_json_success(value)
144    } else {
145        sh_println!("{}", serde_json::to_string_pretty(&value)?)?;
146        Ok(())
147    }
148}
149
150/// Prints a value as one compact JSON line on stdout and flushes.
151///
152/// Bypasses the shell verbosity layer so `--quiet` cannot suppress structured
153/// output the caller explicitly asked for.
154pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
155    let s = to_string(value)?;
156    let mut shell = foundry_common::shell::Shell::get();
157    let out = shell.out();
158    writeln!(out, "{s}")?;
159    out.flush()?;
160    Ok(())
161}
162
163/// One NDJSON record on a long-running command's stream. The kind-specific
164/// `payload` is flattened into the same object alongside the spec fields
165/// (`schema_id`, `command_id`, `kind`, `ts`).
166#[derive(Clone, Debug, Serialize)]
167pub struct StreamRecord<T> {
168    pub(crate) schema_id: &'static str,
169    pub(crate) command_id: &'static str,
170    pub(crate) kind: &'static str,
171    /// RFC 3339 UTC with millisecond precision.
172    pub(crate) ts: String,
173    #[serde(flatten)]
174    pub(crate) payload: T,
175}
176
177impl<T> StreamRecord<T> {
178    /// Build a record stamped with the current UTC time.
179    pub fn new(
180        schema_id: &'static str,
181        command_id: &'static str,
182        kind: &'static str,
183        payload: T,
184    ) -> Self {
185        Self {
186            schema_id,
187            command_id,
188            kind,
189            ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
190            payload,
191        }
192    }
193}
194
195/// Emits a single NDJSON record on stdout for a streaming command.
196pub fn print_stream_record<T: Serialize>(
197    schema_id: &'static str,
198    command_id: &'static str,
199    kind: &'static str,
200    payload: T,
201) -> Result<()> {
202    print_json(&StreamRecord::new(schema_id, command_id, kind, payload))
203}
204
205/// Prints a successful JSON envelope to stdout.
206pub fn print_json_success<T: Serialize>(data: T) -> Result<()> {
207    print_json(&JsonEnvelope::success(data))
208}
209
210/// Prints a successful JSON envelope with warnings to stdout.
211pub fn print_json_success_with_warnings<T: Serialize>(
212    data: T,
213    warnings: Vec<JsonMessage>,
214) -> Result<()> {
215    print_json(&JsonEnvelope::success_with_warnings(data, warnings))
216}
217
218/// Prints command output that may already be JSON: parsed and envelope-wrapped in `--json` mode,
219/// plain text otherwise. If the output is not valid JSON, it is wrapped as a scalar string.
220pub fn print_json_value_or_scalar(value: impl AsRef<str> + std::fmt::Display) -> Result<()> {
221    if shell::is_json() {
222        match serde_json::from_str::<Value>(value.as_ref()) {
223            Ok(value) => print_json_success(value),
224            Err(_) => print_json_success(value.as_ref()),
225        }
226    } else {
227        sh_println!("{value}")?;
228        Ok(())
229    }
230}
231
232/// Prints a scalar value: JSON envelope in `--json` mode, plain text otherwise.
233pub fn print_scalar(value: impl Serialize + std::fmt::Display) -> Result<()> {
234    if shell::is_json() {
235        print_json_success(value)
236    } else {
237        sh_println!("{value}")?;
238        Ok(())
239    }
240}
241
242/// Prints a list of serializable items: JSON envelope wrapping an array in `--json` mode,
243/// one item per line otherwise.
244pub fn print_list<T: Serialize + std::fmt::Display>(items: &[T]) -> Result<()> {
245    if shell::is_json() {
246        print_json_success(items)
247    } else {
248        for item in items {
249            sh_println!("{item}")?;
250        }
251        Ok(())
252    }
253}
254
255/// Prints ABI-decoded tokens: JSON envelope wrapping a value array in `--json` mode,
256/// one formatted token per line otherwise.
257pub fn print_tokens(tokens: &[DynSolValue]) -> Result<()> {
258    if shell::is_json() {
259        let values = tokens
260            .iter()
261            .cloned()
262            .map(|t| serialize_value_as_json(t, None))
263            .collect::<Result<Vec<Value>>>()?;
264        print_json_success(values)
265    } else {
266        format_tokens(tokens).try_for_each(|t| sh_println!("{t}"))
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use serde_json::{json, to_value};
274
275    #[derive(Serialize)]
276    struct BuildData {
277        contracts: usize,
278    }
279
280    #[test]
281    fn success_envelope_serializes_all_top_level_fields() {
282        let envelope = JsonEnvelope::success(BuildData { contracts: 2 });
283
284        let json = to_string(&envelope).unwrap();
285
286        assert_eq!(
287            json,
288            r#"{"schema_version":1,"success":true,"data":{"contracts":2},"errors":[],"warnings":[]}"#
289        );
290    }
291
292    #[test]
293    fn warning_details_are_structured() {
294        let warning = JsonMessage::warning("compiler.remappings", "auto-detected remappings")
295            .with_details(json!({ "count": 3 }));
296        let envelope =
297            JsonEnvelope::success_with_warnings(BuildData { contracts: 1 }, vec![warning]);
298
299        let value = to_value(&envelope).unwrap();
300
301        assert_eq!(value["success"], true);
302        assert_eq!(value["warnings"][0]["level"], "warning");
303        assert_eq!(value["warnings"][0]["code"], "compiler.remappings");
304        assert_eq!(value["warnings"][0]["details"]["count"], 3);
305    }
306
307    #[test]
308    fn stream_record_includes_required_fields_and_flattens_payload() {
309        #[derive(Serialize)]
310        struct TestEvent {
311            contract: String,
312            passed: usize,
313        }
314        let payload = TestEvent { contract: "Counter.t.sol:CounterTest".into(), passed: 3 };
315        let rec = StreamRecord::new(
316            "foundry:forge.test.event@v1",
317            "forge.test",
318            "suite_finished",
319            payload,
320        );
321        let json = to_string(&rec).unwrap();
322        // Compact, no pretty-printing.
323        assert!(!json.contains('\n'), "expected compact json, got: {json}");
324        let v = serde_json::from_str::<Value>(&json).unwrap();
325        assert_eq!(v["schema_id"], "foundry:forge.test.event@v1");
326        assert_eq!(v["command_id"], "forge.test");
327        assert_eq!(v["kind"], "suite_finished");
328        assert!(v["ts"].is_string());
329        assert_eq!(v["contract"], "Counter.t.sol:CounterTest");
330        assert_eq!(v["passed"], 3);
331    }
332
333    #[test]
334    fn failure_envelope_serializes_null_data_and_structured_errors() {
335        let error = JsonMessage::error("config.invalid", "invalid foundry.toml")
336            .with_details(json!({ "path": "foundry.toml" }));
337        let envelope = JsonEnvelope::error(error);
338
339        let value = to_value(&envelope).unwrap();
340
341        assert_eq!(value["schema_version"], JSON_SCHEMA_VERSION);
342        assert_eq!(value["success"], false);
343        assert!(value["data"].is_null());
344        assert_eq!(value["errors"][0]["level"], "error");
345        assert_eq!(value["errors"][0]["code"], "config.invalid");
346        assert_eq!(value["errors"][0]["details"]["path"], "foundry.toml");
347        assert_eq!(value["warnings"], json!([]));
348    }
349}