From 19a41476796642c91e73b48dc012f47554d4d00e Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Tue, 26 May 2026 14:41:39 +0200 Subject: [PATCH 01/13] feat(cast): bring remaining `--json` outputs into `JsonEnvelope` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `print_scalar`, `print_list`, `print_tokens`, and `print_json_object` from local helpers in `args.rs` into `foundry_cli::json` so every cmd module can import them directly instead of inlining the if/else pattern. Replace all remaining inline `if shell::is_json() { sh_println!(...) }` patterns across cmd modules: - `estimate`, `find_block`, `call`, `mktx` → `print_scalar` - `txpool` (all variants), `rpc`, `vaddr/create` → `print_json_object` - `keychain list/inspect/doctor` → `print_json_object` / `print_json_success` - `erc20 name/symbol/decimals` → `print_scalar` - `erc20 balance/allowance/total_supply` → `print_json_success` (keep `format_uint_exp` for plain-mode display) - `Block` and `Tx` in `args.rs` → parse pre-formatted JSON string and wrap - `Selectors` in `args.rs` → new `SelectorInfo` struct, envelope-wraps array - `AbiEncodeEvent` in `args.rs` → new `EncodedEvent{topics,data}` struct - `interface` → wrap ABI array in envelope on stdout path Document explicitly unsupported commands with inline comments: `send` (N::ReceiptResponse lacks Serialize), `logs --subscribe` (streaming), `wallet` (follow-up), `upload-signature` (external API), `four-byte-calldata` (interactive). Add CLI tests: `selectors_json_envelope`, `abi_encode_event_json_envelope`. --- crates/cast/src/args.rs | 153 +++++++++++++--------------- crates/cast/src/cmd/call.rs | 3 +- crates/cast/src/cmd/erc20.rs | 29 ++---- crates/cast/src/cmd/estimate.rs | 5 +- crates/cast/src/cmd/find_block.rs | 3 +- crates/cast/src/cmd/interface.rs | 27 +++-- crates/cast/src/cmd/keychain.rs | 17 ++-- crates/cast/src/cmd/logs.rs | 2 + crates/cast/src/cmd/mktx.rs | 13 +-- crates/cast/src/cmd/rpc.rs | 11 +- crates/cast/src/cmd/send.rs | 3 +- crates/cast/src/cmd/txpool.rs | 9 +- crates/cast/src/cmd/vaddr/create.rs | 26 ++--- crates/cast/src/cmd/wallet/mod.rs | 2 + crates/cast/tests/cli/selectors.rs | 42 ++++++++ crates/cli/src/json.rs | 56 ++++++++++ 16 files changed, 243 insertions(+), 158 deletions(-) diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index 516f6ac01aaf7..a046b1f6b750f 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -5,7 +5,7 @@ use crate::{ traces::identifier::SignaturesIdentifier, tx::CastTxSender, }; -use alloy_dyn_abi::{DynSolValue, ErrorExt, EventExt}; +use alloy_dyn_abi::{ErrorExt, EventExt}; use alloy_eips::eip7702::SignedAuthorization; use alloy_ens::{ProviderEnsExt, namehash}; use alloy_network::Ethereum; @@ -16,12 +16,12 @@ use clap::CommandFactory; use clap_complete::generate; use eyre::{Result, WrapErr}; use foundry_cli::{ - json::print_json_success, + json::{print_json_object, print_list, print_scalar, print_tokens}, utils::{self, LoadConfig}, }; use foundry_common::{ abi::{get_error, get_event}, - fmt::{format_tokens, format_uint_exp, serialize_value_as_json}, + fmt::format_uint_exp, fs, provider::ProviderBuilder, selectors::{ @@ -241,11 +241,24 @@ pub async fn run_command(args: CastArgs) -> Result<()> { // envelope CastSubcommand::AbiEncodeEvent { sig, args } => { let log_data = SimpleCast::abi_encode_event(&sig, &args)?; - for (i, topic) in log_data.topics().iter().enumerate() { - sh_println!("[topic{}]: {}", i, topic)?; - } - if !log_data.data.is_empty() { - sh_println!("[data]: {}", hex::encode_prefixed(log_data.data))?; + if shell::is_json() { + #[derive(serde::Serialize)] + struct EncodedEvent { + topics: Vec, + data: String, + } + let encoded = EncodedEvent { + topics: log_data.topics().iter().map(|t| t.to_string()).collect(), + data: hex::encode_prefixed(&log_data.data), + }; + print_json_object(encoded)?; + } else { + for (i, topic) in log_data.topics().iter().enumerate() { + sh_println!("[topic{}]: {}", i, topic)?; + } + if !log_data.data.is_empty() { + sh_println!("[data]: {}", hex::encode_prefixed(log_data.data))?; + } } } CastSubcommand::DecodeCalldata { sig, calldata, file } => { @@ -430,8 +443,11 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .block(block.unwrap_or(BlockId::Number(Latest)), full, fields) .await? }; - // JSON: Output is already formatted by `Cast::block()` - sh_println!("{output}")?; + if shell::is_json() { + print_json_object(serde_json::from_str::(&output)?)?; + } else { + sh_println!("{output}")?; + } } CastSubcommand::BlockNumber { rpc, block } => { let config = rpc.load_config()?; @@ -504,14 +520,11 @@ pub async fn run_command(args: CastArgs) -> Result<()> { let out = SimpleCast::disassemble(&hex::decode(bytecode)?)?; print_scalar(out)?; } - // TODO(json): tabular multi-row output, needs array envelope CastSubcommand::Selectors { bytecode, resolve } => { let bytecode = stdin::unwrap_line(bytecode)?; let functions = SimpleCast::extract_functions(&bytecode)?; - let max_args_len = functions.iter().map(|r| r.1.len()).max().unwrap_or(0); - let max_mutability_len = functions.iter().map(|r| r.2.len()).max().unwrap_or(0); - let resolve_results = if resolve { + let resolve_results: Vec = if resolve { let selectors = functions .iter() .map(|&(selector, ..)| SelectorKind::Function(selector)) @@ -521,15 +534,41 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } else { vec![] }; - for (pos, (selector, arguments, state_mutability)) in functions.into_iter().enumerate() - { - if resolve { - let resolved = &resolve_results[pos]; - sh_println!( - "{selector}\t{arguments:max_args_len$}\t{state_mutability:max_mutability_len$}\t{resolved}" - )? - } else { - sh_println!("{selector}\t{arguments:max_args_len$}\t{state_mutability}")? + + if shell::is_json() { + #[derive(serde::Serialize)] + struct SelectorInfo { + selector: String, + arguments: String, + state_mutability: String, + #[serde(skip_serializing_if = "Option::is_none")] + resolved: Option, + } + let infos: Vec = functions + .into_iter() + .enumerate() + .map(|(pos, (selector, arguments, state_mutability))| SelectorInfo { + selector: selector.to_string(), + arguments, + state_mutability: state_mutability.to_string(), + resolved: resolve.then_some(resolve_results[pos].clone()), + }) + .collect(); + print_json_object(infos)?; + } else { + let max_args_len = functions.iter().map(|r| r.1.len()).max().unwrap_or(0); + let max_mutability_len = functions.iter().map(|r| r.2.len()).max().unwrap_or(0); + for (pos, (selector, arguments, state_mutability)) in + functions.into_iter().enumerate() + { + if resolve { + let resolved = &resolve_results[pos]; + sh_println!( + "{selector}\t{arguments:max_args_len$}\t{state_mutability:max_mutability_len$}\t{resolved}" + )? + } else { + sh_println!("{selector}\t{arguments:max_args_len$}\t{state_mutability}")? + } } } } @@ -659,8 +698,11 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .await? } }; - // JSON: Output is already formatted by `Cast::transaction()` - sh_println!("{output}")?; + if shell::is_json() { + print_json_object(serde_json::from_str::(&output)?)?; + } else { + sh_println!("{output}")?; + } } // 4Byte @@ -673,8 +715,8 @@ pub async fn run_command(args: CastArgs) -> Result<()> { print_list(&sigs)?; } - // TODO(json): multiple candidates + interactive selection + decoded tokens, needs - // structured envelope + // JSON envelope intentionally unsupported: output combines an interactive selector + // disambiguation step with decoded token output; no single stable shape exists. CastSubcommand::FourByteCalldata { calldata } => { let calldata = stdin::unwrap_line(calldata)?; @@ -715,7 +757,8 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } print_list(&sigs)?; } - // TODO(json): external API response printed via .describe(), needs structured envelope + // JSON envelope intentionally unsupported: output is a human-readable summary from an + // external selector registry API with no stable machine-readable schema. CastSubcommand::UploadSignature { signatures } => { let signatures = stdin::unwrap_vec(signatures)?; let ParsedSignatures { signatures, abis } = parse_signatures(signatures); @@ -885,60 +928,6 @@ pub async fn run_command(args: CastArgs) -> Result<()> { CastSubcommand::Trace(cmd) => cmd.run().await?, }; - /// Prints a scalar value: JSON envelope in `--json` mode, plain text otherwise. - fn print_scalar(value: impl serde::Serialize + std::fmt::Display) -> Result<()> { - if shell::is_json() { - print_json_success(value)?; - } else { - sh_println!("{value}")?; - } - Ok(()) - } - - /// Prints a list of serializable items: JSON envelope wrapping an array in `--json` mode, - /// one item per line otherwise. - fn print_list(items: &[T]) -> Result<()> { - if shell::is_json() { - print_json_success(items)?; - } else { - for item in items { - sh_println!("{item}")?; - } - } - Ok(()) - } - - /// Wraps a serializable object in the JSON envelope in `--json` mode, otherwise pretty-prints - /// it as JSON. Used for objects that have no human-readable `Display` format. - fn print_json_object(value: T) -> Result<()> { - if shell::is_json() { - print_json_success(value)?; - } else { - sh_println!("{}", serde_json::to_string_pretty(&value)?)?; - } - Ok(()) - } - - /// Prints slice of tokens using [`format_tokens`] or [`serialize_value_as_json`] depending - /// whether the shell is in JSON mode. - /// - /// This is included here to avoid a cyclic dependency between `fmt` and `common`. - fn print_tokens(tokens: &[DynSolValue]) -> Result<()> { - if shell::is_json() { - let values = tokens - .iter() - .cloned() - .map(|t| serialize_value_as_json(t, None)) - .collect::>>()?; - print_json_success(values)?; - } else { - format_tokens(tokens).for_each(|t| { - let _ = sh_println!("{t}"); - }); - } - Ok(()) - } - Ok(()) } diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index 05c51d6d1968c..057ca98e4203e 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -16,6 +16,7 @@ use alloy_rpc_types::{ use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_scalar, opts::{ChainValueParser, RpcOpts, TransactionOpts}, utils::{LoadConfig, TraceResult, parse_ether_value}, }; @@ -434,7 +435,7 @@ impl CallArgs { sh_warn!("Contract code is empty")?; } } - sh_println!("{}", response)?; + print_scalar(response)?; Ok(()) } diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs index bddc0939ed215..f49075ea2822d 100644 --- a/crates/cast/src/cmd/erc20.rs +++ b/crates/cast/src/cmd/erc20.rs @@ -15,6 +15,7 @@ use alloy_signer::{Signature, Signer}; use alloy_sol_types::sol; use clap::Parser; use foundry_cli::{ + json::{print_json_success, print_scalar}, opts::RpcOpts, utils::{LoadConfig, get_chain, get_provider}, }; @@ -504,9 +505,9 @@ impl Erc20Subcommand { .await?; if shell::is_json() { - sh_println!("{}", serde_json::to_string(&allowance.to_string())?)? + print_json_success(allowance.to_string())?; } else { - sh_println!("{}", format_uint_exp(allowance))? + sh_println!("{}", format_uint_exp(allowance))?; } } Self::Balance { token, owner, block, .. } => { @@ -521,9 +522,9 @@ impl Erc20Subcommand { .await?; if shell::is_json() { - sh_println!("{}", serde_json::to_string(&balance.to_string())?)? + print_json_success(balance.to_string())?; } else { - sh_println!("{}", format_uint_exp(balance))? + sh_println!("{}", format_uint_exp(balance))?; } } Self::Name { token, block, .. } => { @@ -536,11 +537,7 @@ impl Erc20Subcommand { .call() .await?; - if shell::is_json() { - sh_println!("{}", serde_json::to_string(&name)?)? - } else { - sh_println!("{}", name)? - } + print_scalar(name)?; } Self::Symbol { token, block, .. } => { let provider = get_provider(&config)?; @@ -552,11 +549,7 @@ impl Erc20Subcommand { .call() .await?; - if shell::is_json() { - sh_println!("{}", serde_json::to_string(&symbol)?)? - } else { - sh_println!("{}", symbol)? - } + print_scalar(symbol)?; } Self::Decimals { token, block, .. } => { let provider = get_provider(&config)?; @@ -567,11 +560,7 @@ impl Erc20Subcommand { .block(block.unwrap_or_default()) .call() .await?; - if shell::is_json() { - sh_println!("{}", serde_json::to_string(&decimals)?)? - } else { - sh_println!("{}", decimals)? - } + print_scalar(decimals)?; } Self::TotalSupply { token, block, .. } => { let provider = get_provider(&config)?; @@ -584,7 +573,7 @@ impl Erc20Subcommand { .await?; if shell::is_json() { - sh_println!("{}", serde_json::to_string(&total_supply.to_string())?)? + print_json_success(total_supply.to_string())?; } else { sh_println!("{}", format_uint_exp(total_supply))? } diff --git a/crates/cast/src/cmd/estimate.rs b/crates/cast/src/cmd/estimate.rs index 405266b60457a..5b6d042940ee7 100644 --- a/crates/cast/src/cmd/estimate.rs +++ b/crates/cast/src/cmd/estimate.rs @@ -7,6 +7,7 @@ use alloy_rpc_types::BlockId; use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_scalar, opts::{RpcOpts, TransactionOpts}, utils::{LoadConfig, parse_ether_value}, }; @@ -130,9 +131,9 @@ impl EstimateArgs { let gas_price_wei = provider.get_gas_price().await?; let cost = gas_price_wei * gas as u128; let cost_eth = cost as f64 / 1e18; - sh_println!("{cost_eth}")?; + print_scalar(cost_eth)?; } else { - sh_println!("{gas}")?; + print_scalar(gas)?; } Ok(()) } diff --git a/crates/cast/src/cmd/find_block.rs b/crates/cast/src/cmd/find_block.rs index 19aa9e3ff6abb..76705b059d80f 100644 --- a/crates/cast/src/cmd/find_block.rs +++ b/crates/cast/src/cmd/find_block.rs @@ -3,6 +3,7 @@ use alloy_provider::Provider; use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_scalar, opts::RpcOpts, utils::{self, LoadConfig}, }; @@ -79,7 +80,7 @@ impl FindBlockArgs { } matching_block.unwrap_or(low_block) }; - sh_println!("{block_num}")?; + print_scalar(block_num)?; Ok(()) } diff --git a/crates/cast/src/cmd/interface.rs b/crates/cast/src/cmd/interface.rs index 2e2051822dafc..d69baee17a4b8 100644 --- a/crates/cast/src/cmd/interface.rs +++ b/crates/cast/src/cmd/interface.rs @@ -85,27 +85,32 @@ impl InterfaceArgs { // Retrieve interfaces from the array of ABIs. let interfaces = get_interfaces(abis, config)?; - // Print result or write to file. - let res = if shell::is_json() { - // Format as JSON. - interfaces.iter().map(|iface| &iface.json_abi).format("\n").to_string() - } else { - // Format as Solidity. - format!( + if let Some(loc) = output_location { + // Write Solidity interface to file; JSON envelope doesn't apply to file output. + let res = format!( "// SPDX-License-Identifier: UNLICENSED\n\ pragma solidity {pragma};\n\n\ {}", interfaces.iter().map(|iface| &iface.source).format("\n") - ) - }; - - if let Some(loc) = output_location { + ); if let Some(parent) = loc.parent() { fs::create_dir_all(parent)?; } fs::write(&loc, res)?; sh_println!("Saved interface at {}", loc.display())?; + } else if shell::is_json() { + let abis = interfaces + .iter() + .map(|iface| serde_json::from_str::(&iface.json_abi)) + .collect::, _>>()?; + foundry_cli::json::print_json_object(abis)?; } else { + let res = format!( + "// SPDX-License-Identifier: UNLICENSED\n\ + pragma solidity {pragma};\n\n\ + {}", + interfaces.iter().map(|iface| &iface.source).format("\n") + ); sh_print!("{res}")?; } diff --git a/crates/cast/src/cmd/keychain.rs b/crates/cast/src/cmd/keychain.rs index 88f75aad8957b..487d317d4229c 100644 --- a/crates/cast/src/cmd/keychain.rs +++ b/crates/cast/src/cmd/keychain.rs @@ -13,6 +13,7 @@ use chrono::DateTime; use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_json_object, opts::{RpcOpts, TempoOpts, TransactionOpts}, utils::LoadConfig, }; @@ -732,7 +733,7 @@ fn run_list() -> Result<()> { if shell::is_json() { let entries: Vec<_> = keys_file.keys.iter().map(key_entry_to_json).collect(); - sh_println!("{}", serde_json::to_string_pretty(&entries)?)?; + print_json_object(entries)?; return Ok(()); } @@ -759,8 +760,8 @@ fn run_show(wallet_address: Address) -> Result<()> { } if shell::is_json() { - let json: Vec<_> = entries.iter().map(|e| key_entry_to_json(e)).collect(); - sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + let entries_json: Vec<_> = entries.iter().map(|e| key_entry_to_json(e)).collect(); + print_json_object(entries_json)?; return Ok(()); } @@ -882,7 +883,7 @@ async fn run_inspect( "limits": limits.iter().map(inspected_limit_to_json).collect::>(), "allowed_calls": allowed_calls_to_json(&allowed_calls), }); - sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + print_json_object(json)?; return Ok(()); } @@ -932,7 +933,7 @@ async fn run_check(wallet_address: Address, key_address: Address, rpc: RpcOpts) "enforce_limits": info.enforceLimits, "is_revoked": info.isRevoked, }); - sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + print_json_object(json)?; return Ok(()); } @@ -2549,8 +2550,7 @@ fn finalize_doctor(steps: Vec, context: DoctorContext) -> Result<()> }; if shell::is_json() { - let json = serde_json::json!({ - "schema_version": 1, + foundry_cli::json::print_json_success(serde_json::json!({ "context": context, "steps": steps, "status": status, @@ -2558,8 +2558,7 @@ fn finalize_doctor(steps: Vec, context: DoctorContext) -> Result<()> "healthy": healthy, "warning_count": warning_count, "failure_count": failure_count, - }); - sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + }))?; } else { for step in &steps { print_doctor_step(step)?; diff --git a/crates/cast/src/cmd/logs.rs b/crates/cast/src/cmd/logs.rs index 9ce651303e180..7cf9ec9c5917a 100644 --- a/crates/cast/src/cmd/logs.rs +++ b/crates/cast/src/cmd/logs.rs @@ -98,6 +98,8 @@ impl LogsArgs { return Ok(()); } + // JSON envelope intentionally unsupported for streaming: --subscribe emits NDJSON events + // continuously; a terminal JsonEnvelope is pointless. // FIXME: this is a hotfix for // currently the alloy `eth_subscribe` impl does not work with all transports, so we use // the builtin transport here for now diff --git a/crates/cast/src/cmd/mktx.rs b/crates/cast/src/cmd/mktx.rs index 67178cd093d7b..bb36b4d857099 100644 --- a/crates/cast/src/cmd/mktx.rs +++ b/crates/cast/src/cmd/mktx.rs @@ -11,10 +11,11 @@ use alloy_signer::{Signature, Signer}; use clap::Parser; use eyre::Result; use foundry_cli::{ + json::print_scalar, opts::{EthereumOpts, TransactionOpts}, utils::{LoadConfig, maybe_print_resolved_lane, resolve_lane}, }; -use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder}; +use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder, sh_println}; use std::{path::PathBuf, str::FromStr}; use tempo_alloy::TempoNetwork; @@ -146,7 +147,7 @@ impl MakeTxArgs { let hash = tx.compute_sponsor_hash(from).ok_or_else(|| { eyre::eyre!("This network does not support sponsored transactions") })?; - sh_println!("{hash:?}")?; + print_scalar(format!("{hash:?}"))?; return Ok(()); } @@ -179,7 +180,7 @@ impl MakeTxArgs { } let raw_tx = hex::encode_prefixed(tx.build_unsigned()?.encoded_for_signing()); - sh_println!("{raw_tx}")?; + print_scalar(raw_tx)?; return Ok(()); } @@ -193,7 +194,7 @@ impl MakeTxArgs { } let signed_tx = provider.sign_transaction(tx).await?; - sh_println!("{signed_tx}")?; + print_scalar(signed_tx)?; return Ok(()); } @@ -212,8 +213,8 @@ impl MakeTxArgs { let tx = tx.build(&EthereumWallet::new(signer)).await?; - let signed_tx = hex::encode(tx.encoded_2718()); - sh_println!("0x{signed_tx}")?; + let signed_tx = format!("0x{}", hex::encode(tx.encoded_2718())); + print_scalar(signed_tx)?; Ok(()) } diff --git a/crates/cast/src/cmd/rpc.rs b/crates/cast/src/cmd/rpc.rs index 8883c3fbb5be2..f8de7c4a5aa8e 100644 --- a/crates/cast/src/cmd/rpc.rs +++ b/crates/cast/src/cmd/rpc.rs @@ -1,8 +1,7 @@ use crate::Cast; use clap::Parser; use eyre::Result; -use foundry_cli::{opts::RpcOpts, utils, utils::LoadConfig}; -use foundry_common::shell; +use foundry_cli::{json::print_json_object, opts::RpcOpts, utils, utils::LoadConfig}; use itertools::Itertools; /// CLI arguments for `cast rpc`. @@ -55,12 +54,8 @@ impl RpcArgs { let provider = utils::get_provider(&config)?; let result = Cast::new(provider).rpc(&method, params).await?; - if shell::is_json() { - let result: serde_json::Value = serde_json::from_str(&result)?; - sh_println!("{}", serde_json::to_string_pretty(&result)?)?; - } else { - sh_println!("{}", result)?; - } + let result: serde_json::Value = serde_json::from_str(&result)?; + print_json_object(result)?; Ok(()) } } diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 54d9c02a6c14d..c7ebc90955f60 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -453,7 +453,8 @@ where let cast = CastTxSender::new(provider); if sync { - // Send transaction and wait for receipt synchronously + // JSON envelope not supported: N::ReceiptResponse is generic over Display but not + // Serialize; adding Serialize would ripple across all network-generic callers. let receipt = cast.send_sync(tx).await?; sh_println!("{receipt}")?; } else { diff --git a/crates/cast/src/cmd/txpool.rs b/crates/cast/src/cmd/txpool.rs index 2947295ff4d98..8ccaffda57987 100644 --- a/crates/cast/src/cmd/txpool.rs +++ b/crates/cast/src/cmd/txpool.rs @@ -2,6 +2,7 @@ use alloy_primitives::Address; use alloy_provider::ext::TxPoolApi; use clap::Parser; use foundry_cli::{ + json::print_json_object, opts::RpcOpts, utils::{self, LoadConfig}, }; @@ -41,25 +42,25 @@ impl TxPoolSubcommands { let config = args.load_config()?; let provider = utils::get_provider(&config)?; let content = provider.txpool_content().await?; - sh_println!("{}", serde_json::to_string_pretty(&content)?)?; + print_json_object(content)?; } Self::ContentFrom { from, args } => { let config = args.load_config()?; let provider = utils::get_provider(&config)?; let content = provider.txpool_content_from(from).await?; - sh_println!("{}", serde_json::to_string_pretty(&content)?)?; + print_json_object(content)?; } Self::Inspect { args } => { let config = args.load_config()?; let provider = utils::get_provider(&config)?; let inspect = provider.txpool_inspect().await?; - sh_println!("{}", serde_json::to_string_pretty(&inspect)?)?; + print_json_object(inspect)?; } Self::Status { args } => { let config = args.load_config()?; let provider = utils::get_provider(&config)?; let status = provider.txpool_status().await?; - sh_println!("{}", serde_json::to_string_pretty(&status)?)?; + print_json_object(status)?; } }; diff --git a/crates/cast/src/cmd/vaddr/create.rs b/crates/cast/src/cmd/vaddr/create.rs index 531be8169d9bb..9738613bbb91c 100644 --- a/crates/cast/src/cmd/vaddr/create.rs +++ b/crates/cast/src/cmd/vaddr/create.rs @@ -10,7 +10,10 @@ use crate::{ use alloy_primitives::{Address, B256}; use alloy_signer::Signer; use eyre::Result; -use foundry_cli::utils::{LoadConfig, get_chain}; +use foundry_cli::{ + json::print_json_object, + utils::{LoadConfig, get_chain}, +}; use foundry_common::{provider::ProviderBuilder, shell}; use rand::{RngCore, SeedableRng, rngs::StdRng}; use serde_json::json; @@ -96,18 +99,15 @@ pub(super) async fn run( } if shell::is_json() { - sh_println!( - "{}", - serde_json::to_string_pretty(&json!({ - "salt": format!("{}", output.salt), - "registration_hash": format!("{}", output.registration_hash), - "master_id": format!("{}", output.master_id), - "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ - "tag": format!("{tag}"), - "address": format!("{addr}"), - })).collect::>(), - }))? - )?; + print_json_object(json!({ + "salt": format!("{}", output.salt), + "registration_hash": format!("{}", output.registration_hash), + "master_id": format!("{}", output.master_id), + "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ + "tag": format!("{tag}"), + "address": format!("{addr}"), + })).collect::>(), + }))?; } else { sh_println!( "Salt: {} diff --git a/crates/cast/src/cmd/wallet/mod.rs b/crates/cast/src/cmd/wallet/mod.rs index b2378d8bfdc58..2775ab6de0c53 100644 --- a/crates/cast/src/cmd/wallet/mod.rs +++ b/crates/cast/src/cmd/wallet/mod.rs @@ -308,6 +308,8 @@ pub enum WalletSubcommands { } impl WalletSubcommands { + // NOTE: wallet subcommands use custom shell::is_json() branches with local output shapes. + // TODO: Full JsonEnvelope migration is deferred to a follow-up pass. pub async fn run(self) -> Result<()> { match self { Self::New { path, account_name, unsafe_password, number, password, force } => { diff --git a/crates/cast/tests/cli/selectors.rs b/crates/cast/tests/cli/selectors.rs index 2cb8d0e6ac123..e05aacf05262d 100644 --- a/crates/cast/tests/cli/selectors.rs +++ b/crates/cast/tests/cli/selectors.rs @@ -130,6 +130,48 @@ casttest!(flaky_upload_signatures, |_prj, cmd| { ); }); +casttest!(selectors_json_envelope, |_prj, cmd| { + // bytecode with one function: 0x2125b65b / uint32,address,uint224 / pure + let bytecode = "6080604052348015600e575f80fd5b50600436106026575f3560e01c80632125b65b14602a575b5f80fd5b603a6035366004603c565b505050565b005b5f805f60608486031215604d575f80fd5b833563ffffffff81168114605f575f80fd5b925060208401356001600160a01b03811681146079575f80fd5b915060408401356001600160e01b03811681146093575f80fd5b80915050925092509256"; + + cmd.args(["selectors", bytecode]).assert_success().stdout_eq(str![[r#" +0x2125b65b uint32,address,uint224 pure + +"#]]); + + cmd.args(["--json"]).assert_success().stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":[{"selector":"0x2125b65b","arguments":"uint32,address,uint224","state_mutability":"pure"}],"errors":[],"warnings":[]} + +"#]]); +}); + +casttest!(abi_encode_event_json_envelope, |_prj, cmd| { + cmd.args([ + "abi-encode-event", + "Transfer(address indexed,address indexed,uint256)", + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "1000", + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef +[topic1]: 0x0000000000000000000000000000000000000000000000000000000000000001 +[topic2]: 0x0000000000000000000000000000000000000000000000000000000000000002 +[data]: 0x00000000000000000000000000000000000000000000000000000000000003e8 + +"#]]); + + // --json must precede the subcommand because `args` uses allow_hyphen_values + cmd.cast_fuse() + .args(["--json", "abi-encode-event", "Transfer(address indexed,address indexed,uint256)", "0x0000000000000000000000000000000000000001", "0x0000000000000000000000000000000000000002", "1000"]) + .assert_success() + .stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":{"topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000002"],"data":"0x00000000000000000000000000000000000000000000000000000000000003e8"},"errors":[],"warnings":[]} + +"#]]); +}); + // tests cast can decode event with provided signature casttest!(event_decode_with_sig, |_prj, cmd| { cmd.args(["decode-event", "--sig", "MyEvent(uint256,address)", "0x000000000000000000000000000000000000000000000000000000000000004e0000000000000000000000000000000000000000000000000000000000d0004f"]).assert_success().stdout_eq(str![[r#" diff --git a/crates/cli/src/json.rs b/crates/cli/src/json.rs index 99eafe5ad372a..dff959d7e1880 100644 --- a/crates/cli/src/json.rs +++ b/crates/cli/src/json.rs @@ -1,6 +1,11 @@ //! Shared JSON output primitives for Foundry CLIs. +use alloy_dyn_abi::DynSolValue; use eyre::Result; +use foundry_common::{ + fmt::{format_tokens, serialize_value_as_json}, + sh_println, shell, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, to_string}; @@ -129,6 +134,19 @@ impl JsonMessage { } } +/// Prints a serializable object: envelope-wrapped in `--json` mode, pretty-printed otherwise. +/// +/// Use this for objects that have no human-readable `Display` format (block data, RPC responses, +/// etc.). +pub fn print_json_object(value: T) -> Result<()> { + if foundry_common::shell::is_json() { + print_json_success(value) + } else { + sh_println!("{}", serde_json::to_string_pretty(&value)?)?; + Ok(()) + } +} + /// Prints a value as compact, single-line JSON to stdout. /// /// The trailing newline makes this suitable for NDJSON streams when each call @@ -151,6 +169,44 @@ pub fn print_json_success_with_warnings( print_json(&JsonEnvelope::success_with_warnings(data, warnings)) } +/// Prints a scalar value: JSON envelope in `--json` mode, plain text otherwise. +pub fn print_scalar(value: impl Serialize + std::fmt::Display) -> Result<()> { + if shell::is_json() { + print_json_success(value) + } else { + sh_println!("{value}")?; + Ok(()) + } +} + +/// Prints a list of serializable items: JSON envelope wrapping an array in `--json` mode, +/// one item per line otherwise. +pub fn print_list(items: &[T]) -> Result<()> { + if shell::is_json() { + print_json_success(items) + } else { + for item in items { + sh_println!("{item}")?; + } + Ok(()) + } +} + +/// Prints ABI-decoded tokens: JSON envelope wrapping a value array in `--json` mode, +/// one formatted token per line otherwise. +pub fn print_tokens(tokens: &[DynSolValue]) -> Result<()> { + if shell::is_json() { + let values = tokens + .iter() + .cloned() + .map(|t| serialize_value_as_json(t, None)) + .collect::>>()?; + print_json_success(values) + } else { + format_tokens(tokens).try_for_each(|t| sh_println!("{t}")) + } +} + #[cfg(test)] mod tests { use super::*; From cb2b053b6df4144f740cafa730edda3d59a9a264 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Tue, 26 May 2026 16:01:43 +0200 Subject: [PATCH 02/13] fix: `interface -o ... --json` --- crates/cast/src/cmd/interface.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/cast/src/cmd/interface.rs b/crates/cast/src/cmd/interface.rs index d69baee17a4b8..2734bb21cc511 100644 --- a/crates/cast/src/cmd/interface.rs +++ b/crates/cast/src/cmd/interface.rs @@ -86,13 +86,16 @@ impl InterfaceArgs { let interfaces = get_interfaces(abis, config)?; if let Some(loc) = output_location { - // Write Solidity interface to file; JSON envelope doesn't apply to file output. - let res = format!( - "// SPDX-License-Identifier: UNLICENSED\n\ - pragma solidity {pragma};\n\n\ - {}", - interfaces.iter().map(|iface| &iface.source).format("\n") - ); + let res = if shell::is_json() { + interfaces.iter().map(|iface| &iface.json_abi).format("\n").to_string() + } else { + format!( + "// SPDX-License-Identifier: UNLICENSED\n\ + pragma solidity {pragma};\n\n\ + {}", + interfaces.iter().map(|iface| &iface.source).format("\n") + ) + }; if let Some(parent) = loc.parent() { fs::create_dir_all(parent)?; } From efd0cc116d2b27fb9428db4b00381438869b1387 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Tue, 26 May 2026 16:08:31 +0200 Subject: [PATCH 03/13] fix: `resolved` lazy eval --- crates/cast/src/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index a046b1f6b750f..c257161e9ed30 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -551,7 +551,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { selector: selector.to_string(), arguments, state_mutability: state_mutability.to_string(), - resolved: resolve.then_some(resolve_results[pos].clone()), + resolved: resolve.then(|| resolve_results[pos].clone()), }) .collect(); print_json_object(infos)?; From e44bc1a9c60ce1b68dea35e06f19ec731fc5e37c Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Wed, 27 May 2026 20:15:02 +0200 Subject: [PATCH 04/13] fix: tests --- crates/cast/tests/cli/erc20.rs | 22 +++++++------- crates/cast/tests/cli/main.rs | 55 +++++++++++++++++++--------------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/crates/cast/tests/cli/erc20.rs b/crates/cast/tests/cli/erc20.rs index 9119ba12f9b80..a03690f3c5166 100644 --- a/crates/cast/tests/cli/erc20.rs +++ b/crates/cast/tests/cli/erc20.rs @@ -557,7 +557,8 @@ forgetest_async!(erc20_balance_json, |prj, cmd| { .get_output() .stdout_lossy(); - let balance_str: String = serde_json::from_str(&output).expect("valid json string"); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let balance_str = v["data"].as_str().expect("string data"); let balance: U256 = balance_str.parse().unwrap(); assert_eq!(balance, U256::from(1_000_000_000_000_000_000_000u128)); }); @@ -599,7 +600,8 @@ forgetest_async!(erc20_allowance_json, |prj, cmd| { .get_output() .stdout_lossy(); - let allowance_str: String = serde_json::from_str(&output).expect("valid json string"); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let allowance_str = v["data"].as_str().expect("string data"); let allowance: U256 = allowance_str.parse().unwrap(); assert_eq!(allowance, approve_amount); }); @@ -616,8 +618,8 @@ forgetest_async!(erc20_metadata_json, |prj, cmd| { .assert_success() .get_output() .stdout_lossy(); - let name: String = serde_json::from_str(&output).expect("valid json string"); - assert_eq!(name, "Test Token"); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_str().expect("string data"), "Test Token"); // Test symbol with --json let output = cmd @@ -626,8 +628,8 @@ forgetest_async!(erc20_metadata_json, |prj, cmd| { .assert_success() .get_output() .stdout_lossy(); - let symbol: String = serde_json::from_str(&output).expect("valid json string"); - assert_eq!(symbol, "TEST"); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_str().expect("string data"), "TEST"); // Test decimals with --json let output = cmd @@ -636,8 +638,8 @@ forgetest_async!(erc20_metadata_json, |prj, cmd| { .assert_success() .get_output() .stdout_lossy(); - let decimals: u8 = output.trim().parse().expect("valid number"); - assert_eq!(decimals, 18); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_u64().expect("numeric data"), 18); // Test totalSupply with --json let output = cmd @@ -646,7 +648,7 @@ forgetest_async!(erc20_metadata_json, |prj, cmd| { .assert_success() .get_output() .stdout_lossy(); - let total_supply_str: String = serde_json::from_str(&output).expect("valid json string"); - let total_supply: U256 = total_supply_str.parse().unwrap(); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let total_supply: U256 = v["data"].as_str().expect("string data").parse().unwrap(); assert_eq!(total_supply, U256::from(1_000_000_000_000_000_000_000u128)); }); diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index 20c3ef9ca46fc..9be8250c80938 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -1495,25 +1495,31 @@ casttest!(rpc_format_as_json, |_prj, cmd| { cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_getBlockByNumber", "0x123", "false", "--json"]) .assert_json_stdout(str![[r#" { - "hash": "0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524", - "parentHash": "0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017", - "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "miner": "0xbb7b8287f3f0a933474a79eae42cbca977791171", - "stateRoot": "0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4", - "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "difficulty": "0x494433b31", - "number": "0x123", - "gasLimit": "0x1388", - "gasUsed": "0x0", - "timestamp": "0x55ba4564", - "extraData": "0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32", - "mixHash": "0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0", - "nonce": "0x29d6547c196e00e0", - "size": "0x220", - "uncles": [], - "transactions": [] + "schema_version": 1, + "success": true, + "data": { + "hash": "0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524", + "parentHash": "0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0xbb7b8287f3f0a933474a79eae42cbca977791171", + "stateRoot": "0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4", + "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x494433b31", + "number": "0x123", + "gasLimit": "0x1388", + "gasUsed": "0x0", + "timestamp": "0x55ba4564", + "extraData": "0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32", + "mixHash": "0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0", + "nonce": "0x29d6547c196e00e0", + "size": "0x220", + "uncles": [], + "transactions": [] + }, + "errors": [], + "warnings": [] } "#]]); @@ -2973,8 +2979,8 @@ casttest!(send_eip7702_multiple_auth, async |_prj, cmd| { .get_output() .stdout_lossy(); - let tx_json: serde_json::Value = serde_json::from_str(&tx_output).unwrap(); - let auth_list = tx_json["authorizationList"].as_array().unwrap(); + let tx_envelope: serde_json::Value = serde_json::from_str(&tx_output).unwrap(); + let auth_list = tx_envelope["data"]["authorizationList"].as_array().unwrap(); // Verify we have 2 authorizations assert_eq!(auth_list.len(), 2, "Expected 2 authorizations in the transaction"); @@ -4746,10 +4752,10 @@ casttest!(correct_json_serialization, |_prj, cmd| { [true, "0x0000000000000000000000000000000000000000000000000000000000000012"], [true, "0x0000000000000000000000000000000000000000000000000000000000000012"] ]]); - let decoded: serde_json::Value = + let envelope: serde_json::Value = serde_json::from_slice(&cmd.args(args).assert_success().get_output().stdout) .expect("not valid json"); - assert_eq!(decoded, expected_output); + assert_eq!(envelope["data"], expected_output); }); // Test cast abi-encode-event with indexed parameters @@ -5186,7 +5192,8 @@ casttest!(vaddr_create_json_output, |_prj, cmd| { .get_output() .stdout_lossy(); - let v: serde_json::Value = serde_json::from_str(out.trim()).expect("valid JSON"); + let envelope: serde_json::Value = serde_json::from_str(out.trim()).expect("valid JSON"); + let v = &envelope["data"]; assert_eq!(v["salt"], "0x0000000000000000000000000000000000000000000000003ee0a78d00000000"); assert_eq!( v["registration_hash"], From 660963ae3041c2e68745c4685aa267afc70c9a68 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Wed, 27 May 2026 20:58:02 +0200 Subject: [PATCH 05/13] fix: touch-up test --- crates/cast/src/cmd/call.rs | 8 ++++++-- crates/cast/src/lib.rs | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index 057ca98e4203e..7c357c782424e 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -16,7 +16,7 @@ use alloy_rpc_types::{ use clap::Parser; use eyre::Result; use foundry_cli::{ - json::print_scalar, + json::print_json_success, opts::{ChainValueParser, RpcOpts, TransactionOpts}, utils::{LoadConfig, TraceResult, parse_ether_value}, }; @@ -435,7 +435,11 @@ impl CallArgs { sh_warn!("Contract code is empty")?; } } - print_scalar(response)?; + if shell::is_json() { + print_json_success(serde_json::from_str::(&response)?)?; + } else { + sh_println!("{response}")?; + } Ok(()) } diff --git a/crates/cast/src/lib.rs b/crates/cast/src/lib.rs index 2b1b03486bf04..1fe1326624453 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -198,7 +198,7 @@ impl + Clone + Unpin, N: Network> Cast { .into_iter() .map(|value| serialize_value_as_json(value, None)) .collect::>>()?; - serde_json::to_string_pretty(&tokens).unwrap() + serde_json::to_string(&tokens)? } else { // seth compatible user-friendly return type conversions decoded.iter().map(format_token).collect::>().join("\n") From fad52f7b825b80f3fcc413d808b9a265e620b09a Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Wed, 27 May 2026 21:59:02 +0200 Subject: [PATCH 06/13] fix: tempo-check --- .github/scripts/tempo-check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/tempo-check.sh b/.github/scripts/tempo-check.sh index 0de494fb7823c..4a5b0cb316a73 100755 --- a/.github/scripts/tempo-check.sh +++ b/.github/scripts/tempo-check.sh @@ -212,7 +212,7 @@ echo "$KC_INFO" | grep -q "secp256k1" echo -e "\n=== CAST KEYCHAIN: KEY-INFO --json ===" KC_INFO_JSON=$(cast keychain info "$ADDR" "$KC_KEY_ADDR" --rpc-url "$ETH_RPC_URL" --json) -echo "$KC_INFO_JSON" | jq -e '.signatureType == "secp256k1"' +echo "$KC_INFO_JSON" | jq -e '.data.signatureType == "secp256k1"' echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH LIMIT ===" kc_limited_json="$(cast wallet new --json)" From c6808e529bd7defc7963913156973282491eb2ff Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Thu, 28 May 2026 10:19:31 +0200 Subject: [PATCH 07/13] fix: outscope cast call --- crates/cast/src/cmd/call.rs | 7 +------ crates/cast/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index 7c357c782424e..05c51d6d1968c 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -16,7 +16,6 @@ use alloy_rpc_types::{ use clap::Parser; use eyre::Result; use foundry_cli::{ - json::print_json_success, opts::{ChainValueParser, RpcOpts, TransactionOpts}, utils::{LoadConfig, TraceResult, parse_ether_value}, }; @@ -435,11 +434,7 @@ impl CallArgs { sh_warn!("Contract code is empty")?; } } - if shell::is_json() { - print_json_success(serde_json::from_str::(&response)?)?; - } else { - sh_println!("{response}")?; - } + sh_println!("{}", response)?; Ok(()) } diff --git a/crates/cast/src/lib.rs b/crates/cast/src/lib.rs index 1fe1326624453..2b1b03486bf04 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -198,7 +198,7 @@ impl + Clone + Unpin, N: Network> Cast { .into_iter() .map(|value| serialize_value_as_json(value, None)) .collect::>>()?; - serde_json::to_string(&tokens)? + serde_json::to_string_pretty(&tokens).unwrap() } else { // seth compatible user-friendly return type conversions decoded.iter().map(format_token).collect::>().join("\n") From 60cfa08a5e1fa79a9fe48fa24d027dd17d7357a2 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Thu, 28 May 2026 10:37:03 +0200 Subject: [PATCH 08/13] fix: block/tx/mktx --- crates/cast/src/args.rs | 18 +++++++++++------- crates/cast/src/cmd/mktx.rs | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index c257161e9ed30..51474116f7c81 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -34,6 +34,7 @@ use foundry_common::{ use foundry_evm_networks::NetworkVariant; #[cfg(feature = "optimism")] use op_alloy_network::Optimism; +use serde_json::Value; use std::time::Instant; use tempo_alloy::TempoNetwork; @@ -410,7 +411,9 @@ pub async fn run_command(args: CastArgs) -> Result<()> { CastSubcommand::Block { block, full, fields, raw, rpc, network } => { let config = rpc.load_config()?; // Can use either --raw or specify raw as a field - let output = if raw || fields.contains(&"raw".into()) { + let is_raw_block = raw || fields.contains(&"raw".into()); + let has_fields = !fields.is_empty(); + let output = if is_raw_block { match network { #[cfg(feature = "optimism")] Some(NetworkVariant::Optimism) => { @@ -443,10 +446,10 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .block(block.unwrap_or(BlockId::Number(Latest)), full, fields) .await? }; - if shell::is_json() { - print_json_object(serde_json::from_str::(&output)?)?; + if shell::is_json() && !is_raw_block && !has_fields { + print_json_object(serde_json::from_str::(&output)?)?; } else { - sh_println!("{output}")?; + print_scalar(output)?; } } CastSubcommand::BlockNumber { rpc, block } => { @@ -673,6 +676,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { CastSubcommand::Tx { tx_hash, from, nonce, field, raw, rpc, to_request, network } => { let config = rpc.load_config()?; // Can use either --raw or specify raw as a field + let has_field = field.is_some(); let is_raw = raw || field.as_ref().is_some_and(|f| f == "raw"); let output = match network { #[cfg(feature = "optimism")] @@ -698,10 +702,10 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .await? } }; - if shell::is_json() { - print_json_object(serde_json::from_str::(&output)?)?; + if shell::is_json() && !is_raw && !has_field { + print_json_object(serde_json::from_str::(&output)?)?; } else { - sh_println!("{output}")?; + print_scalar(output)?; } } diff --git a/crates/cast/src/cmd/mktx.rs b/crates/cast/src/cmd/mktx.rs index bb36b4d857099..415bd52c00a6b 100644 --- a/crates/cast/src/cmd/mktx.rs +++ b/crates/cast/src/cmd/mktx.rs @@ -15,7 +15,7 @@ use foundry_cli::{ opts::{EthereumOpts, TransactionOpts}, utils::{LoadConfig, maybe_print_resolved_lane, resolve_lane}, }; -use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder, sh_println}; +use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder}; use std::{path::PathBuf, str::FromStr}; use tempo_alloy::TempoNetwork; @@ -152,7 +152,7 @@ impl MakeTxArgs { } if let Some(ts) = expires_at { - sh_println!("Transaction expires at unix timestamp {ts}")?; + sh_status!("Transaction expires at unix timestamp {ts}")?; } if raw_unsigned { From 005312a7d6655231ba307163324a9945dd6208c6 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Thu, 28 May 2026 11:40:52 +0200 Subject: [PATCH 09/13] fix: revert cast call test update --- crates/cast/tests/cli/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index 9be8250c80938..dc3518136e26a 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -4752,10 +4752,10 @@ casttest!(correct_json_serialization, |_prj, cmd| { [true, "0x0000000000000000000000000000000000000000000000000000000000000012"], [true, "0x0000000000000000000000000000000000000000000000000000000000000012"] ]]); - let envelope: serde_json::Value = + let output: serde_json::Value = serde_json::from_slice(&cmd.args(args).assert_success().get_output().stdout) .expect("not valid json"); - assert_eq!(envelope["data"], expected_output); + assert_eq!(output, expected_output); }); // Test cast abi-encode-event with indexed parameters From 983078a8d05a8acd095eae455b1962faf91c536e Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Thu, 28 May 2026 18:32:47 +0200 Subject: [PATCH 10/13] fix: address figtracer review comments - args.rs: use get(pos).cloned() instead of indexing resolve_results to avoid panic in cast selectors --json when --resolve is not set - interface.rs: use sh_status! for "Saved interface at" so it goes to stderr in JSON mode, keeping stdout clean - keychain.rs: move is_json() branch before the is_empty() guard so cast --json keychain list emits [] instead of plain text when empty - vaddr/create.rs: defer JSON/text output until after register() succeeds to avoid emitting two stdout documents when registration fails --- crates/cast/src/args.rs | 2 +- crates/cast/src/cmd/interface.rs | 2 +- crates/cast/src/cmd/keychain.rs | 10 ++++---- crates/cast/src/cmd/vaddr/create.rs | 38 ++++++++++++++++++++++++----- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index 51474116f7c81..2ca9396eacc11 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -554,7 +554,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { selector: selector.to_string(), arguments, state_mutability: state_mutability.to_string(), - resolved: resolve.then(|| resolve_results[pos].clone()), + resolved: resolve_results.get(pos).cloned(), }) .collect(); print_json_object(infos)?; diff --git a/crates/cast/src/cmd/interface.rs b/crates/cast/src/cmd/interface.rs index 2734bb21cc511..4adaa7ca4fd15 100644 --- a/crates/cast/src/cmd/interface.rs +++ b/crates/cast/src/cmd/interface.rs @@ -100,7 +100,7 @@ impl InterfaceArgs { fs::create_dir_all(parent)?; } fs::write(&loc, res)?; - sh_println!("Saved interface at {}", loc.display())?; + sh_status!("Saved interface at {}", loc.display())?; } else if shell::is_json() { let abis = interfaces .iter() diff --git a/crates/cast/src/cmd/keychain.rs b/crates/cast/src/cmd/keychain.rs index 487d317d4229c..64aaed9581321 100644 --- a/crates/cast/src/cmd/keychain.rs +++ b/crates/cast/src/cmd/keychain.rs @@ -726,17 +726,17 @@ impl KeychainPolicySubcommand { fn run_list() -> Result<()> { let keys_file = load_keys_file()?; - if keys_file.keys.is_empty() { - sh_println!("No keys found in keys.toml.")?; - return Ok(()); - } - if shell::is_json() { let entries: Vec<_> = keys_file.keys.iter().map(key_entry_to_json).collect(); print_json_object(entries)?; return Ok(()); } + if keys_file.keys.is_empty() { + sh_err!("No keys found in keys.toml.")?; + return Ok(()); + } + for (i, entry) in keys_file.keys.iter().enumerate() { if i > 0 { sh_println!()?; diff --git a/crates/cast/src/cmd/vaddr/create.rs b/crates/cast/src/cmd/vaddr/create.rs index 9738613bbb91c..86eb233b55436 100644 --- a/crates/cast/src/cmd/vaddr/create.rs +++ b/crates/cast/src/cmd/vaddr/create.rs @@ -11,7 +11,7 @@ use alloy_primitives::{Address, B256}; use alloy_signer::Signer; use eyre::Result; use foundry_cli::{ - json::print_json_object, + json::{print_json_object, print_json_success}, utils::{LoadConfig, get_chain}, }; use foundry_common::{provider::ProviderBuilder, shell}; @@ -98,6 +98,36 @@ pub(super) async fn run( virtual_addresses.push((user_tag, vaddr)); } + if no_register { + if shell::is_json() { + print_json_success(json!({ + "salt": format!("{}", output.salt), + "registration_hash": format!("{}", output.registration_hash), + "master_id": format!("{}", output.master_id), + "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ + "tag": format!("{tag}"), + "address": format!("{addr}"), + })).collect::>(), + }))?; + } else { + sh_println!( + "Salt: {} +Registration hash: {} +Master ID: {}", + output.salt, + output.registration_hash, + output.master_id, + )?; + sh_println!("\nVirtual addresses:")?; + for (tag, vaddr) in &virtual_addresses { + sh_println!(" tag={tag} {vaddr}")?; + } + } + return Ok(()); + } + + register(owner, output.salt, send_tx, tx_opts).await?; + if shell::is_json() { print_json_object(json!({ "salt": format!("{}", output.salt), @@ -123,11 +153,7 @@ Master ID: {}", } } - if no_register { - return Ok(()); - } - - register(owner, output.salt, send_tx, tx_opts).await + Ok(()) } async fn register( From 53644dbebee626b702f1422f053f16a46c02961b Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Thu, 28 May 2026 20:33:30 +0200 Subject: [PATCH 11/13] fix: according Steven's review --- crates/cast/src/cmd/interface.rs | 6 ++- crates/cast/src/cmd/keychain.rs | 2 +- crates/cast/src/cmd/vaddr/create.rs | 65 +++++++++++------------------ 3 files changed, 30 insertions(+), 43 deletions(-) diff --git a/crates/cast/src/cmd/interface.rs b/crates/cast/src/cmd/interface.rs index 4adaa7ca4fd15..6c7f2817401f4 100644 --- a/crates/cast/src/cmd/interface.rs +++ b/crates/cast/src/cmd/interface.rs @@ -87,7 +87,11 @@ impl InterfaceArgs { if let Some(loc) = output_location { let res = if shell::is_json() { - interfaces.iter().map(|iface| &iface.json_abi).format("\n").to_string() + let abis = interfaces + .iter() + .map(|iface| serde_json::from_str::(&iface.json_abi)) + .collect::, _>>()?; + serde_json::to_string_pretty(&abis)? } else { format!( "// SPDX-License-Identifier: UNLICENSED\n\ diff --git a/crates/cast/src/cmd/keychain.rs b/crates/cast/src/cmd/keychain.rs index 64aaed9581321..e0dbe46f67941 100644 --- a/crates/cast/src/cmd/keychain.rs +++ b/crates/cast/src/cmd/keychain.rs @@ -733,7 +733,7 @@ fn run_list() -> Result<()> { } if keys_file.keys.is_empty() { - sh_err!("No keys found in keys.toml.")?; + sh_println!("No keys found in keys.toml.")?; return Ok(()); } diff --git a/crates/cast/src/cmd/vaddr/create.rs b/crates/cast/src/cmd/vaddr/create.rs index 86eb233b55436..9de8f84e2f961 100644 --- a/crates/cast/src/cmd/vaddr/create.rs +++ b/crates/cast/src/cmd/vaddr/create.rs @@ -98,47 +98,17 @@ pub(super) async fn run( virtual_addresses.push((user_tag, vaddr)); } - if no_register { - if shell::is_json() { - print_json_success(json!({ - "salt": format!("{}", output.salt), - "registration_hash": format!("{}", output.registration_hash), - "master_id": format!("{}", output.master_id), - "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ - "tag": format!("{tag}"), - "address": format!("{addr}"), - })).collect::>(), - }))?; - } else { - sh_println!( - "Salt: {} -Registration hash: {} -Master ID: {}", - output.salt, - output.registration_hash, - output.master_id, - )?; - sh_println!("\nVirtual addresses:")?; - for (tag, vaddr) in &virtual_addresses { - sh_println!(" tag={tag} {vaddr}")?; - } - } - return Ok(()); - } - - register(owner, output.salt, send_tx, tx_opts).await?; - - if shell::is_json() { - print_json_object(json!({ - "salt": format!("{}", output.salt), - "registration_hash": format!("{}", output.registration_hash), - "master_id": format!("{}", output.master_id), - "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ - "tag": format!("{tag}"), - "address": format!("{addr}"), - })).collect::>(), - }))?; - } else { + let payload = json!({ + "salt": format!("{}", output.salt), + "registration_hash": format!("{}", output.registration_hash), + "master_id": format!("{}", output.master_id), + "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ + "tag": format!("{tag}"), + "address": format!("{addr}"), + })).collect::>(), + }); + + if !shell::is_json() { sh_println!( "Salt: {} Registration hash: {} @@ -153,6 +123,19 @@ Master ID: {}", } } + if no_register { + if shell::is_json() { + print_json_success(payload)?; + } + return Ok(()); + } + + register(owner, output.salt, send_tx, tx_opts).await?; + + if shell::is_json() { + print_json_object(payload)?; + } + Ok(()) } From 8d603b5006c6dd1ddbec7b0c6e01f69fc1cc76f7 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Thu, 28 May 2026 21:06:03 +0200 Subject: [PATCH 12/13] clean-up --- crates/cast/src/cmd/vaddr/create.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cast/src/cmd/vaddr/create.rs b/crates/cast/src/cmd/vaddr/create.rs index 9de8f84e2f961..02fb609bb8ff3 100644 --- a/crates/cast/src/cmd/vaddr/create.rs +++ b/crates/cast/src/cmd/vaddr/create.rs @@ -11,7 +11,7 @@ use alloy_primitives::{Address, B256}; use alloy_signer::Signer; use eyre::Result; use foundry_cli::{ - json::{print_json_object, print_json_success}, + json::print_json_success, utils::{LoadConfig, get_chain}, }; use foundry_common::{provider::ProviderBuilder, shell}; @@ -133,7 +133,7 @@ Master ID: {}", register(owner, output.salt, send_tx, tx_opts).await?; if shell::is_json() { - print_json_object(payload)?; + print_json_success(payload)?; } Ok(()) From fefa3998173e1bc9641e1b295bdac1cb7c740078 Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Fri, 29 May 2026 18:17:07 +0200 Subject: [PATCH 13/13] fix(cast): preserve structured block and tx json fields --- crates/cast/src/args.rs | 17 +++------------ crates/cast/src/lib.rs | 15 +++++++++++-- crates/cast/tests/cli/keychain.rs | 21 ++++++++++++++++++ crates/cast/tests/cli/main.rs | 36 +++++++++++++++++++++++++++++++ crates/cli/src/json.rs | 14 ++++++++++++ 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index 2ca9396eacc11..7a8febb674f1f 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -16,7 +16,7 @@ use clap::CommandFactory; use clap_complete::generate; use eyre::{Result, WrapErr}; use foundry_cli::{ - json::{print_json_object, print_list, print_scalar, print_tokens}, + json::{print_json_object, print_json_value_or_scalar, print_list, print_scalar, print_tokens}, utils::{self, LoadConfig}, }; use foundry_common::{ @@ -34,7 +34,6 @@ use foundry_common::{ use foundry_evm_networks::NetworkVariant; #[cfg(feature = "optimism")] use op_alloy_network::Optimism; -use serde_json::Value; use std::time::Instant; use tempo_alloy::TempoNetwork; @@ -412,7 +411,6 @@ pub async fn run_command(args: CastArgs) -> Result<()> { let config = rpc.load_config()?; // Can use either --raw or specify raw as a field let is_raw_block = raw || fields.contains(&"raw".into()); - let has_fields = !fields.is_empty(); let output = if is_raw_block { match network { #[cfg(feature = "optimism")] @@ -446,11 +444,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .block(block.unwrap_or(BlockId::Number(Latest)), full, fields) .await? }; - if shell::is_json() && !is_raw_block && !has_fields { - print_json_object(serde_json::from_str::(&output)?)?; - } else { - print_scalar(output)?; - } + print_json_value_or_scalar(output)?; } CastSubcommand::BlockNumber { rpc, block } => { let config = rpc.load_config()?; @@ -676,7 +670,6 @@ pub async fn run_command(args: CastArgs) -> Result<()> { CastSubcommand::Tx { tx_hash, from, nonce, field, raw, rpc, to_request, network } => { let config = rpc.load_config()?; // Can use either --raw or specify raw as a field - let has_field = field.is_some(); let is_raw = raw || field.as_ref().is_some_and(|f| f == "raw"); let output = match network { #[cfg(feature = "optimism")] @@ -702,11 +695,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .await? } }; - if shell::is_json() && !is_raw && !has_field { - print_json_object(serde_json::from_str::(&output)?)?; - } else { - print_scalar(output)?; - } + print_json_value_or_scalar(output)?; } // 4Byte diff --git a/crates/cast/src/lib.rs b/crates/cast/src/lib.rs index 2b1b03486bf04..3caed66ada245 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -1132,8 +1132,19 @@ where let encoded = tx.as_ref().encoded_2718(); format!("0x{}", hex::encode(encoded)) } else if let Some(ref field) = field { - get_pretty_tx_attr::(&tx, field.as_str()) - .ok_or_else(|| eyre::eyre!("invalid tx field: {}", field.clone()))? + if let Some(value) = get_pretty_tx_attr::(&tx, field.as_str()) { + value + } else { + let tx_json = serde_json::to_value(&tx)?; + let value = tx_json + .get(field) + .ok_or_else(|| eyre::eyre!("invalid tx field: {}", field.clone()))?; + + match value { + serde_json::Value::String(value) => value.clone(), + value => value.to_string(), + } + } } else if shell::is_json() { // to_value first to sort json object keys serde_json::to_value(&tx)?.to_string() diff --git a/crates/cast/tests/cli/keychain.rs b/crates/cast/tests/cli/keychain.rs index 88e9e16983cc5..0f2c5ebf82ae7 100644 --- a/crates/cast/tests/cli/keychain.rs +++ b/crates/cast/tests/cli/keychain.rs @@ -74,3 +74,24 @@ casttest!(keychain_authorize_sponsor_hash_json_is_object, async |_prj, cmd| { assert!(hash.starts_with("0x"), "sponsor_hash should be 0x-prefixed, got: {hash}"); assert_eq!(hash.len(), 66, "sponsor_hash should be 32-byte hex (66 chars), got: {hash}"); }); + +casttest!(keychain_doctor_json_keeps_report_schema_version, async |_prj, cmd| { + let output = cmd + .args([ + "keychain", + "doctor", + accounts::ADDR2, + "--root-account", + accounts::ADDR1, + "--rpc-url", + "http://127.0.0.1:1", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast keychain doctor --json should emit valid JSON"); + assert_eq!(parsed["schema_version"], 1); +}); diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index e30ba22837c0b..95fe3ef85d628 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -193,6 +193,31 @@ casttest!(block_raw, |_prj, cmd| { ); }); +casttest!(block_json_wraps_raw_and_scalar_field_outputs, |_prj, cmd| { + let eth_rpc_url = next_http_rpc_endpoint(); + + let raw_output = cmd + .args(["block", "22934900", "--rpc-url", eth_rpc_url.as_str(), "--raw", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let raw_envelope: serde_json::Value = serde_json::from_str(raw_output.trim()).unwrap(); + assert_eq!(raw_envelope["schema_version"], 1); + assert!(raw_envelope["success"].as_bool().unwrap()); + assert!(raw_envelope["data"].as_str().unwrap().starts_with("0x")); + + let field_output = cmd + .cast_fuse() + .args(["block", "0x123", "--field", "number", "--rpc-url", eth_rpc_url.as_str(), "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let field_envelope: serde_json::Value = serde_json::from_str(field_output.trim()).unwrap(); + assert_eq!(field_envelope["schema_version"], 1); + assert!(field_envelope["success"].as_bool().unwrap()); + assert_eq!(field_envelope["data"], 291); +}); + casttest!(block_raw_tempo, |_prj, cmd| { // https://explore.tempo.xyz/block/8386710 let output = cmd @@ -2984,6 +3009,17 @@ casttest!(send_eip7702_multiple_auth, async |_prj, cmd| { // Verify we have 2 authorizations assert_eq!(auth_list.len(), 2, "Expected 2 authorizations in the transaction"); + + let field_output = cmd + .cast_fuse() + .args(["tx", tx_hash, "authorizationList", "--rpc-url", &endpoint, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + + let field_envelope: serde_json::Value = serde_json::from_str(field_output.trim()).unwrap(); + let field_auth_list = field_envelope["data"].as_array().unwrap(); + assert_eq!(field_auth_list.len(), 2, "Expected authorizationList field data to be an array"); }); // Test that multiple address-based authorizations are rejected diff --git a/crates/cli/src/json.rs b/crates/cli/src/json.rs index dff959d7e1880..7e98c39fff72c 100644 --- a/crates/cli/src/json.rs +++ b/crates/cli/src/json.rs @@ -169,6 +169,20 @@ pub fn print_json_success_with_warnings( print_json(&JsonEnvelope::success_with_warnings(data, warnings)) } +/// Prints command output that may already be JSON: parsed and envelope-wrapped in `--json` mode, +/// plain text otherwise. If the output is not valid JSON, it is wrapped as a scalar string. +pub fn print_json_value_or_scalar(value: impl AsRef + std::fmt::Display) -> Result<()> { + if shell::is_json() { + match serde_json::from_str::(value.as_ref()) { + Ok(value) => print_json_success(value), + Err(_) => print_json_success(value.as_ref()), + } + } else { + sh_println!("{value}")?; + Ok(()) + } +} + /// Prints a scalar value: JSON envelope in `--json` mode, plain text otherwise. pub fn print_scalar(value: impl Serialize + std::fmt::Display) -> Result<()> { if shell::is_json() {