1use 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
12pub const JSON_SCHEMA_VERSION: u32 = 1;
14
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21pub struct JsonEnvelope<T> {
22 pub schema_version: u32,
24 pub success: bool,
28 pub data: Option<T>,
30 pub errors: Vec<JsonMessage>,
32 pub warnings: Vec<JsonMessage>,
34}
35
36impl<T> JsonEnvelope<T> {
37 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 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 pub fn error(error: JsonMessage) -> Self {
63 Self::failure(vec![error])
64 }
65
66 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum JsonMessageLevel {
86 Error,
88 Warning,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct JsonMessage {
98 pub level: JsonMessageLevel,
100 pub code: String,
102 pub message: String,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub details: Option<Value>,
107}
108
109impl JsonMessage {
110 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 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 pub fn with_details(mut self, details: Value) -> Self {
132 self.details = Some(details);
133 self
134 }
135}
136
137pub 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
150pub 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#[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 pub(crate) ts: String,
173 #[serde(flatten)]
174 pub(crate) payload: T,
175}
176
177impl<T> StreamRecord<T> {
178 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
195pub 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
205pub fn print_json_success<T: Serialize>(data: T) -> Result<()> {
207 print_json(&JsonEnvelope::success(data))
208}
209
210pub 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
218pub 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
232pub 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
242pub 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
255pub 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 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}