From 68635c9a591c240c5574b971ebbddcd67e626a5d Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Thu, 30 Apr 2026 00:20:58 +0000 Subject: [PATCH 1/5] feat(cli): add `serve` subcommand for browsing decoded txs in a web UI Restructures `parser_cli` into a true subcommand model: parser_cli decode --chain X -t @file # was: parser_cli --chain X -t @file parser_cli serve --chain X --dir ./txs # new `serve` (gated behind a `serve` Cargo feature) scans a directory of raw transaction files at startup, decodes each one against the requested chain, and serves a small read-only web UI on `127.0.0.1:`: - GET / HTML page, one collapsible
block per file with the pretty JSON inline - GET /api/file?path= JSON for a single entry Hand-rolled HTML, no JS, no template engine. Server is single-thread tokio (axum 0.8). Both `axum` and `tokio` are already transitive via `tonic` in the lock file, so the resolved dep tree gains nothing by adding them as direct deps for `parser_cli`. The default build (no `--features serve`) is unchanged: axum and tokio are not pulled in, the `Serve` variant of the subcommand enum is gated out, and `parser_app`/enclave callers see no new deps. Existing fixtures get a leading `decode` line to match the new grammar. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 5 +- src/Cargo.lock | 2 + src/parser/cli/Cargo.toml | 4 + src/parser/cli/src/cli.rs | 189 ++++--- src/parser/cli/src/lib.rs | 17 +- src/parser/cli/src/serve.rs | 461 ++++++++++++++++++ src/parser/cli/tests/cli_test.rs | 94 ++++ .../tests/fixtures/ethereum-from-file.input | 1 + .../cli/tests/fixtures/ethereum-json.input | 1 + .../cli/tests/fixtures/solana-json.input | 1 + .../cli/tests/fixtures/solana-text.input | 1 + 11 files changed, 710 insertions(+), 66 deletions(-) create mode 100644 src/parser/cli/src/serve.rs diff --git a/CLAUDE.md b/CLAUDE.md index 4277fdd3..a55f3483 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,10 @@ cargo test -p visualsign-ethereum test_name Parse a transaction locally: ```bash -cargo run --bin parser_cli -- --chain ethereum --network ETHEREUM_MAINNET --output human -t +cargo run --bin parser_cli -- decode --chain ethereum --network ETHEREUM_MAINNET --output human -t + +# Browse a directory of raw-tx files in a local web UI (feature-gated): +cargo run --bin parser_cli --features serve -- serve --chain ethereum --network ETHEREUM_MAINNET --dir ./txs ``` CI requires: codegen produces no diff, clippy passes with `-D warnings`, all tests pass. Protoc v21.4. diff --git a/src/Cargo.lock b/src/Cargo.lock index e57b6e48..9abda59f 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -6548,6 +6548,7 @@ dependencies = [ name = "parser_cli" version = "0.1.0" dependencies = [ + "axum 0.8.8", "bincode", "borsh 1.6.0", "bs58 0.5.1", @@ -6557,6 +6558,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "similar", + "tokio", "tracing", "tracing-bunyan-formatter", "tracing-log 0.2.0", diff --git a/src/parser/cli/Cargo.toml b/src/parser/cli/Cargo.toml index 8001501a..ec574e4d 100644 --- a/src/parser/cli/Cargo.toml +++ b/src/parser/cli/Cargo.toml @@ -9,6 +9,7 @@ default = ["solana", "ethereum", "diagnostics"] solana = ["dep:visualsign-solana"] ethereum = ["dep:visualsign-ethereum"] diagnostics = ["visualsign/diagnostics", "visualsign-solana?/diagnostics"] +serve = ["dep:axum", "dep:tokio"] [dependencies] tracing = { workspace = true } @@ -32,5 +33,8 @@ bs58 = { version = "0.5.1", default-features = false } sha2 = { version = "0.10.8", default-features = false } similar = "2.7.0" +axum = { version = "0.8", optional = true, default-features = false, features = ["http1", "tokio", "query", "json"] } +tokio = { workspace = true, optional = true, features = ["rt", "macros", "net"] } + [lints] workspace = true diff --git a/src/parser/cli/src/cli.rs b/src/parser/cli/src/cli.rs index 01dfdc66..c19eec1a 100644 --- a/src/parser/cli/src/cli.rs +++ b/src/parser/cli/src/cli.rs @@ -1,5 +1,5 @@ use crate::chains::parse_chain; -use clap::Parser; +use clap::{Parser, Subcommand}; use visualsign::registry::{Chain, TransactionConverterRegistry}; use visualsign::vsptrait::{DeveloperConfig, VisualSignOptions}; use visualsign::{SignablePayload, SignablePayloadField}; @@ -9,6 +9,21 @@ use visualsign::{SignablePayload, SignablePayloadField}; #[command(version = env!("VERSION"))] #[command(about = "Converts raw transactions to visual signing properties")] pub(crate) struct Args { + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + /// Decode a single transaction and print it. + Decode(DecodeArgs), + /// Serve a directory of raw-transaction files via a local web UI. + #[cfg(feature = "serve")] + Serve(crate::serve::ServeArgs), +} + +#[derive(clap::Args, Debug)] +pub(crate) struct DecodeArgs { #[arg(short, long, help = "Chain type")] pub(crate) chain: String, @@ -19,16 +34,16 @@ pub(crate) struct Args { help = "Raw transaction string. Prefix with '@' to read from a file \ (e.g. '@/path/to/tx.hex'), or use '@-' to read from stdin." )] - transaction: String, + pub(crate) transaction: String, #[arg(short, long, default_value = "text", help = "Output format")] - output: OutputFormat, + pub(crate) output: OutputFormat, #[arg( long, help = "Show only condensed view (what hardware wallets display)" )] - condensed_only: bool, + pub(crate) condensed_only: bool, #[arg( long, @@ -49,8 +64,19 @@ pub(crate) struct Args { pub(crate) solana: crate::solana::SolanaArgs, } +impl DecodeArgs { + pub(crate) fn plugin_args(&self) -> crate::PluginArgs { + crate::PluginArgs { + #[cfg(feature = "ethereum")] + ethereum: self.ethereum.clone(), + #[cfg(feature = "solana")] + solana: self.solana.clone(), + } + } +} + #[derive(Debug, Clone, Copy)] -enum OutputFormat { +pub(crate) enum OutputFormat { Text, Json, Human, @@ -257,70 +283,107 @@ fn parse_and_display( Ok(()) } -/// CLI entry point. -pub struct Cli; -impl Cli { - /// Parse arguments and run the transaction visualizer. - pub fn execute() -> Result<(), String> { - let args = Args::parse(); - let chain = parse_chain(&args.chain); - let plugins = crate::build_plugins(&args); +/// Resolves chain + plugins + per-chain metadata, returning a ready-to-use registry. +/// +/// Shared by `decode` and `serve`. On invalid chain or metadata error, returns +/// an `Err(String)` with a user-facing message — the caller is responsible for +/// printing it and exiting non-zero. +pub(crate) struct Runtime { + pub registry: TransactionConverterRegistry, + pub options: VisualSignOptions, +} - let mut registry = TransactionConverterRegistry::new(); - for plugin in &plugins { - plugin.register(&mut registry); - } +pub(crate) fn prepare_runtime( + chain_str: &str, + network: Option, + plugin_args: &crate::PluginArgs, +) -> Result { + let chain = parse_chain(chain_str); + let plugins = crate::build_plugins(plugin_args); - let plugin = plugins.iter().find(|p| p.chain() == chain).ok_or_else(|| { - let supported: Vec = plugins - .iter() - .map(|p| p.chain().as_str().to_lowercase()) - .collect(); - let supported_str = if supported.is_empty() { - "none".to_string() - } else { - supported.join(", ") - }; - if chain == Chain::Unspecified { - format!( - "unrecognized chain '{}'.\nSupported chains: {supported_str}", - args.chain, - ) - } else { - format!( - "chain '{}' is not supported by this CLI build.\n\ - Supported chains: {supported_str}", - args.chain, - ) - } - })?; + let mut registry = TransactionConverterRegistry::new(); + for plugin in &plugins { + plugin.register(&mut registry); + } - let chain_metadata = plugin.create_metadata(args.network.clone())?; + let plugin = plugins.iter().find(|p| p.chain() == chain); - let options = VisualSignOptions { - decode_transfers: true, - transaction_name: None, - metadata: chain_metadata, - developer_config: Some(DeveloperConfig { - allow_signed_transactions: true, - }), + let Some(plugin) = plugin else { + let supported: Vec = plugins + .iter() + .map(|p| p.chain().as_str().to_lowercase()) + .collect(); + let supported_str = if supported.is_empty() { + "none".to_string() + } else { + supported.join(", ") }; + if chain == Chain::Unspecified { + return Err(format!( + "unrecognized chain '{chain_str}'.\nSupported chains: {supported_str}" + )); + } + return Err(format!( + "chain '{chain_str}' is not supported by this CLI build.\nSupported chains: {supported_str}" + )); + }; - let raw_tx = match crate::tx_input::resolve_transaction_input(&args.transaction) { - Ok(tx) => tx, - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - }; + let chain_metadata = plugin.create_metadata(network)?; + + let options = VisualSignOptions { + decode_transfers: true, + transaction_name: None, + metadata: chain_metadata, + developer_config: Some(DeveloperConfig { + allow_signed_transactions: true, + }), + }; + + Ok(Runtime { registry, options }) +} + +fn execute_decode(args: &DecodeArgs) { + let plugin_args = args.plugin_args(); + let runtime = match prepare_runtime(&args.chain, args.network.clone(), &plugin_args) { + Ok(r) => r, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + let raw_tx = match crate::tx_input::resolve_transaction_input(&args.transaction) { + Ok(tx) => tx, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + if let Err(e) = parse_and_display( + &args.chain, + &raw_tx, + &runtime.registry, + runtime.options, + args.output, + args.condensed_only, + ) { + eprintln!("Error: {e}"); + std::process::exit(1); + } +} - parse_and_display( - &args.chain, - &raw_tx, - ®istry, - options, - args.output, - args.condensed_only, - ) +/// CLI entry point. +pub struct Cli; +impl Cli { + /// Parse arguments and run the transaction visualizer. + pub fn execute() -> Result<(), String> { + let args = Args::parse(); + match &args.command { + Command::Decode(a) => execute_decode(a), + #[cfg(feature = "serve")] + Command::Serve(a) => crate::serve::execute_serve(a), + } + Ok(()) } } diff --git a/src/parser/cli/src/lib.rs b/src/parser/cli/src/lib.rs index a46c8a8f..4d210553 100644 --- a/src/parser/cli/src/lib.rs +++ b/src/parser/cli/src/lib.rs @@ -21,6 +21,9 @@ pub mod cli; pub mod ethereum; /// Common mapping parser for ABI and IDL file mappings. pub mod mapping_parser; +/// Local web UI for browsing decoded transactions from a directory. +#[cfg(feature = "serve")] +pub mod serve; /// Solana-specific CLI handling: IDL mappings, Solana metadata. #[cfg(feature = "solana")] pub mod solana; @@ -45,14 +48,24 @@ pub trait ChainPlugin { fn create_metadata(&self, network: Option) -> Result, String>; } +/// Per-chain CLI args needed to construct plugins. Populated from whichever +/// subcommand is running (`decode`, `serve`, …). +#[derive(Debug, Clone, Default)] +pub(crate) struct PluginArgs { + #[cfg(feature = "ethereum")] + pub ethereum: ethereum::EthereumArgs, + #[cfg(feature = "solana")] + pub solana: solana::SolanaArgs, +} + /// Constructs all enabled chain plugins, each pre-loaded with its CLI args. /// /// **To add a new chain:** create its module, implement [`ChainPlugin`], /// then add one entry here (behind its feature flag) and one -/// `#[command(flatten)]` field to `cli::Args`. +/// `#[command(flatten)]` field to whichever subcommand args struct uses it. #[must_use] #[allow(clippy::vec_init_then_push)] // cfg-gated pushes cannot be expressed as vec![...] -pub(crate) fn build_plugins(args: &cli::Args) -> Vec> { +pub(crate) fn build_plugins(args: &PluginArgs) -> Vec> { let mut plugins: Vec> = vec![]; #[cfg(feature = "ethereum")] diff --git a/src/parser/cli/src/serve.rs b/src/parser/cli/src/serve.rs new file mode 100644 index 00000000..032d83fc --- /dev/null +++ b/src/parser/cli/src/serve.rs @@ -0,0 +1,461 @@ +//! `serve` subcommand: scans a directory of raw-transaction files, decodes +//! every file once, and serves a small local web UI for browsing the results. +//! +//! The server binds to `127.0.0.1` only — this is intentional, the feature +//! is for local triage, not network exposure. There is no auth and no TLS. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + response::Html, + routing::get, +}; +use clap::Args; +use serde::{Deserialize, Serialize}; +use visualsign::SignablePayload; +use visualsign::registry::TransactionConverterRegistry; +use visualsign::vsptrait::VisualSignOptions; + +use crate::PluginArgs; +use crate::cli::{Runtime, prepare_runtime}; + +const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; + +/// Args for the `serve` subcommand. +#[derive(Args, Debug)] +pub struct ServeArgs { + /// Chain identifier (e.g. `ethereum`, `solana`). + #[arg(short, long, help = "Chain type")] + pub chain: String, + + /// Optional network override; same semantics as on `decode`. + #[arg( + long, + short = 'n', + value_name = "NETWORK", + help = "Network identifier - same as the decode subcommand" + )] + pub network: Option, + + /// Directory to scan recursively for raw-transaction files. + #[arg( + long, + value_name = "DIR", + help = "Directory of raw-transaction files (recursive scan)" + )] + pub dir: PathBuf, + + /// TCP port to bind on `127.0.0.1`. + #[arg(long, default_value_t = 8080, help = "Port to bind on 127.0.0.1")] + pub port: u16, + + /// Ethereum-specific CLI args (ABI mappings, etc.). + #[cfg(feature = "ethereum")] + #[command(flatten)] + pub ethereum: crate::ethereum::EthereumArgs, + + /// Solana-specific CLI args (IDL mappings, etc.). + #[cfg(feature = "solana")] + #[command(flatten)] + pub solana: crate::solana::SolanaArgs, +} + +impl ServeArgs { + fn plugin_args(&self) -> PluginArgs { + PluginArgs { + #[cfg(feature = "ethereum")] + ethereum: self.ethereum.clone(), + #[cfg(feature = "solana")] + solana: self.solana.clone(), + } + } +} + +#[derive(Debug, Clone)] +struct DecodedEntry { + rel_path: String, + result: Result, +} + +#[derive(Clone)] +struct AppState { + entries: Arc>, +} + +#[derive(Deserialize)] +struct FileQuery { + path: String, +} + +#[derive(Serialize)] +struct FileResponse<'a> { + path: &'a str, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + payload: Option<&'a SignablePayload>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'a str>, +} + +/// Entry point for the `serve` subcommand. Decodes every file in +/// `args.dir` once, then serves a small local web UI on `127.0.0.1:port`. +/// +/// # Panics +/// +/// Panics if the tokio runtime cannot be constructed — only happens in +/// catastrophic environments (e.g. lacking the ability to create threads). +pub fn execute_serve(args: &ServeArgs) { + let plugin_args = args.plugin_args(); + let runtime = match prepare_runtime(&args.chain, args.network.clone(), &plugin_args) { + Ok(r) => r, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + let chain_str = args.chain.clone(); + let entries = match decode_directory(&args.dir, &chain_str, &runtime) { + Ok(es) => es, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + }; + + if entries.is_empty() { + eprintln!("No transaction files found in {}", args.dir.display()); + return; + } + + let ok = entries.iter().filter(|e| e.result.is_ok()).count(); + let err = entries.len() - ok; + eprintln!( + "Loaded {} entries from {} ({ok} ok, {err} error)", + entries.len(), + args.dir.display(), + ); + + let state = AppState { + entries: Arc::new(entries), + }; + let app = Router::new() + .route("/", get(handle_index)) + .route("/api/file", get(handle_file)) + .with_state(state); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .expect("build tokio runtime"); + + rt.block_on(async move { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], args.port)); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to bind to {addr}: {e}"); + std::process::exit(1); + } + }; + match listener.local_addr() { + Ok(bound) => println!("Serving on http://{bound}"), + Err(_) => println!("Serving on http://{addr}"), + } + if let Err(e) = axum::serve(listener, app).await { + eprintln!("Server error: {e}"); + std::process::exit(1); + } + }); +} + +fn decode_directory( + dir: &Path, + chain_str: &str, + runtime: &Runtime, +) -> Result, String> { + if !dir.exists() { + return Err(format!("directory does not exist: {}", dir.display())); + } + if !dir.is_dir() { + return Err(format!("not a directory: {}", dir.display())); + } + + let mut entries = Vec::new(); + walk(dir, dir, &mut entries, chain_str, runtime)?; + entries.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + Ok(entries) +} + +fn walk( + base: &Path, + current: &Path, + out: &mut Vec, + chain_str: &str, + runtime: &Runtime, +) -> Result<(), String> { + let read = + std::fs::read_dir(current).map_err(|e| format!("read_dir({}): {e}", current.display()))?; + + for entry in read { + let entry = entry.map_err(|e| format!("read_dir entry: {e}"))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with('.') { + continue; + } + let path = entry.path(); + let metadata = match entry.metadata() { + Ok(m) => m, + Err(e) => { + out.push(DecodedEntry { + rel_path: rel_path(base, &path), + result: Err(format!("metadata: {e}")), + }); + continue; + } + }; + if metadata.is_dir() { + walk(base, &path, out, chain_str, runtime)?; + } else if metadata.is_file() { + let rel = rel_path(base, &path); + if metadata.len() > MAX_FILE_SIZE { + out.push(DecodedEntry { + rel_path: rel, + result: Err(format!("file exceeds {MAX_FILE_SIZE} bytes")), + }); + continue; + } + let result = decode_file(&path, chain_str, &runtime.registry, &runtime.options); + out.push(DecodedEntry { + rel_path: rel, + result, + }); + } + } + Ok(()) +} + +fn rel_path(base: &Path, full: &Path) -> String { + full.strip_prefix(base) + .unwrap_or(full) + .display() + .to_string() +} + +fn decode_file( + path: &Path, + chain_str: &str, + registry: &TransactionConverterRegistry, + options: &VisualSignOptions, +) -> Result { + let raw = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("empty file".to_string()); + } + let chain = crate::chains::parse_chain(chain_str); + registry + .convert_transaction(&chain, trimmed, options.clone()) + .map_err(|e| format!("{e:?}")) +} + +async fn handle_index(State(state): State) -> Html { + Html(render_html(&state.entries)) +} + +async fn handle_file( + State(state): State, + Query(q): Query, +) -> Result, StatusCode> { + let entry = state + .entries + .iter() + .find(|e| e.rel_path == q.path) + .ok_or(StatusCode::NOT_FOUND)?; + + let response = match &entry.result { + Ok(payload) => FileResponse { + path: &entry.rel_path, + ok: true, + payload: Some(payload), + error: None, + }, + Err(err) => FileResponse { + path: &entry.rel_path, + ok: false, + payload: None, + error: Some(err), + }, + }; + serde_json::to_value(&response) + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +fn render_html(entries: &[DecodedEntry]) -> String { + use std::fmt::Write as _; + + const STYLE: &str = "body{font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:1100px;margin:1.5em auto;padding:0 1em;color:#222}\ +h1{font-size:1.2em;margin-bottom:1em}\ +details{margin:0.4em 0;border-left:3px solid #ccc;padding:0.4em 0.8em;background:#fafafa}\ +details[open]{background:#fff;border-left-color:#456}\ +summary{font-family:ui-monospace,Menlo,Consolas,monospace;cursor:pointer;font-weight:600}\ +summary.err{color:#b00}\ +pre{background:#f5f5f5;padding:0.8em;overflow:auto;font-size:12.5px;border-radius:3px;margin-top:0.6em}"; + + let ok = entries.iter().filter(|e| e.result.is_ok()).count(); + let err = entries.len() - ok; + + let mut body = String::new(); + let _ = write!( + body, + "

parser_cli — {} entries ({ok} ok, {err} error)

", + entries.len() + ); + + for entry in entries { + match &entry.result { + Ok(payload) => { + let json = serde_json::to_string_pretty(payload) + .unwrap_or_else(|e| format!("(serialization error: {e})")); + let _ = write!( + body, + "
{}
{}
", + html_escape(&entry.rel_path), + html_escape(&json), + ); + } + Err(err) => { + let _ = write!( + body, + "
{} — error
{}
", + html_escape(&entry.rel_path), + html_escape(err), + ); + } + } + } + + format!( + "parser_cli serve{body}" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "vsp_serve_{label}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn make_runtime() -> Runtime { + let plugin_args = PluginArgs::default(); + prepare_runtime( + "ethereum", + Some("ETHEREUM_MAINNET".to_string()), + &plugin_args, + ) + .unwrap() + } + + /// A real EIP-1559 ETH transfer, also used by the integration fixture. + const VALID_HEX: &str = "02f86c0180830f4240843b9aca00830186a094111111111111111111111111111111111111111180b844a9059cbb000000000000000000000000000000000000000000000000000000000000dead00000000000000000000000000000000000000000000000000000000000f4240c0"; + + #[test] + fn decode_directory_mixes_ok_and_err() { + let dir = temp_dir("mixed"); + fs::write(dir.join("a-good.hex"), format!(" {VALID_HEX}\n")).unwrap(); + fs::write(dir.join("b-bad.hex"), "definitely not hex").unwrap(); + fs::write(dir.join("c-empty.hex"), " \n\n").unwrap(); + // Hidden file should be skipped + fs::write(dir.join(".dotfile"), VALID_HEX).unwrap(); + + let runtime = make_runtime(); + let entries = decode_directory(&dir, "ethereum", &runtime).unwrap(); + assert_eq!(entries.len(), 3, "got: {entries:#?}"); + // sorted by rel_path + assert_eq!(entries[0].rel_path, "a-good.hex"); + assert_eq!(entries[1].rel_path, "b-bad.hex"); + assert_eq!(entries[2].rel_path, "c-empty.hex"); + assert!(entries[0].result.is_ok()); + assert!(entries[1].result.is_err()); + assert!(entries[2].result.is_err()); + } + + #[test] + fn decode_directory_recurses() { + let dir = temp_dir("nested"); + let nested = dir.join("inner"); + fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("tx.hex"), VALID_HEX).unwrap(); + + let runtime = make_runtime(); + let entries = decode_directory(&dir, "ethereum", &runtime).unwrap(); + assert_eq!(entries.len(), 1); + assert!(entries[0].rel_path.contains("tx.hex")); + assert!(entries[0].result.is_ok()); + } + + #[test] + fn decode_directory_missing_path_errors() { + let err = decode_directory( + Path::new("/nonexistent/vsp/serve/path"), + "ethereum", + &make_runtime(), + ) + .unwrap_err(); + assert!(err.contains("does not exist"), "got: {err}"); + } + + #[test] + fn html_escape_handles_all_specials() { + assert_eq!( + html_escape("&'"), + "<a href="x">&'</a>" + ); + } + + #[test] + fn render_html_contains_paths_and_payload() { + let dir = temp_dir("html"); + fs::write(dir.join("good.hex"), VALID_HEX).unwrap(); + fs::write(dir.join("bad.hex"), "garbage").unwrap(); + let entries = decode_directory(&dir, "ethereum", &make_runtime()).unwrap(); + let html = render_html(&entries); + assert!(html.contains("good.hex")); + assert!(html.contains("bad.hex")); + assert!(html.contains("Ethereum Transaction")); + assert!(html.contains("class=err")); + } +} diff --git a/src/parser/cli/tests/cli_test.rs b/src/parser/cli/tests/cli_test.rs index 5f9f1bcc..0a50130f 100644 --- a/src/parser/cli/tests/cli_test.rs +++ b/src/parser/cli/tests/cli_test.rs @@ -290,6 +290,7 @@ fn test_cli_transaction_from_stdin() { let mut child = Command::new(env!("CARGO_BIN_EXE_parser_cli")) .args([ + "decode", "--chain", "ethereum", "--network", @@ -330,6 +331,7 @@ fn test_cli_transaction_from_stdin() { fn test_cli_transaction_at_missing_file_errors() { let output = Command::new(env!("CARGO_BIN_EXE_parser_cli")) .args([ + "decode", "--chain", "ethereum", "--network", @@ -375,6 +377,7 @@ fn test_cli_ethereum_abi_json_mappings() { ); let output = run_cli(&[ + "decode", "--chain", "ethereum", "--network", @@ -421,6 +424,7 @@ fn test_cli_ethereum_abi_json_mappings() { #[cfg(feature = "ethereum")] fn test_cli_ethereum_without_abi_uses_builtin_visualizer() { let output = run_cli(&[ + "decode", "--chain", "ethereum", "--network", @@ -456,6 +460,7 @@ fn test_cli_ethereum_abi_invalid_file_still_parses() { let mapping = "Bad:/nonexistent/abi.json:0x1111111111111111111111111111111111111111"; let output = run_cli(&[ + "decode", "--chain", "ethereum", "--network", @@ -524,6 +529,95 @@ fn test_cli_solana_idl_json_mappings() { assert!(json["Fields"].as_array().is_some_and(|f| !f.is_empty())); } +#[test] +#[cfg(all(feature = "ethereum", feature = "serve"))] +fn test_cli_serve_renders_directory() { + use std::io::{BufRead, BufReader, Read as _}; + use std::net::TcpStream; + use std::time::{Duration, Instant}; + + // Use the existing ethereum-from-file.hex as a one-file fixture directory. + let fixture_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures"); + + // Ask the OS for a free port: bind ephemeral, drop, race-tolerable since + // the server immediately re-binds. + let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind"); + let port = probe.local_addr().expect("local_addr").port(); + drop(probe); + + let mut child = Command::new(env!("CARGO_BIN_EXE_parser_cli")) + .args([ + "serve", + "--chain", + "ethereum", + "--network", + "ETHEREUM_MAINNET", + "--dir", + fixture_dir.to_str().unwrap(), + "--port", + &port.to_string(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn parser_cli serve"); + + // Wait for the "Serving on" line on stdout, with a hard timeout. + let stdout = child.stdout.take().expect("stdout"); + let mut reader = BufReader::new(stdout); + let deadline = Instant::now() + Duration::from_secs(15); + let mut ready = false; + let mut greeting = String::new(); + while Instant::now() < deadline { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + std::thread::sleep(Duration::from_millis(50)); + continue; + } + greeting.push_str(&line); + if line.contains("Serving on") { + ready = true; + break; + } + } + assert!(ready, "server never printed ready line. got: {greeting}"); + + // Hit `/` over a raw TCP socket — keep the test free of new HTTP-client deps. + let body = http_get(port, "/"); + assert!( + body.contains("parser_cli serve"), + "got: {body}" + ); + assert!(body.contains("ethereum-from-file.hex")); + assert!(body.contains("Ethereum Transaction")); + + // JSON endpoint round-trip. + let api = http_get(port, "/api/file?path=ethereum-from-file.hex"); + let parsed: serde_json::Value = { + // body is "headers\r\n\r\nbody" + let payload = api.split("\r\n\r\n").nth(1).unwrap_or(""); + serde_json::from_str(payload).expect("api response should be JSON") + }; + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["payload"]["Title"], "Ethereum Transaction"); + + // Cleanup. + let _ = child.kill(); + let _ = child.wait(); + + fn http_get(port: u16, path: &str) -> String { + let mut s = TcpStream::connect(("127.0.0.1", port)).expect("tcp connect"); + s.set_read_timeout(Some(Duration::from_secs(5))).ok(); + let req = format!("GET {path} HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + std::io::Write::write_all(&mut s, req.as_bytes()).expect("write req"); + let mut buf = String::new(); + s.read_to_string(&mut buf).expect("read response"); + buf + } +} + #[test] #[cfg(feature = "solana")] fn test_cli_solana_idl_invalid_file_still_parses() { diff --git a/src/parser/cli/tests/fixtures/ethereum-from-file.input b/src/parser/cli/tests/fixtures/ethereum-from-file.input index 1c530615..2818c718 100644 --- a/src/parser/cli/tests/fixtures/ethereum-from-file.input +++ b/src/parser/cli/tests/fixtures/ethereum-from-file.input @@ -1,3 +1,4 @@ +decode --chain ethereum --network diff --git a/src/parser/cli/tests/fixtures/ethereum-json.input b/src/parser/cli/tests/fixtures/ethereum-json.input index ac57b6e3..9d48df8d 100644 --- a/src/parser/cli/tests/fixtures/ethereum-json.input +++ b/src/parser/cli/tests/fixtures/ethereum-json.input @@ -1,3 +1,4 @@ +decode --chain ethereum --network diff --git a/src/parser/cli/tests/fixtures/solana-json.input b/src/parser/cli/tests/fixtures/solana-json.input index 85c47013..523f921d 100644 --- a/src/parser/cli/tests/fixtures/solana-json.input +++ b/src/parser/cli/tests/fixtures/solana-json.input @@ -1,3 +1,4 @@ +decode --chain solana -o diff --git a/src/parser/cli/tests/fixtures/solana-text.input b/src/parser/cli/tests/fixtures/solana-text.input index 5cdcb939..b7cedaf2 100644 --- a/src/parser/cli/tests/fixtures/solana-text.input +++ b/src/parser/cli/tests/fixtures/solana-text.input @@ -1,3 +1,4 @@ +decode --chain solana -t From 613cb679f413a52a319e39ce4320dc912332ec9f Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Thu, 30 Apr 2026 04:32:37 +0000 Subject: [PATCH 2/5] feat(cli): live reload + JSON passthrough for `serve` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-decode the directory on every HTTP request instead of caching at startup, so editing a fixture and refreshing the browser is enough to see the new state — no server restart needed. Useful when iterating on fixtures across multiple worktrees. Files with a `.json` extension are parsed as `serde_json::Value` and served as-is, so a directory of `.expected` outputs (or any pre-recorded JSON) can be browsed without going through the chain decoder. Other files take the existing hex-decode path. Mixed dirs work either way. `DecodedEntry.result` and `FileResponse.payload` switch from `SignablePayload` to `serde_json::Value` so both decode paths share one shape. Re-decoding moves into the request handlers via `tokio::task::spawn_blocking` to keep fs+CPU off the current-thread async executor. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/parser/cli/src/serve.rs | 169 ++++++++++++++++++++++--------- src/parser/cli/tests/cli_test.rs | 115 +++++++++++++++++++++ 2 files changed, 238 insertions(+), 46 deletions(-) diff --git a/src/parser/cli/src/serve.rs b/src/parser/cli/src/serve.rs index 032d83fc..7741a62c 100644 --- a/src/parser/cli/src/serve.rs +++ b/src/parser/cli/src/serve.rs @@ -1,8 +1,14 @@ //! `serve` subcommand: scans a directory of raw-transaction files, decodes -//! every file once, and serves a small local web UI for browsing the results. +//! every file on each request, and serves a small local web UI for browsing +//! the results. `.json` files are passed through as-is; other files are +//! decoded as raw transactions through the chain registry. //! //! The server binds to `127.0.0.1` only — this is intentional, the feature //! is for local triage, not network exposure. There is no auth and no TLS. +//! +//! Re-decoding happens on every HTTP request rather than once at startup, +//! so editing a fixture and refreshing the browser is enough to see the +//! new state — no server restart needed. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -16,7 +22,6 @@ use axum::{ }; use clap::Args; use serde::{Deserialize, Serialize}; -use visualsign::SignablePayload; use visualsign::registry::TransactionConverterRegistry; use visualsign::vsptrait::VisualSignOptions; @@ -78,12 +83,14 @@ impl ServeArgs { #[derive(Debug, Clone)] struct DecodedEntry { rel_path: String, - result: Result, + result: Result, } #[derive(Clone)] struct AppState { - entries: Arc>, + dir: Arc, + chain: Arc, + runtime: Arc, } #[derive(Deserialize)] @@ -96,13 +103,15 @@ struct FileResponse<'a> { path: &'a str, ok: bool, #[serde(skip_serializing_if = "Option::is_none")] - payload: Option<&'a SignablePayload>, + payload: Option<&'a serde_json::Value>, #[serde(skip_serializing_if = "Option::is_none")] error: Option<&'a str>, } -/// Entry point for the `serve` subcommand. Decodes every file in -/// `args.dir` once, then serves a small local web UI on `127.0.0.1:port`. +/// Entry point for the `serve` subcommand. Validates the directory, then +/// serves a small local web UI on `127.0.0.1:port`. Each request triggers +/// a fresh re-walk and re-decode of the directory — refresh the browser to +/// see edits. /// /// # Panics /// @@ -118,30 +127,17 @@ pub fn execute_serve(args: &ServeArgs) { } }; - let chain_str = args.chain.clone(); - let entries = match decode_directory(&args.dir, &chain_str, &runtime) { - Ok(es) => es, - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - }; - - if entries.is_empty() { - eprintln!("No transaction files found in {}", args.dir.display()); - return; + if let Err(e) = validate_dir(&args.dir) { + eprintln!("Error: {e}"); + std::process::exit(1); } - let ok = entries.iter().filter(|e| e.result.is_ok()).count(); - let err = entries.len() - ok; - eprintln!( - "Loaded {} entries from {} ({ok} ok, {err} error)", - entries.len(), - args.dir.display(), - ); + eprintln!("Watching {} (re-decoded per request)", args.dir.display()); let state = AppState { - entries: Arc::new(entries), + dir: Arc::new(args.dir.clone()), + chain: Arc::new(args.chain.clone()), + runtime: Arc::new(runtime), }; let app = Router::new() .route("/", get(handle_index)) @@ -173,17 +169,22 @@ pub fn execute_serve(args: &ServeArgs) { }); } -fn decode_directory( - dir: &Path, - chain_str: &str, - runtime: &Runtime, -) -> Result, String> { +fn validate_dir(dir: &Path) -> Result<(), String> { if !dir.exists() { return Err(format!("directory does not exist: {}", dir.display())); } if !dir.is_dir() { return Err(format!("not a directory: {}", dir.display())); } + Ok(()) +} + +fn decode_directory( + dir: &Path, + chain_str: &str, + runtime: &Runtime, +) -> Result, String> { + validate_dir(dir)?; let mut entries = Vec::new(); walk(dir, dir, &mut entries, chain_str, runtime)?; @@ -252,28 +253,50 @@ fn decode_file( chain_str: &str, registry: &TransactionConverterRegistry, options: &VisualSignOptions, -) -> Result { +) -> Result { let raw = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; let trimmed = raw.trim(); if trimmed.is_empty() { return Err("empty file".to_string()); } + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + return serde_json::from_str::(trimmed) + .map_err(|e| format!("invalid json: {e}")); + } + let chain = crate::chains::parse_chain(chain_str); - registry + let payload = registry .convert_transaction(&chain, trimmed, options.clone()) - .map_err(|e| format!("{e:?}")) + .map_err(|e| format!("{e:?}"))?; + serde_json::to_value(&payload).map_err(|e| format!("serialize: {e}")) } -async fn handle_index(State(state): State) -> Html { - Html(render_html(&state.entries)) +async fn load_entries(state: &AppState) -> Result, String> { + let dir = Arc::clone(&state.dir); + let chain = Arc::clone(&state.chain); + let runtime = Arc::clone(&state.runtime); + tokio::task::spawn_blocking(move || decode_directory(&dir, &chain, &runtime)) + .await + .map_err(|e| format!("join: {e}"))? +} + +async fn handle_index(State(state): State) -> Result, StatusCode> { + let entries = match load_entries(&state).await { + Ok(es) => es, + Err(e) => return Ok(Html(render_error_page(&e))), + }; + Ok(Html(render_html(&entries))) } async fn handle_file( State(state): State, Query(q): Query, ) -> Result, StatusCode> { - let entry = state - .entries + let entries = load_entries(&state) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let entry = entries .iter() .find(|e| e.rel_path == q.path) .ok_or(StatusCode::NOT_FOUND)?; @@ -312,16 +335,17 @@ fn html_escape(s: &str) -> String { out } -fn render_html(entries: &[DecodedEntry]) -> String { - use std::fmt::Write as _; - - const STYLE: &str = "body{font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:1100px;margin:1.5em auto;padding:0 1em;color:#222}\ +const STYLE: &str = "body{font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:1100px;margin:1.5em auto;padding:0 1em;color:#222}\ h1{font-size:1.2em;margin-bottom:1em}\ details{margin:0.4em 0;border-left:3px solid #ccc;padding:0.4em 0.8em;background:#fafafa}\ details[open]{background:#fff;border-left-color:#456}\ summary{font-family:ui-monospace,Menlo,Consolas,monospace;cursor:pointer;font-weight:600}\ summary.err{color:#b00}\ -pre{background:#f5f5f5;padding:0.8em;overflow:auto;font-size:12.5px;border-radius:3px;margin-top:0.6em}"; +pre{background:#f5f5f5;padding:0.8em;overflow:auto;font-size:12.5px;border-radius:3px;margin-top:0.6em}\ +footer{margin-top:1.5em;color:#888;font-size:0.85em}"; + +fn render_html(entries: &[DecodedEntry]) -> String { + use std::fmt::Write as _; let ok = entries.iter().filter(|e| e.result.is_ok()).count(); let err = entries.len() - ok; @@ -333,10 +357,14 @@ pre{background:#f5f5f5;padding:0.8em;overflow:auto;font-size:12.5px;border-radiu entries.len() ); + if entries.is_empty() { + body.push_str("

No files found in directory.

"); + } + for entry in entries { match &entry.result { - Ok(payload) => { - let json = serde_json::to_string_pretty(payload) + Ok(value) => { + let json = serde_json::to_string_pretty(value) .unwrap_or_else(|e| format!("(serialization error: {e})")); let _ = write!( body, @@ -356,11 +384,20 @@ pre{background:#f5f5f5;padding:0.8em;overflow:auto;font-size:12.5px;border-radiu } } + body.push_str("
Refresh to re-decode from disk. .json files are served as-is; everything else is decoded through the chain registry.
"); + format!( "parser_cli serve{body}" ) } +fn render_error_page(msg: &str) -> String { + format!( + "parser_cli serve

parser_cli — error

{}
Refresh once the underlying issue is fixed.
", + html_escape(msg) + ) +} + #[cfg(test)] mod tests { use super::*; @@ -438,6 +475,45 @@ mod tests { assert!(err.contains("does not exist"), "got: {err}"); } + #[test] + fn json_files_passthrough_as_is() { + let dir = temp_dir("json_passthrough"); + let payload = serde_json::json!({"hello": "world", "n": 42}); + fs::write(dir.join("expected.json"), payload.to_string()).unwrap(); + + let entries = decode_directory(&dir, "ethereum", &make_runtime()).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].rel_path, "expected.json"); + let value = entries[0].result.as_ref().expect("json should parse"); + assert_eq!(value, &payload); + } + + #[test] + fn malformed_json_errors_with_invalid_json_prefix() { + let dir = temp_dir("json_bad"); + fs::write(dir.join("bad.json"), "{not json}").unwrap(); + + let entries = decode_directory(&dir, "ethereum", &make_runtime()).unwrap(); + assert_eq!(entries.len(), 1); + let err = entries[0].result.as_ref().unwrap_err(); + assert!(err.starts_with("invalid json:"), "got: {err}"); + } + + #[test] + fn mixed_hex_and_json_directory_decodes_both() { + let dir = temp_dir("mixed_hex_json"); + fs::write(dir.join("a.hex"), VALID_HEX).unwrap(); + fs::write(dir.join("b.json"), r#"{"sentinel":"value"}"#).unwrap(); + + let entries = decode_directory(&dir, "ethereum", &make_runtime()).unwrap(); + assert_eq!(entries.len(), 2); + // Both succeed, but via different paths. + let hex_value = entries[0].result.as_ref().expect("hex should decode"); + assert_eq!(hex_value["Title"], "Ethereum Transaction"); + let json_value = entries[1].result.as_ref().expect("json should parse"); + assert_eq!(json_value["sentinel"], "value"); + } + #[test] fn html_escape_handles_all_specials() { assert_eq!( @@ -457,5 +533,6 @@ mod tests { assert!(html.contains("bad.hex")); assert!(html.contains("Ethereum Transaction")); assert!(html.contains("class=err")); + assert!(html.contains("Refresh to re-decode")); } } diff --git a/src/parser/cli/tests/cli_test.rs b/src/parser/cli/tests/cli_test.rs index 0a50130f..b53db847 100644 --- a/src/parser/cli/tests/cli_test.rs +++ b/src/parser/cli/tests/cli_test.rs @@ -618,6 +618,121 @@ fn test_cli_serve_renders_directory() { } } +#[test] +#[cfg(all(feature = "ethereum", feature = "serve"))] +fn test_cli_serve_live_reload_and_json_passthrough() { + use std::io::{BufRead, BufReader, Read as _}; + use std::net::TcpStream; + use std::time::{Duration, Instant}; + + // Working directory we can mutate between requests. + let work = std::env::temp_dir().join(format!( + "vsp_serve_live_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&work).unwrap(); + + let valid_hex = "02f86c0180830f4240843b9aca00830186a094111111111111111111111111111111111111111180b844a9059cbb000000000000000000000000000000000000000000000000000000000000dead00000000000000000000000000000000000000000000000000000000000f4240c0"; + fs::write(work.join("tx.hex"), valid_hex).unwrap(); + fs::write(work.join("expected.json"), r#"{"sentinel":"first"}"#).unwrap(); + + let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind"); + let port = probe.local_addr().expect("local_addr").port(); + drop(probe); + + let mut child = Command::new(env!("CARGO_BIN_EXE_parser_cli")) + .args([ + "serve", + "--chain", + "ethereum", + "--network", + "ETHEREUM_MAINNET", + "--dir", + work.to_str().unwrap(), + "--port", + &port.to_string(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn parser_cli serve"); + + let stdout = child.stdout.take().expect("stdout"); + let mut reader = BufReader::new(stdout); + let deadline = Instant::now() + Duration::from_secs(15); + let mut ready = false; + let mut greeting = String::new(); + while Instant::now() < deadline { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + std::thread::sleep(Duration::from_millis(50)); + continue; + } + greeting.push_str(&line); + if line.contains("Serving on") { + ready = true; + break; + } + } + assert!(ready, "server never printed ready line. got: {greeting}"); + + fn http_get(port: u16, path: &str) -> String { + let mut s = TcpStream::connect(("127.0.0.1", port)).expect("tcp connect"); + s.set_read_timeout(Some(Duration::from_secs(5))).ok(); + let req = format!("GET {path} HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + std::io::Write::write_all(&mut s, req.as_bytes()).expect("write req"); + let mut buf = String::new(); + s.read_to_string(&mut buf).expect("read response"); + buf + } + + fn parse_json_body(api: &str) -> serde_json::Value { + let payload = api.split("\r\n\r\n").nth(1).unwrap_or(""); + serde_json::from_str(payload).expect("api response should be JSON") + } + + // 1. Initial: hex decodes, json passes through verbatim. + let hex_resp = parse_json_body(&http_get(port, "/api/file?path=tx.hex")); + assert_eq!(hex_resp["ok"], true); + assert_eq!(hex_resp["payload"]["Title"], "Ethereum Transaction"); + + let json_resp = parse_json_body(&http_get(port, "/api/file?path=expected.json")); + assert_eq!(json_resp["ok"], true); + assert_eq!(json_resp["payload"]["sentinel"], "first"); + + // 2. Mutate the JSON file on disk and verify the next GET reflects it + // without restarting the server. + fs::write(work.join("expected.json"), r#"{"sentinel":"second"}"#).unwrap(); + let json_resp2 = parse_json_body(&http_get(port, "/api/file?path=expected.json")); + assert_eq!(json_resp2["payload"]["sentinel"], "second"); + + // 3. Drop in a brand-new file and verify it shows up in /. + fs::write( + work.join("late.json"), + r#"{"created":"after-server-start"}"#, + ) + .unwrap(); + let body = http_get(port, "/"); + assert!( + body.contains("late.json"), + "new file missing from /. got: {body}" + ); + + // 4. Truncate the hex file and verify it flips to error state. + fs::write(work.join("tx.hex"), "").unwrap(); + let hex_resp2 = parse_json_body(&http_get(port, "/api/file?path=tx.hex")); + assert_eq!(hex_resp2["ok"], false); + + // Cleanup. + let _ = child.kill(); + let _ = child.wait(); + let _ = fs::remove_dir_all(&work); +} + #[test] #[cfg(feature = "solana")] fn test_cli_solana_idl_invalid_file_still_parses() { From 8cd5ba11c3f425be32b938f2f5b4cb595ef0c697 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Thu, 30 Apr 2026 23:20:15 +0000 Subject: [PATCH 3/5] feat(cli): default `serve` port to 47474 (avoid 8080 conflict) The dev container already uses 8080 for REST, and 8080 collides with just about every other local dev tool. 47474 is uncommon enough to avoid most conflicts and easy enough to type. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/parser/cli/src/serve.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parser/cli/src/serve.rs b/src/parser/cli/src/serve.rs index 7741a62c..56617438 100644 --- a/src/parser/cli/src/serve.rs +++ b/src/parser/cli/src/serve.rs @@ -55,7 +55,7 @@ pub struct ServeArgs { pub dir: PathBuf, /// TCP port to bind on `127.0.0.1`. - #[arg(long, default_value_t = 8080, help = "Port to bind on 127.0.0.1")] + #[arg(long, default_value_t = 47474, help = "Port to bind on 127.0.0.1")] pub port: u16, /// Ethereum-specific CLI args (ABI mappings, etc.). From ef1fe2093d1c5e72cc4cdde37c1c040e7a19347f Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Thu, 30 Apr 2026 23:28:46 +0000 Subject: [PATCH 4/5] feat(cli): browseable standalone URLs for `serve` entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a wildcard route so any rel path under the served directory becomes a bookmarkable URL — `GET /token_2022/transfer_checked.json` returns that file's payload directly as JSON, no envelope. Useful when sharing a fixture's decoded view (e.g. through a Cloud Workstations tunnel) or when piping into curl/jq. Each entry on the index page now has a small `[json]` link next to its path, pointing at the standalone URL. Clicking the path text still toggles the `
` block; the `[json]` link navigates. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/parser/cli/src/serve.rs | 69 +++++++++++++++++++++++++++++--- src/parser/cli/tests/cli_test.rs | 14 +++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/parser/cli/src/serve.rs b/src/parser/cli/src/serve.rs index 56617438..602abce0 100644 --- a/src/parser/cli/src/serve.rs +++ b/src/parser/cli/src/serve.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use axum::{ Json, Router, - extract::{Query, State}, + extract::{Path as AxumPath, Query, State}, http::StatusCode, response::Html, routing::get, @@ -142,6 +142,7 @@ pub fn execute_serve(args: &ServeArgs) { let app = Router::new() .route("/", get(handle_index)) .route("/api/file", get(handle_file)) + .route("/{*path}", get(handle_payload)) .with_state(state); let rt = tokio::runtime::Builder::new_current_thread() @@ -289,6 +290,28 @@ async fn handle_index(State(state): State) -> Result, Sta Ok(Html(render_html(&entries))) } +/// Serve the decoded payload for a single file by its rel-path. Lets each +/// entry have its own bookmarkable / shareable URL — e.g. +/// `/token_2022/transfer_checked.json` returns just that file's payload as +/// JSON. Wraps no envelope around it so browsers and `curl` see the raw +/// `SignablePayload` (or the verbatim file content for `.json` passthrough). +async fn handle_payload( + State(state): State, + AxumPath(path): AxumPath, +) -> Result, (StatusCode, String)> { + let entries = load_entries(&state) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + let entry = entries + .iter() + .find(|e| e.rel_path == path) + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("not found: {path}\n")))?; + match &entry.result { + Ok(value) => Ok(Json(value.clone())), + Err(err) => Err((StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n"))), + } +} + async fn handle_file( State(state): State, Query(q): Query, @@ -320,6 +343,27 @@ async fn handle_file( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } +/// Percent-encode the segments that need it so a rel path becomes a URL. +/// Segment separators (`/`) are preserved. Common safe filename characters +/// (alphanumerics, `-`, `_`, `.`) are left alone; everything else is +/// escaped. Sufficient for filesystem rel paths; not a general-purpose +/// URL encoder. +fn url_encode_path(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + out.push(b as char); + } + _ => { + use std::fmt::Write as _; + let _ = write!(out, "%{b:02X}"); + } + } + } + out +} + fn html_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { @@ -341,6 +385,8 @@ details{margin:0.4em 0;border-left:3px solid #ccc;padding:0.4em 0.8em;background details[open]{background:#fff;border-left-color:#456}\ summary{font-family:ui-monospace,Menlo,Consolas,monospace;cursor:pointer;font-weight:600}\ summary.err{color:#b00}\ +summary a.open{font-weight:400;color:#456;text-decoration:none;margin-left:0.5em;font-size:0.85em}\ +summary a.open:hover{text-decoration:underline}\ pre{background:#f5f5f5;padding:0.8em;overflow:auto;font-size:12.5px;border-radius:3px;margin-top:0.6em}\ footer{margin-top:1.5em;color:#888;font-size:0.85em}"; @@ -362,22 +408,22 @@ fn render_html(entries: &[DecodedEntry]) -> String { } for entry in entries { + let escaped_path = html_escape(&entry.rel_path); + let url_path = url_encode_path(&entry.rel_path); match &entry.result { Ok(value) => { let json = serde_json::to_string_pretty(value) .unwrap_or_else(|e| format!("(serialization error: {e})")); let _ = write!( body, - "
{}
{}
", - html_escape(&entry.rel_path), + "
{escaped_path} [json]
{}
", html_escape(&json), ); } Err(err) => { let _ = write!( body, - "
{} — error
{}
", - html_escape(&entry.rel_path), + "
{escaped_path} — error [json]
{}
", html_escape(err), ); } @@ -514,6 +560,16 @@ mod tests { assert_eq!(json_value["sentinel"], "value"); } + #[test] + fn url_encode_path_preserves_separators_and_safe_chars() { + assert_eq!( + url_encode_path("token_2022/transfer_checked.json"), + "token_2022/transfer_checked.json" + ); + assert_eq!(url_encode_path("a b/c.hex"), "a%20b/c.hex"); + assert_eq!(url_encode_path("dir/file?weird"), "dir/file%3Fweird"); + } + #[test] fn html_escape_handles_all_specials() { assert_eq!( @@ -534,5 +590,8 @@ mod tests { assert!(html.contains("Ethereum Transaction")); assert!(html.contains("class=err")); assert!(html.contains("Refresh to re-decode")); + // Each entry exposes a standalone-link to its rel-path. + assert!(html.contains("href=\"/good.hex\""), "got: {html}"); + assert!(html.contains("href=\"/bad.hex\""), "got: {html}"); } } diff --git a/src/parser/cli/tests/cli_test.rs b/src/parser/cli/tests/cli_test.rs index b53db847..6ce03836 100644 --- a/src/parser/cli/tests/cli_test.rs +++ b/src/parser/cli/tests/cli_test.rs @@ -704,6 +704,20 @@ fn test_cli_serve_live_reload_and_json_passthrough() { assert_eq!(json_resp["ok"], true); assert_eq!(json_resp["payload"]["sentinel"], "first"); + // 1b. Standalone payload route: the rel-path itself is browseable and + // returns the bare payload (no envelope). + let standalone_hex = parse_json_body(&http_get(port, "/tx.hex")); + assert_eq!(standalone_hex["Title"], "Ethereum Transaction"); + let standalone_json = parse_json_body(&http_get(port, "/expected.json")); + assert_eq!(standalone_json["sentinel"], "first"); + + // Unknown path 404s on the standalone route. + let missing = http_get(port, "/no-such-file.hex"); + assert!( + missing.contains(" 404 "), + "expected 404 status line, got: {missing}" + ); + // 2. Mutate the JSON file on disk and verify the next GET reflects it // without restarting the server. fs::write(work.join("expected.json"), r#"{"sentinel":"second"}"#).unwrap(); From cadccbf827d9a1beadc493a45a77e1d8346cc498 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Mon, 4 May 2026 23:47:18 +0000 Subject: [PATCH 5/5] feat(cli): mobile-friendly `serve` page + per-entry copy button Adds the missing viewport meta tag (the actual reason the page wasn't mobile-friendly), small responsive CSS tweaks, and a vanilla-JS copy button next to each entry's `[json]` link. --- src/parser/cli/src/serve.rs | 48 +++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/parser/cli/src/serve.rs b/src/parser/cli/src/serve.rs index 602abce0..091cfb22 100644 --- a/src/parser/cli/src/serve.rs +++ b/src/parser/cli/src/serve.rs @@ -385,10 +385,28 @@ details{margin:0.4em 0;border-left:3px solid #ccc;padding:0.4em 0.8em;background details[open]{background:#fff;border-left-color:#456}\ summary{font-family:ui-monospace,Menlo,Consolas,monospace;cursor:pointer;font-weight:600}\ summary.err{color:#b00}\ -summary a.open{font-weight:400;color:#456;text-decoration:none;margin-left:0.5em;font-size:0.85em}\ +summary .path{word-break:break-all}\ +summary a.open,summary button.copy{font-weight:400;color:#456;text-decoration:none;margin-left:0.5em;font-size:0.85em;display:inline-block;padding:0.25em 0.5em;font-family:inherit}\ +summary button.copy{background:#eef;border:1px solid #cce;border-radius:3px;cursor:pointer}\ +summary button.copy:hover{background:#dde}\ +summary button.copy.copied{background:#dfd;border-color:#9c9}\ summary a.open:hover{text-decoration:underline}\ -pre{background:#f5f5f5;padding:0.8em;overflow:auto;font-size:12.5px;border-radius:3px;margin-top:0.6em}\ -footer{margin-top:1.5em;color:#888;font-size:0.85em}"; +pre{background:#f5f5f5;padding:0.8em;overflow:auto;font-size:12px;border-radius:3px;margin-top:0.6em;white-space:pre}\ +footer{margin-top:1.5em;color:#888;font-size:0.85em}\ +@media (max-width:600px){body{margin:0.5em auto;padding:0 0.5em}h1{font-size:1.05em}}"; + +const COPY_SCRIPT: &str = "document.addEventListener('click',function(e){\ +var btn=e.target.closest('button.copy');\ +if(!btn)return;\ +e.preventDefault();e.stopPropagation();\ +var pre=btn.closest('details').querySelector('pre');\ +if(!pre||!navigator.clipboard)return;\ +navigator.clipboard.writeText(pre.textContent).then(function(){\ +var orig=btn.textContent;\ +btn.textContent='copied!';btn.classList.add('copied');\ +setTimeout(function(){btn.textContent=orig;btn.classList.remove('copied');},1200);\ +});\ +});"; fn render_html(entries: &[DecodedEntry]) -> String { use std::fmt::Write as _; @@ -416,14 +434,14 @@ fn render_html(entries: &[DecodedEntry]) -> String { .unwrap_or_else(|e| format!("(serialization error: {e})")); let _ = write!( body, - "
{escaped_path} [json]
{}
", + "
{escaped_path} [json]
{}
", html_escape(&json), ); } Err(err) => { let _ = write!( body, - "
{escaped_path} — error [json]
{}
", + "
{escaped_path} — error [json]
{}
", html_escape(err), ); } @@ -433,13 +451,13 @@ fn render_html(entries: &[DecodedEntry]) -> String { body.push_str("
Refresh to re-decode from disk. .json files are served as-is; everything else is decoded through the chain registry.
"); format!( - "parser_cli serve{body}" + "parser_cli serve{body}" ) } fn render_error_page(msg: &str) -> String { format!( - "parser_cli serve

parser_cli — error

{}
Refresh once the underlying issue is fixed.
", + "parser_cli serve

parser_cli — error

{}
", html_escape(msg) ) } @@ -593,5 +611,21 @@ mod tests { // Each entry exposes a standalone-link to its rel-path. assert!(html.contains("href=\"/good.hex\""), "got: {html}"); assert!(html.contains("href=\"/bad.hex\""), "got: {html}"); + // Mobile-friendly: viewport meta tag present. + assert!( + html.contains("name=viewport") && html.contains("width=device-width"), + "got: {html}" + ); + // Each entry has a copy button + the inline script that wires it up. + let copy_buttons = html.matches("